Files
vps-manager/static/js/modals.js
T
gouki 61372f7ceb fix(review): 修复全量审查发现的域名到期日链路、AI 资产挂靠迁移与 2FA 取码缺陷
92 文件全量代码审查后的高危/中危修复,每条都带回归测试,并在未修复的 HEAD 上
反向确认过「测试确实会失败」。

- H1 域名同步整批失败:NormalizedDomain.expiry_date 契约声明为字符串,Cloudflare
  适配器把 Registrar 的 ISO 时间戳截成 "YYYY-MM-DD" 直接赋给 Date 列,写库抛
  TypeError;而 _sync_domains 的 commit 在循环外,一个域名就让整批全量回滚
  (Cloudflare 域名实际从未同步成功)。现统一契约:base.py 新增 parse_iso_date,
  适配器产出 date,_sync_domains 写库前再归一一次以兼容仍返回字符串的适配器
- M9 Vultr / DigitalOcean 的域名到期日此前完全没映射,这两家域名永远进不了续费提醒
- M1 同步失败变 500:能力分支写库失败后未回滚,Session 停在 pending-rollback,
  随后更新 last_synced_at 的 commit 抛 PendingRollbackError,真实错误被 500 掩盖
- H2 AI 资产挂靠迁移静默零命中:迁移 SQL 用枚举值 'ai_agent' 匹配 Enum 列,而
  SQLAlchemy Enum 存的是成员名 'AI_AGENT',该分支从未生效(6 个 AI 资产的
  account_id 全为 NULL)。改用成员名并加日志留痕;账号命名沿用步骤 1 的
  「{slug}-默认」约定,避免建出与既有账号语义重复、且没有凭证的空账号
  (dry-run 实测:deepseek 资产复用已有且有凭证的 deepseek-默认)
- H3 2FA 动态码不刷新:otpState 全局常驻、收起只清 timer 不删条目,「不存在才拉」
  的守卫导致再次展开显示上一次的旧码且倒计时冻结在 0。改为每次展开都重取;
  stopOtp 同时清空旧码(避免重展开瞬间闪过已失效的码),并加请求代号,防止
  「请求在途时收起」后返回的响应又重启轮询
- M10 账号弹窗关闭不清理:外层用 v-if 关闭并不卸载组件,onUnmounted 不触发,
  OTP 轮询定时器与明文密码驻留后台。关闭时显式调 closePwd
- M2 提醒发不出去:Telegram 用 parse_mode=HTML,资产名含 < > & 时整条消息被 400
  拒收;纯文本提醒不需要任何标签,去掉 parse_mode,并把拒收正文带回便于排查
- M3 负数天文案:cbb32ea 已修好提醒消息正文,本次补齐仍在直出负数的两处
  (check_renewals.py 输出、设置页预览),后端抽出 renewal_service.day_text 统一,
  前端走 Fmt.countdownText

验证:pytest 94 passed(新增 tests/test_domain_expiry.py、tests/test_notification.py);
本地起服务对 H2 迁移做真实 dry-run(6 个 AI 资产挂到 4 个账号、无重复账号、幂等、
非 AI 资产零影响,迁移前已备份);浏览器端以 otpState 状态断言验证 H3/M10,
设置页续费预览确认已过期项显示「已过期 N 天」。
2026-09-22 22:26:42 +00:00

593 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 资产编辑模态框 */
const AssetModal = {
template: `
<div v-if="store.assetModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-lg md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
<h3 class="font-semibold text-sm">{{ store.assetModal.editing ? '编辑资产' : '新增资产' }}</h3>
<button @click="store.assetModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<div class="grid grid-cols-2 gap-3">
<label class="col-span-2 block"><span class="text-xs text-slate-500">名称 *</span>
<input v-model="f.name" required 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>
<select v-model="f.asset_type" 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">
<option v-for="(l,k) in Fmt.TYPE_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">所属平台</span>
<select v-model="f.provider_id" @change="onProviderChange" 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">
<option :value="null">未指定</option>
<option v-for="p in providersForType" :key="p.id" :value="p.id">{{ p.name }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">到期时间</span>
<input type="date" v-model="f.expiry_date" 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>
<select v-model="f.renewal_cycle" 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">
<option value="">未设定</option><option v-for="(l,k) in Fmt.CYCLE_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">费用</span>
<input type="number" step="0.01" v-model.number="f.cost" 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>
<select v-model="f.currency" 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">
<option>USD</option><option>CNY</option><option>JPY</option><option>EUR</option>
</select></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">续费网址</span>
<input v-model="f.renew_url" placeholder="https://…(去哪续费)" 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="col-span-2 block"><span class="text-xs text-slate-500">取消订阅网址</span>
<input v-model="f.cancel_url" placeholder="https://…(去哪取消)" 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>
<select v-model="f.status" 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">
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">所属账号</span>
<select v-model.number="f.account_id" 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">
<option :value="null">无(在「平台」页管理账号)</option>
<option v-for="a in store.accounts" :key="a.id" :value="a.id">{{ accountLabel(a) }}</option>
</select></label>
<div class="col-span-2 flex gap-5 text-sm text-slate-600 dark:text-slate-300">
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.auto_renew"> 自动续费</label>
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.is_archived"> 已归档</label>
</div>
</div>
<fieldset v-if="f.asset_type==='vps'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">VPS 详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="block"><span class="text-xs text-slate-400">公网 IP *</span><input v-model="f.vps_detail.ip_address" required class="mt-1 w-full px-2 py-1.5 rounded 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-400">Tailscale IP</span><input v-model="f.vps_detail.tailscale_ip" class="mt-1 w-full px-2 py-1.5 rounded 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-400">区域</span><input v-model="f.vps_detail.region" class="mt-1 w-full px-2 py-1.5 rounded 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-400">系统</span><input v-model="f.vps_detail.os" class="mt-1 w-full px-2 py-1.5 rounded 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-400">CPU核</span><input type="number" v-model.number="f.vps_detail.cpu_cores" class="mt-1 w-full px-2 py-1.5 rounded 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-400">内存GB</span><input type="number" step="0.5" v-model.number="f.vps_detail.memory_gb" class="mt-1 w-full px-2 py-1.5 rounded 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-400">磁盘GB</span><input type="number" v-model.number="f.vps_detail.disk_gb" class="mt-1 w-full px-2 py-1.5 rounded 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-400">SSH端口</span><input type="number" v-model.number="f.vps_detail.ssh_port" class="mt-1 w-full px-2 py-1.5 rounded 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-400">SSH用户</span><input v-model="f.vps_detail.ssh_user" placeholder="root" class="mt-1 w-full px-2 py-1.5 rounded 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-400">登录方式</span><select v-model="f.vps_detail.login_method" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option value="key">密钥</option><option value="password">密码</option><option value="other">其他</option></select></label>
<label class="block"><span class="text-xs text-slate-400">SSH密钥</span><input v-model="f.vps_detail.ssh_key" type="password" :placeholder="store.assetModal.editing ? '留空不修改' : ''" class="mt-1 w-full px-2 py-1.5 rounded 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-400">密码</span><input v-model="f.vps_detail.password" type="password" :placeholder="store.assetModal.editing ? '留空不修改' : ''" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-400">用途/项目</span><input v-model="f.vps_detail.purpose" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
</fieldset>
<fieldset v-if="f.asset_type==='domain'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">域名详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="col-span-2 block"><span class="text-xs text-slate-400">域名 *</span><input v-model="f.domain_detail.domain_name" required class="mt-1 w-full px-2 py-1.5 rounded 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-400">注册商</span><input v-model="f.domain_detail.registrar" class="mt-1 w-full px-2 py-1.5 rounded 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-400">DNS托管</span><input v-model="f.domain_detail.dns_provider" class="mt-1 w-full px-2 py-1.5 rounded 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-400">CF账号</span><input v-model="f.domain_detail.cloudflare_account" class="mt-1 w-full px-2 py-1.5 rounded 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-400">绑定资产ID</span><input type="number" v-model.number="f.domain_detail.bind_asset_id" placeholder="关联VPS的ID" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.domain_detail.is_using"> 仍在实际使用</label>
</div>
</fieldset>
<fieldset v-if="f.asset_type==='ai_agent'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">AI 账号详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="block"><span class="text-xs text-slate-400">服务商 *</span><input v-model="f.ai_detail.provider" required class="mt-1 w-full px-2 py-1.5 rounded 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-400">计划</span><input v-model="f.ai_detail.plan" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-400">API Key</span><input v-model="f.ai_detail.api_key" type="password" class="mt-1 w-full px-2 py-1.5 rounded 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-400">余额</span><input type="number" step="0.01" v-model.number="f.ai_detail.balance" class="mt-1 w-full px-2 py-1.5 rounded 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-400">月限额</span><input type="number" step="0.01" v-model.number="f.ai_detail.monthly_limit" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
</fieldset>
<fieldset v-if="f.asset_type==='cloudflare'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">Cloudflare 子资产详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="block"><span class="text-xs text-slate-400">账号邮箱</span><input v-model="f.cloudflare_detail.account_email" placeholder="账号邮箱(区分多账号)" class="mt-1 w-full px-2 py-1.5 rounded 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-400">子资产类型</span><select v-model="f.cloudflare_detail.sub_type" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option value="zone">zone(解析)</option><option value="worker">Worker</option><option value="r2">R2</option><option value="tunnel">Tunnel</option><option value="mail">Mail</option><option value="dns_record">DNS记录</option><option value="other">其他</option></select></label>
<label class="block"><span class="text-xs text-slate-400">子资产名称</span><input v-model="f.cloudflare_detail.sub_name" placeholder="如 worker 名 / bucket 名" class="mt-1 w-full px-2 py-1.5 rounded 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-400">关联 zone/域名</span><input v-model="f.cloudflare_detail.zone_name" placeholder="example.com" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-400">状态</span><input v-model="f.cloudflare_detail.status" placeholder="active / paused 等" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
</fieldset>
<label class="block"><span class="text-xs text-slate-500">备注</span>
<textarea v-model="f.remark" rows="2" 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"></textarea></label>
<div class="flex justify-end gap-2 pt-1">
<button type="button" @click="store.assetModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">{{ store.assetModal.editing ? '保存' : '创建' }}</button>
</div>
</form>
</div>
</div>`,
setup() {
const f = Vue.computed(() => store.assetModal.form);
const providersForType = Vue.computed(() => {
const t = f.value ? f.value.asset_type : null;
const map = { vps: 'vps', domain: 'domain', ai_agent: 'ai_agent', cloudflare: 'cloudflare' };
const cat = map[t];
if (!cat) return store.providers;
// 综合平台按 services 过滤(如阿里云可同时选 vps/domain);services 为空时回退分类匹配
return store.providers.filter(p => {
if (p.services) return p.services.split(',').map(s => s.trim()).includes(cat);
return p.category === cat || p.category === 'other';
});
});
function onProviderChange() {
const p = store.providers.find(x => x.id === f.value.provider_id);
if (p) f.value.provider = p.slug;
}
// 账号候选显示:平台名 + 账号标识(同名账号靠平台前缀区分),说明优先
function accountLabel(a) {
const p = store.providers.find(x => x.slug === a.platform);
const pname = p ? p.name : (a.platform || '未指定平台');
if (a.remark) return pname + ' · ' + a.remark + '(' + a.name + ')';
return pname + ' · ' + a.name;
}
return { store, Fmt, f, providersForType, onProviderChange, accountLabel, save: saveAsset };
},
};
/* 平台编辑模态框 */
const ProviderModal = {
template: `
<div v-if="store.providerModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h3 class="font-semibold text-sm">{{ store.providerModal.editing ? '编辑平台' : '新增平台' }}</h3>
<button @click="store.providerModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">标识 slug *</span><input v-model="f.slug" required placeholder="aliyun" 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><input v-model="f.name" required 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="col-span-2 block"><span class="text-xs text-slate-500">分类</span><select v-model="f.category" 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"><option v-for="(l,k) in Fmt.CATEGORY_LABELS" :key="k" :value="k">{{ l }}</option></select></label>
<div class="col-span-2 block"><span class="text-xs text-slate-500">提供服务(可多选,综合平台全选)</span>
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1">
<label v-for="(l,k) in Fmt.SERVICES_LABELS" :key="k" class="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300"><input type="checkbox" :checked="servicesSet.has(k)" @change="toggleService(k)"> {{ l }}</label>
</div>
</div>
<label class="block"><span class="text-xs text-slate-500">SDK类型</span><select v-model="f.sdk_type" 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"><option value="">未指定</option><option v-for="o in sdkOptions" :key="o" :value="o">{{ o }}</option></select></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">官网</span><input v-model="f.website" 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="col-span-2 block"><span class="text-xs text-slate-500">管理面板URL</span><input v-model="f.console_url" 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 v-if="f.sdk_type" class="col-span-2 text-xs text-slate-400 bg-slate-50 dark:bg-slate-800/50 rounded-lg p-2">API 凭证请在平台账号中配置:保存后在平台卡片点「账号」→ 新增账号并填写 API 配置,即可测试/同步。</div>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.enabled"> 启用</label>
</div>
<div class="flex justify-end gap-2">
<button type="button" @click="store.providerModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">保存</button>
</div>
</form>
</div>
</div>`,
setup() {
const f = Vue.computed(() => store.providerModal.form);
const sdkOptions = Vue.ref([]);
Vue.onMounted(async () => {
try {
const r = await Api.get('/providers/adapters');
const opts = [];
for (const m of (r.adapters || [])) for (const t of (m.sdk_types || [])) opts.push(t);
sdkOptions.value = opts;
} catch (e) { /* ignore */ }
});
// 提供服务多选:services 逗号分隔字符串 <-> checkbox 集合
const servicesSet = Vue.computed(() => new Set((f.value && f.value.services || '').split(',').map(s => s.trim()).filter(Boolean)));
function toggleService(k) {
const arr = (f.value.services || '').split(',').map(s => s.trim()).filter(Boolean);
const idx = arr.indexOf(k);
if (idx >= 0) arr.splice(idx, 1); else arr.push(k);
f.value.services = arr.join(',');
}
// 凭证已下沉到账号层,平台表单不再收集 API 凭证
return { store, Fmt, f, sdkOptions, servicesSet, toggleService, save: saveProvider };
},
};
/* 账号编辑模态框(z-[60]:可能叠加在账号查看弹窗 z-50 之上打开,需更高层级) */
const AccountModal = {
template: `
<div v-if="store.accountModal.show" class="fixed inset-0 bg-black/40 z-[60] flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h3 class="font-semibold text-sm">{{ store.accountModal.editing ? '编辑账号' : '新增账号' }}</h3>
<button @click="store.accountModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<label class="block"><span class="text-xs text-slate-500">账号说明(这个账号是干嘛的)</span>
<input v-model="f.remark" placeholder="如:XX公司代管账号" 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>
<input v-model="f.name" required placeholder="如 you@example.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>
<label class="block"><span class="text-xs text-slate-500">所属平台</span>
<select v-model="f.platform" 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">
<option value="">未指定</option>
<option v-for="p in store.providers" :key="p.id" :value="p.slug">{{ p.name }}</option>
</select></label>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">登录用户名/邮箱</span>
<input v-model="f.login_user" placeholder="网站登录用" 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>
<input v-model="f.login_password" type="password" :placeholder="store.accountModal.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>
</div>
<div v-if="currentFields.length" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 配置(供 SDK 同步,加密存储)</div>
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span>
<input v-model="apiFields[field]" type="password" :placeholder="store.accountModal.editing ? '留空不修改' : '填写 ' + field" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p>
<div class="flex justify-end gap-2">
<button type="button" @click="store.accountModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" :disabled="store.accountSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.accountSaving ? '保存中…' : '保存' }}</button>
</div>
</form>
</div>
</div>`,
setup() {
const f = Vue.computed(() => store.accountModal.form);
const adapterMeta = Vue.ref([]);
const apiFields = Vue.reactive({});
Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ }
});
// 按账号所属平台的 sdk_type 动态渲染所需凭证字段
const currentFields = Vue.computed(() => {
const p = store.providers.find(x => x.slug === (f.value && f.value.platform));
if (!p || !p.sdk_type) return [];
const m = adapterMeta.value.find(x => (x.sdk_types || []).includes(p.sdk_type));
return m ? (m.required_config || []) : [];
});
// 每次打开清空残留输入,避免串号
Vue.watch(() => store.accountModal.show, (show) => {
if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; }
});
function save() {
const cfg = {};
for (const field of currentFields.value) if (apiFields[field]) cfg[field] = apiFields[field];
f.value.api_config = Object.keys(cfg).length ? JSON.stringify(cfg) : '';
saveAccount();
}
return { store, f, currentFields, apiFields, save };
},
};
/* 账号查看弹窗:平台 → 账号 → 资产 三层结构,展开账号看名下资产 */
const AccountsViewModal = {
template: `
<div v-if="store.accountsModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-lg md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
<h3 class="font-semibold text-sm">👤 {{ title }}({{ accountList.length }})</h3>
<div class="flex items-center gap-3">
<button @click="add" class="text-xs text-blue-600 dark:text-blue-400">+ 新增账号</button>
<button @click="store.accountsModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
</div>
<div class="px-5 py-4">
<p v-if="!accountList.length" class="text-xs text-slate-400">暂无账号。一个平台可有多个账号(如代管/公司账号),添加后可在资产表单「所属账号」中选择。</p>
<div v-else class="space-y-4">
<div v-for="g in groups" :key="g.platform">
<div v-if="!singleProvider" class="text-xs font-medium text-slate-400 mb-1">{{ g.platform ? platformName(g.platform) : '未指定平台' }}</div>
<div class="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-100 dark:border-slate-800 rounded-lg">
<div v-for="a in g.accounts" :key="a.id">
<div class="px-3 py-2 flex items-center gap-2">
<button @click="toggle(a.id)" class="text-slate-400 text-xs w-4">{{ expanded[a.id] ? '▾' : '▸' }}</button>
<div class="flex-1 min-w-0">
<div class="text-sm truncate">{{ a.remark || a.name }}
<span v-if="a.has_login_password" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-slate-100 dark:bg-slate-800 text-slate-400" title="已配置登录凭证">🔑登录</span>
<span v-if="a.has_api_config" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-500" title="已配置 API 凭证">⚙️API</span>
</div>
<div class="text-xs text-slate-400 truncate"><span v-if="a.remark">{{ a.name }} · </span>关联 {{ assetsOf(a).length }} 个资产</div>
</div>
<button v-if="a.has_login_password" @click="viewPwd(a)" class="text-xs text-amber-600 dark:text-amber-400" title="查看登录密码">🔑</button>
<button v-if="a.has_login_password" @click="copyAcctPwd(a)" class="text-xs text-slate-500 dark:text-slate-400" title="复制登录密码">复制</button>
<button v-if="a.has_otp" @click="viewPwd(a)" class="text-xs text-blue-600 dark:text-blue-400" title="该账号已绑定 2FA,点 🔑 一并查看动态码">2FA</button>
<button v-if="canSync(a)" @click="testAcct(a)" class="text-xs text-emerald-600 dark:text-emerald-400">测试</button>
<button v-if="canSync(a)" @click="syncAcct(a)" class="text-xs text-violet-600 dark:text-violet-400">同步</button>
<button @click="edit(a)" class="text-xs text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(a)" class="text-xs text-red-600 dark:text-red-400">删除</button>
</div>
<div v-if="expanded[a.id]" class="px-3 pb-2 pl-9 space-y-1">
<p v-if="!assetsOf(a).length" class="text-xs text-slate-400">该账号下暂无资产(编辑资产时在「所属账号」选择 {{ a.name }} 即可关联)</p>
<div v-for="asset in assetsOf(a)" :key="asset.id" class="text-xs flex items-center gap-2">
<span class="px-1.5 py-0.5 rounded-full shrink-0" :class="Fmt.typeBadge(asset.asset_type)">{{ Fmt.TYPE_LABELS[asset.asset_type] }}</span>
<span class="truncate">{{ asset.name }}</span>
<span v-if="asset.expiry_date" class="text-slate-400 shrink-0">{{ asset.expiry_date }} 到期</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 密码查看弹窗:点击账号的 🔑 后展示明文,几秒后自动隐藏 -->
<div v-if="pwd.show" class="absolute inset-0 bg-black/50 flex items-center justify-center p-4" @click.self="pwd.show=false">
<div class="bg-white dark:bg-slate-800 rounded-xl w-full max-w-xs p-5 shadow-xl">
<h4 class="font-semibold text-sm mb-3">🔑 {{ pwd.name }} 的登录凭证</h4>
<div class="space-y-2 text-sm">
<div v-if="pwd.login_user" class="flex justify-between items-center gap-2">
<span class="text-slate-400 text-xs shrink-0">用户名</span>
<span class="font-mono truncate">{{ pwd.login_user }}</span>
</div>
<div class="flex justify-between items-center gap-2">
<span class="text-slate-400 text-xs shrink-0">密码</span>
<span class="font-mono break-all">{{ pwd.password }}</span>
</div>
</div>
<!-- 账号绑定了 2FA:一并展示动态码(服务端生成,本地倒计时) -->
<div v-if="pwd.otpId && otp.code" class="mt-3 flex items-center gap-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg px-3 py-2">
<span class="text-[10px] text-slate-500 shrink-0">2FA</span>
<span class="font-mono text-xl tracking-widest text-blue-700 dark:text-blue-300">{{ otp.code }}</span>
<span class="text-[10px] text-slate-400 shrink-0">{{ otp.expiresIn }}s</span>
<button @click="copyOtpCode" class="ml-auto text-[11px] px-2 py-0.5 rounded border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
</div>
<!-- secret 未经校验:码照样能算出来,但不保证站点认 -->
<div v-if="pwd.otpId && !pwd.otpVerified" class="mt-2 text-[11px] text-amber-600 dark:text-amber-400 leading-snug">⚠ 此 2FA 的 secret 未经校验;若在该站点登录失败,请到凭据库核对补验。</div>
<div class="flex gap-2 mt-4">
<button @click="copyPwd" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">{{ pwd.copied ? '✓ 已复制' : '复制密码' }}</button>
<button @click="closePwd" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
</div>
<p v-if="pwd.countdown" class="text-[10px] text-slate-400 mt-2 text-center">{{ pwd.countdown }}s 后自动关闭</p>
</div>
</div>
</div>`,
setup() {
const expanded = Vue.reactive({});
const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, otpId: null, otpVerified: false, _timer: null });
// 当前动态码(来自全局 otpState,服务端生成、secret 不下发)
const otp = Vue.computed(() => (pwd.otpId ? (otpState[pwd.otpId] || { code: '', expiresIn: 0 }) : { code: '', expiresIn: 0 }));
const singleProvider = Vue.computed(() => !!store.accountsModal.providerSlug);
const title = Vue.computed(() => {
const slug = store.accountsModal.providerSlug;
if (!slug) return '全部账号';
const p = store.providers.find(x => x.slug === slug);
return (p ? p.name : slug) + ' 的账号';
});
const accountList = Vue.computed(() => {
const slug = store.accountsModal.providerSlug;
return slug ? store.accounts.filter(a => a.platform === slug) : store.accounts;
});
// 全部模式下按平台分组,保持 平台→账号→资产 层级
const groups = Vue.computed(() => {
const m = new Map();
for (const a of accountList.value) {
const key = a.platform || '';
if (!m.has(key)) m.set(key, []);
m.get(key).push(a);
}
return [...m.entries()].map(([platform, accounts]) => ({ platform, accounts }));
});
function platformName(slug) {
const p = store.providers.find(x => x.slug === slug);
return p ? p.name : slug;
}
function assetsOf(a) {
return store.assets.filter(x => x.account_id === a.id);
}
function toggle(id) { expanded[id] = !expanded[id]; }
// 账号所属平台配了 sdk_type 且账号有 API 配置时,提供测试/同步
function providerOf(a) {
return store.providers.find(p => p.slug === a.platform) || store.providers.find(p => p.name === a.platform);
}
function canSync(a) {
const p = providerOf(a);
return !!(a.has_api_config && p && p.sdk_type);
}
async function testAcct(a) {
try {
const r = await Api.post('/accounts/' + a.id + '/test', {});
alert(r.ok ? '✅ ' + r.message : '❌ ' + r.message);
} catch (e) { alert('测试失败:' + e.message); }
}
async function syncAcct(a) {
try {
const r = await Api.post('/accounts/' + a.id + '/sync', {});
const lines = ['同步完成'];
if (r.vps) lines.push('VPS:新增 ' + r.vps.created + ' / 更新 ' + r.vps.updated);
if (r.domains) lines.push('域名:新增 ' + r.domains.created + ' / 更新 ' + r.domains.updated);
if (r.vps_error) lines.push('VPS 错误:' + r.vps_error);
if (r.domains_error) lines.push('域名错误:' + r.domains_error);
alert(lines.join('\n'));
await Promise.all([loadAccounts(), loadAssets()]);
} catch (e) { alert('同步失败:' + e.message); }
}
// 每次打开重置展开状态;关闭时连带收掉密码弹窗
// 外层弹窗用 v-if 控制,但本组件实例并不卸载,onUnmounted 不会触发:
// 不主动清理就会留下 OTP 轮询定时器与驻留的明文密码,重开弹窗还会复现旧内容
Vue.watch(() => store.accountsModal.show, (show) => {
if (show) { for (const k of Object.keys(expanded)) delete expanded[k]; }
else closePwd();
});
// 查看密码:拉取明文并展示,8 秒后自动关闭;账号绑定 2FA 时一并取动态码且不自动关
async function viewPwd(a) {
try {
const r = await Api.get('/accounts/' + a.id + '/password');
pwd.name = a.remark || a.name;
pwd.login_user = r.login_user || '';
pwd.password = r.password;
pwd.copied = false;
pwd.show = true;
if (pwd._timer) clearInterval(pwd._timer);
if (a.has_otp && a.credential_id) {
// 验证码需看满 30s 周期,此时不自动关闭(countdown=0 不显示倒计时)
pwd.otpId = a.credential_id;
pwd.otpVerified = !!a.otp_verified;
pwd.countdown = 0;
fetchOtp(a.credential_id);
} else {
pwd.otpId = null;
pwd.countdown = 8;
pwd._timer = setInterval(() => {
pwd.countdown--;
if (pwd.countdown <= 0) closePwd();
}, 1000);
}
} catch (e) { alert('获取密码失败:' + e.message); }
}
function closePwd() {
pwd.show = false;
pwd.password = '';
if (pwd.otpId) { stopOtp(pwd.otpId); pwd.otpId = null; }
if (pwd._timer) { clearInterval(pwd._timer); pwd._timer = null; }
}
function copyOtpCode() { if (otp.value.code) copyText(otp.value.code, '验证码'); }
function copyPwd() {
const done = () => { pwd.copied = true; setTimeout(() => { pwd.copied = false; }, 1500); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(pwd.password).then(done).catch(() => fallbackCopy());
} else { fallbackCopy(); }
function fallbackCopy() {
const ta = document.createElement('textarea');
ta.value = pwd.password; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } catch (e) {}
document.body.removeChild(ta);
}
}
// 关闭弹窗时清定时器并停掉动态码刷新,避免泄漏
Vue.watch(() => pwd.show, (v) => { if (!v) closePwd(); });
return {
store, Fmt, expanded, pwd, otp, singleProvider, title, accountList, groups,
platformName, assetsOf, toggle, canSync, testAcct, syncAcct, viewPwd, copyPwd,
closePwd, copyOtpCode, copyAcctPwd: copyAccountPassword,
add: () => openAccountCreate(store.accountsModal.providerSlug || ''),
edit: openAccountEdit, del: deleteAccount,
};
},
};
/* 凭据编辑模态框(密码库)
*
* 粒度 = 站点 × 登录方式:同一邮箱注册多个站点就录多条,密码相同也各存一份。
* 2FA 录入必须同时填当前动态码(服务端校验),防 secret 手误成废条目。
*/
const CredentialModal = {
template: `
<div v-if="store.credentialModal.show" class="fixed inset-0 bg-black/40 z-[60] flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h3 class="font-semibold text-sm">{{ store.credentialModal.editing ? '编辑凭据' : '新增凭据' }}</h3>
<button @click="store.credentialModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<label class="block"><span class="text-xs text-slate-500">站点/服务名 *</span>
<input v-model="f.site" required placeholder="如 GitHub、某论坛、邮箱" 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 class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">登录方式</span>
<select v-model="f.login_type" 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">
<option v-for="(l,k) in Fmt.LOGIN_TYPE_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<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>
<!-- 授权登录:只需记录来源,本站无独立密码;候选=预设+手填留存,点一下即填 -->
<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>
<input v-model="f.url" placeholder="https://…(可选,便于直接跳转)" 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>
<fieldset class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">🛡 2FA(TOTP 动态码)</legend>
<p class="text-[11px] text-slate-400 leading-relaxed">粘贴 otpauth:// 链接或直接填 secret。当前 6 位动态码能拿到就顺手填上(服务端校验,确认没抄错);拿不到就留空先存着,条目会标「未验证」,之后在列表里随时补验。</p>
<label class="block"><span class="text-xs text-slate-400">secret 或 otpauth 链接</span>
<input v-model="f.otp_secret" :placeholder="store.credentialModal.editing ? '留空不修改' : '可选'" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono"></label>
<label v-if="f.otp_secret" class="block"><span class="text-xs text-slate-400">当前 6 位动态码(可选)</span>
<input v-model="f.otp_code" inputmode="numeric" maxlength="6" placeholder="如 123456;拿不到就留空" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono tracking-widest"></label>
</fieldset>
<label class="block"><span class="text-xs text-slate-500">备注</span>
<textarea v-model="f.note" rows="2" placeholder="如:备用邮箱注册、公司账号等" 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"></textarea></label>
<div class="flex justify-end gap-2">
<button type="button" @click="store.credentialModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" :disabled="store.credentialSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.credentialSaving ? '保存中…' : '保存' }}</button>
</div>
</form>
</div>
</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; }
// 动态码为可选:填了就要求格式正确(值由服务端校验),没填则存为「未验证」。
// 不能再要求必填:secret 多为历史留存,录入时手上常常没有可用验证器。
function save() {
const code = ((f.value && f.value.otp_code) || '').trim();
if (code && !/^\d{6}$/.test(code)) {
store.error = '动态码需为 6 位数字;拿不到可以留空先存着';
return;
}
saveCredential();
}
return { store, Fmt, f, oauthGroups, oauthUnmatched, pickOauth, dropOauth: dropOauthProvider, save };
},
};