feat(vault): 授权来源候选扩至 37 项并支持手填自动记入

问题:凭据表单的「授权来源」只有 6 项 datalist 建议,缺国内常见的支付宝/淘宝/
微博等三方登录,且移动端 iOS Safari 对 datalist 支持不稳,导致没法填。

- api.js: OAUTH_PROVIDERS(6) → OAUTH_PRESETS(37,按国内/国际/开发者分组,
  key 为入库值、label 为展示名);新增 oauthLabel/oauthDetail 展示中文名,
  normalizeOauth 把中文输入归一化成英文 slug,避免同一来源存成多种写法
- modals.js: 授权来源改为「输入框 + 分组芯片」选择器,随输入过滤(中英文均可
  搜)、可一键清空、误填项可划掉;无匹配时提示将按原样保存
- store.js: 手填的非预设值记入 localStorage 候选,并在拉取凭据列表后与服务端
  值对账,换设备也能看到以前填过的来源;被划掉的记入 ignore 不再冒回
- credential_service.py: 凭据库搜索纳入 oauth_provider
This commit is contained in:
gouki
2026-09-10 09:27:52 +00:00
parent cc8c91ddd9
commit 49c4dc9af5
5 changed files with 191 additions and 13 deletions
+62 -1
View File
@@ -4,6 +4,25 @@ const Api = window.VpsApi;
const Fmt = window.VpsFmt;
/* ---------------- 全局状态 ---------------- */
/* 手动填入的授权来源候选(本地留存)
*
* credentials 表只存条目本身,不维护来源字典;为了让「这次手填的值下次还能点」,
* 把非预设值记在 localStorageoauth_custom 是候选队列,oauth_ignored 是被用户
* 划掉过的值(防止从凭据列表反推时又冒回来)。
*/
const OAUTH_CUSTOM_KEY = 'vps_oauth_custom';
const OAUTH_IGNORED_KEY = 'vps_oauth_ignored';
function _readOauthList(k) {
try {
const raw = JSON.parse(localStorage.getItem(k) || '[]');
return Array.isArray(raw) ? raw.filter(x => typeof x === 'string' && x) : [];
} catch (e) { return []; }
}
function _writeOauthList(k, list) {
try { localStorage.setItem(k, JSON.stringify(list.slice(0, 24))); } catch (e) { /* 隐私模式写入失败只影响候选展示 */ }
}
const store = reactive({
view: 'dashboard',
assets: [],
@@ -34,6 +53,8 @@ const store = reactive({
credentialSaving: false, // 保存锁,防双击重复提交
vaultSearch: '',
vaultOtpOnly: false, // 只看已绑定 2FA 的条目
oauthCustom: _readOauthList(OAUTH_CUSTOM_KEY), // 手填来源候选(预设之外)
oauthIgnored: _readOauthList(OAUTH_IGNORED_KEY), // 已划掉的手填来源
});
function applyDark() {
@@ -96,6 +117,7 @@ async function loadCredentials() {
if (store.vaultSearch) params.set('q', store.vaultSearch);
if (store.vaultOtpOnly) params.set('has_otp', 'true');
store.credentials = await Api.get('/credentials?' + params.toString());
syncOauthCustom();
}
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
@@ -224,6 +246,41 @@ async function deleteAccount(a) {
}
/* ---------------- 凭据库(密码 + 2FA ---------------- */
// 手填来源记入候选:预设内的值不记(清单已在 api.js),并撤销先前的「划掉」
function rememberOauthProvider(raw) {
const key = Fmt.normalizeOauth(raw);
if (!key || Fmt.OAUTH_PRESETS.some(p => p.key === key)) return;
store.oauthIgnored = store.oauthIgnored.filter(x => x !== key);
_writeOauthList(OAUTH_IGNORED_KEY, store.oauthIgnored);
store.oauthCustom = [key, ...store.oauthCustom.filter(x => x !== key)];
_writeOauthList(OAUTH_CUSTOM_KEY, store.oauthCustom);
}
// 划掉一个候选(错别字等):同时进 ignore 名单,避免从凭据列表反推时再冒回来
function dropOauthProvider(raw) {
const key = Fmt.normalizeOauth(raw);
if (!key) return;
store.oauthCustom = store.oauthCustom.filter(x => x !== key);
_writeOauthList(OAUTH_CUSTOM_KEY, store.oauthCustom);
if (!store.oauthIgnored.includes(key)) {
store.oauthIgnored = [...store.oauthIgnored, key];
_writeOauthList(OAUTH_IGNORED_KEY, store.oauthIgnored);
}
}
// 候选跟服务端的值对账:换设备/别人录过的来源也能直接点(忽略名单优先)
function syncOauthCustom() {
const used = [];
for (const c of store.credentials) {
const key = Fmt.normalizeOauth(c.oauth_provider);
if (!key || store.oauthIgnored.includes(key)) continue;
if (Fmt.OAUTH_PRESETS.some(p => p.key === key)) continue;
if (!used.includes(key)) used.push(key);
}
const merged = [...store.oauthCustom, ...used.filter(k => !store.oauthCustom.includes(k))];
if (merged.length !== store.oauthCustom.length || merged.some((k, i) => k !== store.oauthCustom[i])) {
store.oauthCustom = merged;
_writeOauthList(OAUTH_CUSTOM_KEY, merged);
}
}
function emptyCredentialForm() {
return {
site: '', username: '', login_type: 'password', oauth_provider: '',
@@ -253,9 +310,12 @@ async function saveCredential() {
store.credentialSaving = true;
const f = store.credentialModal.form;
const editing = store.credentialModal.editing;
// 授权来源允许手填(中文/英文均可),归一化后回写表单,保证候选高亮与入库值一致
const oauthKey = f.login_type === 'oauth' ? Fmt.normalizeOauth(f.oauth_provider) : '';
f.oauth_provider = oauthKey;
const payload = {
site: f.site, username: f.username || null, login_type: f.login_type,
oauth_provider: f.oauth_provider || null, url: f.url || null, note: f.note || null,
oauth_provider: oauthKey || null, url: f.url || null, note: f.note || null,
};
try {
if (editing) {
@@ -272,6 +332,7 @@ async function saveCredential() {
await Api.post('/credentials', payload);
}
store.credentialModal.show = false;
if (oauthKey) rememberOauthProvider(oauthKey);
// 账号侧 has_login_password / has_otp 来自关联凭据,需一并刷新
await Promise.all([loadCredentials(), loadAccounts()]);
} catch (e) { store.error = e.message; }