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:
@@ -83,7 +83,7 @@ def list_credentials(
|
|||||||
login_type: Optional[str] = None,
|
login_type: Optional[str] = None,
|
||||||
has_otp: Optional[bool] = None,
|
has_otp: Optional[bool] = None,
|
||||||
) -> List[CredentialRead]:
|
) -> List[CredentialRead]:
|
||||||
"""凭据列表:搜索 site/username/note,可按登录方式与 2FA 绑定过滤"""
|
"""凭据列表:搜索 site/username/note/授权来源,可按登录方式与 2FA 绑定过滤"""
|
||||||
stmt = select(Credential)
|
stmt = select(Credential)
|
||||||
if login_type:
|
if login_type:
|
||||||
stmt = stmt.where(Credential.login_type == login_type)
|
stmt = stmt.where(Credential.login_type == login_type)
|
||||||
@@ -98,6 +98,7 @@ def list_credentials(
|
|||||||
Credential.site.like(like), # type: ignore[union-attr]
|
Credential.site.like(like), # type: ignore[union-attr]
|
||||||
Credential.username.like(like), # type: ignore[union-attr]
|
Credential.username.like(like), # type: ignore[union-attr]
|
||||||
Credential.note.like(like), # type: ignore[union-attr]
|
Credential.note.like(like), # type: ignore[union-attr]
|
||||||
|
Credential.oauth_provider.like(like), # type: ignore[union-attr]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
creds = list(
|
creds = list(
|
||||||
|
|||||||
+66
-2
@@ -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 = {
|
window.VpsFmt = {
|
||||||
TYPE_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
|
TYPE_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
|
||||||
STATUS_LABELS: { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' },
|
STATUS_LABELS: { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' },
|
||||||
@@ -37,8 +80,29 @@ window.VpsFmt = {
|
|||||||
CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' },
|
CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' },
|
||||||
// 凭据库登录方式:字段对应 Credential.login_type
|
// 凭据库登录方式:字段对应 Credential.login_type
|
||||||
LOGIN_TYPE_LABELS: { password: '密码登录', oauth: '授权登录', other: '其他' },
|
LOGIN_TYPE_LABELS: { password: '密码登录', oauth: '授权登录', other: '其他' },
|
||||||
// 授权登录常见来源(表单 datalist 建议,可自由输入)
|
/* 授权登录来源候选(表单 chips + datalist 建议)
|
||||||
OAUTH_PROVIDERS: ['google', 'apple', 'github', 'microsoft', 'wechat', 'qq'],
|
*
|
||||||
|
* 清单在上面 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) {
|
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';
|
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
@@ -487,12 +487,36 @@ const CredentialModal = {
|
|||||||
<label class="block"><span class="text-xs text-slate-500">用户名/邮箱</span>
|
<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>
|
<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>
|
</div>
|
||||||
<!-- 授权登录:只需记录来源,本站无独立密码 -->
|
<!-- 授权登录:只需记录来源,本站无独立密码;候选=预设+手填留存,点一下即填 -->
|
||||||
<label v-if="f.login_type==='oauth'" class="block"><span class="text-xs text-slate-500">授权来源</span>
|
<div v-if="f.login_type==='oauth'" class="space-y-1.5">
|
||||||
<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">
|
<label class="block"><span class="text-xs text-slate-500">授权来源(点选或直接输入)</span>
|
||||||
<datalist id="oauth-provider-options">
|
<div class="relative mt-1">
|
||||||
<option v-for="p in Fmt.OAUTH_PROVIDERS" :key="p" :value="p"></option>
|
<input v-model="f.oauth_provider" placeholder="如 支付宝 / alipay,也可以是任意自定义来源"
|
||||||
</datalist></label>
|
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>
|
<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>
|
<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>
|
<label class="block"><span class="text-xs text-slate-500">登录页地址</span>
|
||||||
@@ -519,6 +543,34 @@ const CredentialModal = {
|
|||||||
</div>`,
|
</div>`,
|
||||||
setup() {
|
setup() {
|
||||||
const f = Vue.computed(() => store.credentialModal.form);
|
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 位码(服务端会再校验一次)
|
// 2FA 前端预校验:填了 secret 就必须填 6 位码(服务端会再校验一次)
|
||||||
function save() {
|
function save() {
|
||||||
const form = f.value;
|
const form = f.value;
|
||||||
@@ -528,6 +580,6 @@ const CredentialModal = {
|
|||||||
}
|
}
|
||||||
saveCredential();
|
saveCredential();
|
||||||
}
|
}
|
||||||
return { store, Fmt, f, save };
|
return { store, Fmt, f, oauthGroups, oauthUnmatched, pickOauth, dropOauth: dropOauthProvider, save };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+62
-1
@@ -4,6 +4,25 @@ const Api = window.VpsApi;
|
|||||||
const Fmt = window.VpsFmt;
|
const Fmt = window.VpsFmt;
|
||||||
|
|
||||||
/* ---------------- 全局状态 ---------------- */
|
/* ---------------- 全局状态 ---------------- */
|
||||||
|
|
||||||
|
/* 手动填入的授权来源候选(本地留存)
|
||||||
|
*
|
||||||
|
* credentials 表只存条目本身,不维护来源字典;为了让「这次手填的值下次还能点」,
|
||||||
|
* 把非预设值记在 localStorage:oauth_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({
|
const store = reactive({
|
||||||
view: 'dashboard',
|
view: 'dashboard',
|
||||||
assets: [],
|
assets: [],
|
||||||
@@ -34,6 +53,8 @@ const store = reactive({
|
|||||||
credentialSaving: false, // 保存锁,防双击重复提交
|
credentialSaving: false, // 保存锁,防双击重复提交
|
||||||
vaultSearch: '',
|
vaultSearch: '',
|
||||||
vaultOtpOnly: false, // 只看已绑定 2FA 的条目
|
vaultOtpOnly: false, // 只看已绑定 2FA 的条目
|
||||||
|
oauthCustom: _readOauthList(OAUTH_CUSTOM_KEY), // 手填来源候选(预设之外)
|
||||||
|
oauthIgnored: _readOauthList(OAUTH_IGNORED_KEY), // 已划掉的手填来源
|
||||||
});
|
});
|
||||||
|
|
||||||
function applyDark() {
|
function applyDark() {
|
||||||
@@ -96,6 +117,7 @@ async function loadCredentials() {
|
|||||||
if (store.vaultSearch) params.set('q', store.vaultSearch);
|
if (store.vaultSearch) params.set('q', store.vaultSearch);
|
||||||
if (store.vaultOtpOnly) params.set('has_otp', 'true');
|
if (store.vaultOtpOnly) params.set('has_otp', 'true');
|
||||||
store.credentials = await Api.get('/credentials?' + params.toString());
|
store.credentials = await Api.get('/credentials?' + params.toString());
|
||||||
|
syncOauthCustom();
|
||||||
}
|
}
|
||||||
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
|
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
|
||||||
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
|
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
|
||||||
@@ -224,6 +246,41 @@ async function deleteAccount(a) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 凭据库(密码 + 2FA) ---------------- */
|
/* ---------------- 凭据库(密码 + 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() {
|
function emptyCredentialForm() {
|
||||||
return {
|
return {
|
||||||
site: '', username: '', login_type: 'password', oauth_provider: '',
|
site: '', username: '', login_type: 'password', oauth_provider: '',
|
||||||
@@ -253,9 +310,12 @@ async function saveCredential() {
|
|||||||
store.credentialSaving = true;
|
store.credentialSaving = true;
|
||||||
const f = store.credentialModal.form;
|
const f = store.credentialModal.form;
|
||||||
const editing = store.credentialModal.editing;
|
const editing = store.credentialModal.editing;
|
||||||
|
// 授权来源允许手填(中文/英文均可),归一化后回写表单,保证候选高亮与入库值一致
|
||||||
|
const oauthKey = f.login_type === 'oauth' ? Fmt.normalizeOauth(f.oauth_provider) : '';
|
||||||
|
f.oauth_provider = oauthKey;
|
||||||
const payload = {
|
const payload = {
|
||||||
site: f.site, username: f.username || null, login_type: f.login_type,
|
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 {
|
try {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
@@ -272,6 +332,7 @@ async function saveCredential() {
|
|||||||
await Api.post('/credentials', payload);
|
await Api.post('/credentials', payload);
|
||||||
}
|
}
|
||||||
store.credentialModal.show = false;
|
store.credentialModal.show = false;
|
||||||
|
if (oauthKey) rememberOauthProvider(oauthKey);
|
||||||
// 账号侧 has_login_password / has_otp 来自关联凭据,需一并刷新
|
// 账号侧 has_login_password / has_otp 来自关联凭据,需一并刷新
|
||||||
await Promise.all([loadCredentials(), loadAccounts()]);
|
await Promise.all([loadCredentials(), loadAccounts()]);
|
||||||
} catch (e) { store.error = e.message; }
|
} catch (e) { store.error = e.message; }
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const VaultView = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-slate-400 truncate">
|
<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.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-else-if="c.login_type==='other'" class="ml-1">· 其他登录方式</span>
|
||||||
<span v-if="c.account_name" class="ml-1">· 平台账号</span>
|
<span v-if="c.account_name" class="ml-1">· 平台账号</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +66,7 @@ const VaultView = {
|
|||||||
<div class="text-xs text-slate-500 space-y-1">
|
<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.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.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 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 class="text-slate-300 dark:text-slate-600">更新于 {{ fmtTime(c.updated_at) }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user