Files
vps-manager/static/js/modals.js
T
gouki eeb05372c4 feat(ui): 全局弹窗替代原生 alert/confirm,添加防重复提交与搜索功能
- 新增 ConfirmModal / ResultModal 全局组件,替代原生 alert/confirm/prompt
- 资产/平台/账号保存添加防重复提交锁(assetSaving / providerSaving)
- 添加 Esc 键盘快捷键关闭模态框
- 平台/监控视图添加搜索过滤功能
- 修复监控中心 SSL TAB 下新增按钮的显示逻辑
- 调整 toast 位置避免遮挡底部导航
- 统一各弹窗 z-index 层级
2026-10-17 21:17:18 +00:00

831 lines
64 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" :disabled="store.assetSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.assetSaving ? '保存中…' : (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" :disabled="store.providerSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.providerSaving ? '保存中…' : '保存' }}</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 z-[70] 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) {
// 用 allAssets(全量口径):账号名下有多少资产是事实,不该受资产页筛选影响
return store.allAssets.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', {});
showResult(r.ok ? '测试成功' : '测试失败', r.message, !r.ok);
} catch (e) { showResult('测试失败', e.message, true); }
}
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);
showResult('同步完成', lines.join('\n'));
// 同步会新建/更新资产(含到期日),总览的统计卡片也得跟着刷,
// 否则回到总览看到的还是同步前的数字
await Promise.all([loadAccounts(), loadAssets(), loadOverview()]);
} catch (e) { showResult('同步失败', e.message, true); }
}
// 每次打开重置展开状态;关闭时连带收掉密码弹窗
// 外层弹窗用 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) { showResult('获取密码失败', e.message, true); }
}
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 };
},
};
/* ================= 云资源操作弹窗(启停/重启/续费) =================
* 每个按钮都会真实改变云端资源,所以:
* 1. 可用性完全由后端 /actions/{id}/capabilities 决定,前端不自作主张放开
* 2. 点击后先出二次确认条,写清后果(续费会扣款、关机会中断服务)
* 3. 不支持时如实说明原因,而不是给一个点了才报错的按钮 */
const ActionModal = {
template: `
<div v-if="m.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 sticky top-0 bg-white dark:bg-slate-900">
<h3 class="font-semibold text-sm">⚡ 云资源操作</h3>
<button @click="close" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<div class="px-5 py-4 space-y-3">
<div class="text-sm font-medium truncate">{{ m.asset && m.asset.name }}</div>
<div v-if="m.asset" class="text-xs text-slate-400">
{{ m.asset.provider }}<span v-if="m.asset.external_id"> · {{ m.asset.external_id }}</span>
</div>
<div v-if="m.loading" class="text-xs text-slate-400 py-6 text-center">正在查询该平台支持的操作…</div>
<div v-else-if="m.error && !m.caps" class="text-xs px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400">{{ m.error }}</div>
<!-- 平台不可用:如实说明,不给可点按钮 -->
<div v-else-if="m.caps && !m.caps.available" class="text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/60 border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-300">
<div class="font-medium mb-1">该平台暂不支持远程操作</div>
<div>{{ m.caps.reason || '适配器未实现对应能力,请到云厂商控制台操作。' }}</div>
</div>
<template v-else-if="m.caps">
<div class="grid grid-cols-2 gap-2">
<button v-for="a in actions" :key="a.key"
@click="ask(a.key)"
:disabled="!m.caps.actions[a.key] || !!m.confirm"
class="px-3 py-2.5 rounded-lg border text-sm transition disabled:opacity-40 disabled:cursor-not-allowed"
:class="a.cls">
{{ a.icon }} {{ a.label }}
</button>
</div>
<!-- 二次确认条 -->
<div v-if="m.confirm" class="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 px-3 py-3 space-y-2">
<div class="text-xs font-medium text-amber-800 dark:text-amber-300">确认{{ label(m.confirm) }}「{{ m.asset.name }}」?</div>
<div class="text-xs text-amber-700 dark:text-amber-400">{{ warn(m.confirm) }}</div>
<div v-if="m.confirm === 'renew'" class="flex items-center gap-2 text-xs text-amber-800 dark:text-amber-300">
续费周期
<select v-model.number="m.periodMonths" class="px-2 py-1 rounded border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 text-xs">
<option v-for="n in [1,2,3,6,12,24,36]" :key="n" :value="n">{{ n }} 个月</option>
</select>
</div>
<div class="flex gap-2 justify-end">
<button @click="m.confirm=null" :disabled="m.busy" class="px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-xs">取消</button>
<button @click="run(m.confirm)" :disabled="m.busy" class="px-3 py-1.5 rounded-lg bg-amber-600 text-white text-xs disabled:opacity-50">
{{ m.busy ? '下发中…' : '确认' + label(m.confirm) }}
</button>
</div>
</div>
<div v-if="m.error" class="text-xs px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400">{{ m.error }}</div>
<div class="text-[11px] text-slate-400 leading-relaxed">
操作通过账号上配置的 API 凭证下发到云端,每次执行都会记入事件日志。
平台能力:{{ capsText }}
</div>
</template>
</div>
</div>
</div>`,
setup() {
const m = actionModal;
const actions = [
{ key: 'start', label: '开机', icon: '▶', cls: 'border-emerald-300 dark:border-emerald-800 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20' },
{ key: 'stop', label: '关机', icon: '⏻', cls: 'border-red-300 dark:border-red-800 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20' },
{ key: 'reboot', label: '重启', icon: '⟳', cls: 'border-amber-300 dark:border-amber-800 text-amber-700 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20' },
{ key: 'renew', label: '续费', icon: '💳', cls: 'border-blue-300 dark:border-blue-800 text-blue-700 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20' },
];
const capsText = Vue.computed(() => {
if (!m.caps || !m.caps.sdk_type || !m.caps.sdk_type.length) return '未知';
return m.caps.sdk_type.join(' / ');
});
return {
m, actions, capsText,
label: (k) => ACTION_LABELS[k] || k,
warn: (k) => ACTION_WARNS[k] || '',
ask: askAction, run: runAction, close: closeActions,
};
},
};
/* ================= DNS 解析记录管理弹窗 ================= */
const DnsModal = {
template: `
<div v-if="m.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-2xl md:rounded-xl rounded-t-xl max-h-[92vh] flex flex-col">
<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">🌐 DNS 解析<span v-if="m.zoneId" class="text-slate-400 font-normal"> · {{ m.zoneId }}</span></h3>
<div class="flex items-center gap-3">
<button v-if="!m.loading && !m.error" @click="newRecord" class="text-xs text-blue-600 dark:text-blue-400">+ 新增记录</button>
<button @click="close" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
</div>
<div class="px-5 py-4 overflow-y-auto space-y-3">
<div v-if="m.loading" class="text-xs text-slate-400 py-6 text-center">正在读取解析记录…</div>
<div v-else-if="m.error && !m.records.length" class="text-xs px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400">{{ m.error }}</div>
<template v-else>
<p v-if="!m.records.length" class="text-xs text-slate-400 py-4 text-center">该域名下暂无解析记录。</p>
<!-- 记录列表:窄屏用卡片,宽屏用表格 -->
<div v-else class="hidden md:block border border-slate-100 dark:border-slate-800 rounded-lg overflow-hidden">
<table class="w-full text-xs">
<thead class="bg-slate-50 dark:bg-slate-800/60 text-slate-500 dark:text-slate-400">
<tr><th class="text-left px-3 py-2 font-medium">名称</th><th class="text-left px-3 py-2 font-medium">类型</th><th class="text-left px-3 py-2 font-medium">记录值</th><th class="text-left px-3 py-2 font-medium">TTL</th><th class="text-right px-3 py-2 font-medium">操作</th></tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-for="r in m.records" :key="r.record_id">
<td class="px-3 py-2 break-all">
{{ r.name }}
<span v-if="r.comment" class="block text-[10px] text-slate-400">{{ r.comment }}</span>
</td>
<td class="px-3 py-2"><span class="px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300">{{ r.type }}</span></td>
<td class="px-3 py-2 break-all text-slate-600 dark:text-slate-300">{{ r.content }}<span v-if="r.proxied" class="ml-1 text-orange-500" title="已走 Cloudflare 代理">☁</span></td>
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">
{{ r.ttl || '自动' }}
<span v-if="hasPriority(r)" class="ml-1.5" title="MX/SRV 优先级,数字越小越优先">优 {{ r.priority }}</span>
</td>
<td class="px-3 py-2 text-right whitespace-nowrap">
<button @click="editRecord(r)" class="text-blue-600 dark:text-blue-400 mr-3">编辑</button>
<button @click="del(r)" class="text-red-600 dark:text-red-400">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="m.records.length" class="md:hidden space-y-2">
<div v-for="r in m.records" :key="r.record_id" class="border border-slate-100 dark:border-slate-800 rounded-lg px-3 py-2">
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-[10px] text-slate-600 dark:text-slate-300">{{ r.type }}</span>
<span class="text-xs truncate flex-1">{{ r.name }}</span>
<span v-if="hasPriority(r)" class="text-[10px] text-slate-400">优 {{ r.priority }}</span>
<span class="text-[10px] text-slate-400">TTL {{ r.ttl || '自动' }}</span>
</div>
<div class="text-xs text-slate-600 dark:text-slate-300 break-all mt-1">{{ r.content }}<span v-if="r.proxied" class="ml-1 text-orange-500">☁</span></div>
<div v-if="r.comment" class="text-[10px] text-slate-400 mt-0.5">{{ r.comment }}</div>
<div class="flex gap-4 justify-end mt-1 text-xs">
<button @click="editRecord(r)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(r)" class="text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</div>
<!-- 新增 / 编辑表单 -->
<div v-if="m.editing" class="border border-blue-200 dark:border-blue-900 rounded-lg px-3 py-3 space-y-2 bg-blue-50/40 dark:bg-blue-900/10">
<div class="text-xs font-medium text-blue-700 dark:text-blue-300">{{ m.editing === 'new' ? '新增记录' : '编辑记录' }}</div>
<div class="grid grid-cols-2 gap-2">
<label class="text-xs text-slate-500 col-span-2">名称
<input v-model="m.form.name" placeholder="www.example.com 或 www" 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-slate-800 dark:text-slate-200">
</label>
<label class="text-xs text-slate-500">类型
<select v-model="m.form.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-slate-800 dark:text-slate-200">
<option v-for="t in recordTypes" :key="t" :value="t">{{ t }}</option>
</select>
</label>
<label class="text-xs text-slate-500">TTL(秒)
<input v-model.number="m.form.ttl" type="number" min="60" max="86400" 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-slate-800 dark:text-slate-200">
</label>
<label class="text-xs text-slate-500 col-span-2">记录值
<input v-model="m.form.content" placeholder="1.2.3.4 / target.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-slate-800 dark:text-slate-200">
</label>
<label v-if="m.form.type === 'MX' || m.form.type === 'SRV'" class="text-xs text-slate-500">优先级
<input v-model.number="m.form.priority" type="number" min="0" max="65535" 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-slate-800 dark:text-slate-200">
</label>
<label v-if="isCloudflare" class="text-xs text-slate-500 flex items-center gap-2 pt-4">
<input type="checkbox" v-model="m.form.proxied"> 走 Cloudflare 代理
</label>
</div>
<div v-if="m.error" class="text-xs px-2 py-1.5 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400">{{ m.error }}</div>
<div class="flex gap-2 justify-end">
<button @click="cancelEdit" :disabled="m.busy" class="px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-xs">取消</button>
<button @click="save" :disabled="m.busy" class="px-3 py-1.5 rounded-lg bg-blue-600 text-white text-xs disabled:opacity-50">{{ m.busy ? '保存中…' : '保存' }}</button>
</div>
</div>
<div v-else-if="m.error" class="text-xs px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400">{{ m.error }}</div>
</template>
</div>
</div>
</div>`,
setup() {
const m = dnsModal;
const isCloudflare = Vue.computed(() => m.provider === 'cloudflare' || m.provider === 'cloudflare-api');
// 显式判 null:MX 优先级 0 是合法值且为 falsy,用 r.priority 直接当真值会漏显示
const hasPriority = (r) => r.priority !== null && r.priority !== undefined && r.priority !== '';
return {
m, recordTypes: RECORD_TYPES, isCloudflare, hasPriority,
newRecord: newDnsRecord, editRecord: editDnsRecord, cancelEdit: cancelDnsEdit,
save: saveDnsRecord, del: deleteDnsRecord, close: closeDns,
};
},
};
/* ================= 全局确认弹窗(替代原生 confirm) ================= */
const ConfirmModal = {
template: `
<div v-if="store.confirmModal.show" class="fixed inset-0 bg-black/40 z-[70] flex items-center justify-center p-4" @click.self="closeConfirm(false)">
<div class="bg-white dark:bg-slate-900 rounded-xl w-full max-w-sm p-5 shadow-xl">
<h3 class="font-semibold text-sm mb-2">{{ store.confirmModal.title }}</h3>
<p class="text-sm text-slate-600 dark:text-slate-300 whitespace-pre-wrap">{{ store.confirmModal.message }}</p>
<div class="flex gap-2 mt-4 justify-end">
<button @click="closeConfirm(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 @click="closeConfirm(true)" class="px-4 py-2 rounded-lg text-sm text-white" :class="store.confirmModal.danger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'">确认</button>
</div>
</div>
</div>`,
setup() { return { store, closeConfirm }; },
};
/* ================= 全局结果弹窗(替代原生 alert) ================= */
const ResultModal = {
template: `
<div v-if="store.resultModal.show" class="fixed inset-0 bg-black/40 z-[70] flex items-center justify-center p-4" @click.self="closeResult">
<div class="bg-white dark:bg-slate-900 rounded-xl w-full max-w-md p-5 shadow-xl">
<h3 class="font-semibold text-sm mb-2" :class="store.resultModal.isError ? 'text-red-600 dark:text-red-400' : ''">{{ store.resultModal.title }}</h3>
<p class="text-sm text-slate-600 dark:text-slate-300 whitespace-pre-wrap max-h-64 overflow-y-auto">{{ store.resultModal.message }}</p>
<div class="flex gap-2 mt-4 justify-end">
<button @click="closeResult" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">确定</button>
</div>
</div>
</div>`,
setup() { return { store, closeResult }; },
};