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
+66 -2
View File
@@ -28,6 +28,49 @@ window.VpsApi = (function () {
};
})();
/* 授权登录来源预设:key = 入库值(oauth_provider,统一英文 slug),label = 展示名,
* group = 表单分组顺序。国内三方登录排在最前(支付宝 / 淘宝 / 微博 等),
* 手填的自定义值由 store.oauthCustom 追加成候选,不需要动后端字典。 */
const OAUTH_PRESETS = [
{ key: 'alipay', label: '支付宝', group: '国内' },
{ key: 'taobao', label: '淘宝', group: '国内' },
{ key: 'wechat', label: '微信', group: '国内' },
{ key: 'qq', label: 'QQ', group: '国内' },
{ key: 'weibo', label: '微博', group: '国内' },
{ key: 'douyin', label: '抖音', group: '国内' },
{ key: 'baidu', label: '百度', group: '国内' },
{ key: 'jd', label: '京东', group: '国内' },
{ key: 'xiaohongshu', label: '小红书', group: '国内' },
{ key: 'bilibili', label: 'B站', group: '国内' },
{ key: 'meituan', label: '美团', group: '国内' },
{ key: 'dingtalk', label: '钉钉', group: '国内' },
{ key: 'feishu', label: '飞书', group: '国内' },
{ key: 'huawei', label: '华为', group: '国内' },
{ key: 'mi', label: '小米', group: '国内' },
{ key: 'google', label: 'Google', group: '国际' },
{ key: 'apple', label: 'Apple', group: '国际' },
{ key: 'microsoft', label: 'Microsoft', group: '国际' },
{ key: 'facebook', label: 'Facebook', group: '国际' },
{ key: 'x', label: 'X (Twitter)', group: '国际' },
{ key: 'yahoo', label: 'Yahoo', group: '国际' },
{ key: 'line', label: 'LINE', group: '国际' },
{ key: 'kakao', label: 'Kakao', group: '国际' },
{ key: 'naver', label: 'NAVER', group: '国际' },
{ key: 'amazon', label: 'Amazon', group: '国际' },
{ key: 'paypal', label: 'PayPal', group: '国际' },
{ key: 'discord', label: 'Discord', group: '国际' },
{ key: 'telegram', label: 'Telegram', group: '国际' },
{ key: 'reddit', label: 'Reddit', group: '国际' },
{ key: 'linkedin', label: 'LinkedIn', group: '国际' },
{ key: 'steam', label: 'Steam', group: '国际' },
{ key: 'epic', label: 'Epic Games', group: '国际' },
{ key: 'github', label: 'GitHub', group: '开发者' },
{ key: 'gitlab', label: 'GitLab', group: '开发者' },
{ key: 'bitbucket', label: 'Bitbucket', group: '开发者' },
{ key: 'cloudflare', label: 'Cloudflare', group: '开发者' },
{ key: 'huggingface', label: 'Hugging Face', group: '开发者' },
];
window.VpsFmt = {
TYPE_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
STATUS_LABELS: { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' },
@@ -37,8 +80,29 @@ window.VpsFmt = {
CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' },
// 凭据库登录方式:字段对应 Credential.login_type
LOGIN_TYPE_LABELS: { password: '密码登录', oauth: '授权登录', other: '其他' },
// 授权登录常见来源(表单 datalist 建议,可自由输入
OAUTH_PROVIDERS: ['google', 'apple', 'github', 'microsoft', 'wechat', 'qq'],
/* 授权登录来源候选(表单 chips + datalist 建议)
*
* 清单在上面 OAUTH_PRESETS;这里只暴露给视图使用。
*/
OAUTH_PRESETS,
// 短展示名:命中预设给中文,否则原样返回(自定义值不加工)
oauthLabel(k) {
const p = OAUTH_PRESETS.find(x => x.key === (k || '').trim().toLowerCase());
return p ? p.label : (k || '');
},
// 详情展示:「支付宝(alipay)」,自定义值只显示原文
oauthDetail(k) {
const p = OAUTH_PRESETS.find(x => x.key === (k || '').trim().toLowerCase());
return p ? p.label + '' + p.key + '' : (k || '');
},
// 归一化:允许输入中文名或大小写不一致的 slug,落到规范 key 上,避免同来源存成多种写法
normalizeOauth(raw) {
const v = (raw || '').trim();
if (!v) return '';
const lower = v.toLowerCase();
const p = OAUTH_PRESETS.find(x => x.key === lower || x.label.toLowerCase() === lower);
return p ? p.key : v;
},
loginTypeBadge(t) {
return { password: 'bg-slate-500/10 text-slate-600 dark:text-slate-400', oauth: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', other: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' }[t] || 'bg-slate-500/10 text-slate-500';
},
+59 -7
View File
@@ -487,12 +487,36 @@ const CredentialModal = {
<label class="block"><span class="text-xs text-slate-500">用户名/邮箱</span>
<input v-model="f.username" placeholder="如 you@gmail.com" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<!-- 授权登录:只需记录来源,本站无独立密码 -->
<label v-if="f.login_type==='oauth'" class="block"><span class="text-xs text-slate-500">授权来源</span>
<input v-model="f.oauth_provider" list="oauth-provider-options" placeholder="google / apple / github / wechat" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<datalist id="oauth-provider-options">
<option v-for="p in Fmt.OAUTH_PROVIDERS" :key="p" :value="p"></option>
</datalist></label>
<!-- 授权登录:只需记录来源,本站无独立密码;候选=预设+手填留存,点一下即填 -->
<div v-if="f.login_type==='oauth'" class="space-y-1.5">
<label class="block"><span class="text-xs text-slate-500">授权来源(点选或直接输入)</span>
<div class="relative mt-1">
<input v-model="f.oauth_provider" placeholder="如 支付宝 / alipay,也可以是任意自定义来源"
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<button v-if="f.oauth_provider" type="button" @click="f.oauth_provider=''" title="清空"
class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 text-sm">✕</button>
</div>
</label>
<!-- 候选自己渲染(不用 datalist):iOS Safari 对 datalist 支持不稳,且需要展示中文名 -->
<div class="max-h-36 overflow-y-auto rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-800/30 p-2 space-y-1.5">
<div v-for="g in oauthGroups" :key="g.name">
<div class="text-[10px] text-slate-400 mb-0.5">{{ g.name }}</div>
<div class="flex flex-wrap gap-1">
<button v-for="o in g.items" :key="o.key" type="button" @click="pickOauth(o.key)"
class="text-[11px] pl-2 py-1 rounded-full border flex items-center gap-1"
:class="f.oauth_provider===o.key ? 'border-emerald-500 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' : 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-300'">
<span>{{ o.label }}</span>
<span v-if="o.label!==o.key" class="text-[9px] text-slate-400 pr-1">{{ o.key }}</span>
<span v-if="o.custom" @click.stop="dropOauth(o.key)" title="从候选移除"
class="px-1 text-slate-400 hover:text-red-500">✕</span>
</button>
</div>
</div>
<p v-if="oauthUnmatched" class="text-[11px] text-slate-400 leading-relaxed">
「{{ f.oauth_provider }}」不在候选里,会按原样保存,并记入上面的自定义候选
</p>
</div>
</div>
<label v-else-if="f.login_type==='password'" class="block"><span class="text-xs text-slate-500">登录密码(加密存储)</span>
<input v-model="f.password" type="password" autocomplete="new-password" :placeholder="store.credentialModal.editing ? '留空不修改' : '可选'" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">登录页地址</span>
@@ -519,6 +543,34 @@ const CredentialModal = {
</div>`,
setup() {
const f = Vue.computed(() => store.credentialModal.form);
// 候选清单:手填留存值置顶(最近用过),其后按预设的国内/国际/开发者分组
const oauthAll = Vue.computed(() => [
...store.oauthCustom.map(k => ({ key: k, label: k, group: '自定义(我填过的)', custom: true })),
...Fmt.OAUTH_PRESETS,
]);
function groupOptions(list) {
const groups = [];
for (const o of list) {
let g = groups.find(x => x.name === o.group);
if (!g) { g = { name: o.group, items: [] }; groups.push(g); }
g.items.push(o);
}
return groups;
}
// 输入时同步过滤(key / 中文名都能搜);没命中就退回完整清单,避免逼用户先删字
const oauthGroups = Vue.computed(() => {
const all = oauthAll.value;
const kw = ((f.value && f.value.oauth_provider) || '').trim().toLowerCase();
if (!kw) return groupOptions(all);
const hit = all.filter(o => o.key.toLowerCase().includes(kw) || o.label.toLowerCase().includes(kw));
return hit.length ? groupOptions(hit) : groupOptions(all);
});
// 手填的新值给出明确反馈:会按原样入库并成为候选
const oauthUnmatched = Vue.computed(() => {
const key = Fmt.normalizeOauth((f.value && f.value.oauth_provider) || '');
return !!key && !oauthAll.value.some(o => o.key === key);
});
function pickOauth(key) { if (f.value) f.value.oauth_provider = key; }
// 2FA 前端预校验:填了 secret 就必须填 6 位码(服务端会再校验一次)
function save() {
const form = f.value;
@@ -528,6 +580,6 @@ const CredentialModal = {
}
saveCredential();
}
return { store, Fmt, f, save };
return { store, Fmt, f, oauthGroups, oauthUnmatched, pickOauth, dropOauth: dropOauthProvider, save };
},
};
+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; }
+2 -2
View File
@@ -37,7 +37,7 @@ const VaultView = {
</div>
<div class="text-xs text-slate-400 truncate">
<span v-if="c.username">{{ c.username }}</span><span v-else class="italic">未填用户名</span>
<span v-if="c.login_type==='oauth'" class="ml-1 text-emerald-600 dark:text-emerald-400">· {{ c.oauth_provider || '授权' }} 登录</span>
<span v-if="c.login_type==='oauth'" class="ml-1 text-emerald-600 dark:text-emerald-400">· {{ Fmt.oauthLabel(c.oauth_provider) || '授权' }} 登录</span>
<span v-else-if="c.login_type==='other'" class="ml-1">· 其他登录方式</span>
<span v-if="c.account_name" class="ml-1">· 平台账号</span>
</div>
@@ -66,7 +66,7 @@ const VaultView = {
<div class="text-xs text-slate-500 space-y-1">
<div v-if="c.url"><a :href="c.url" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline break-all">登录页 ↗</a></div>
<div v-if="c.note" class="whitespace-pre-wrap break-words">{{ c.note }}</div>
<div v-if="c.login_type==='oauth'" class="text-slate-400">授权登录({{ c.oauth_provider || '未记录来源' }}),本站无独立密码</div>
<div v-if="c.login_type==='oauth'" class="text-slate-400">授权登录({{ Fmt.oauthDetail(c.oauth_provider) || '未记录来源' }}),本站无独立密码</div>
<div v-if="c.account_name" class="text-slate-400">关联平台账号:{{ c.account_name }}</div>
<div class="text-slate-300 dark:text-slate-600">更新于 {{ fmtTime(c.updated_at) }}</div>
</div>