Files
vps-manager/static/js/store.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

519 lines
24 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 { reactive, computed } = Vue;
const Api = window.VpsApi;
const Fmt = window.VpsFmt;
/* ---------------- 全局状态 ---------------- */
/* 手动填入的授权来源候选(本地留存)
*
* credentials 表只存条目本身,不维护来源字典;为了让「这次手填的值下次还能点」,
* 把非预设值记在 localStorage:oauth_custom 是候选队列,oauth_ignored 是被用户
* 划掉过的值(防止从凭据列表反推时又冒回来)。
*/
const OAUTH_CUSTOM_KEY = 'vps_oauth_custom';
const OAUTH_IGNORED_KEY = 'vps_oauth_ignored';
function _readOauthList(k) {
try {
const raw = JSON.parse(localStorage.getItem(k) || '[]');
return Array.isArray(raw) ? raw.filter(x => typeof x === 'string' && x) : [];
} catch (e) { return []; }
}
function _writeOauthList(k, list) {
try { localStorage.setItem(k, JSON.stringify(list.slice(0, 24))); } catch (e) { /* 隐私模式写入失败只影响候选展示 */ }
}
const store = reactive({
view: 'dashboard',
assets: [],
providers: [],
accounts: [],
subdomains: [],
siteCerts: [],
overview: {},
expiring: [],
serverMetrics: {},
search: '',
filterType: '',
filterStatus: '',
filterProvider: '', // 按平台筛选(slug),从平台页「关联 N 个资产」跳转而来
assetTab: '', // 资产页 TAB:'' 全部 / vps / domain / cloudflare / subscription(付费资产) / ai_agent
monitorTab: '', // 监控中心 TAB:同上
monitorExpiryFilter: '', // 监控中心到期区间子筛选:'' 全部 / soon30 / soon7 / expired
monitorSslStatusFilter: '', // 监控中心 SSL 状态子筛选:'' 全部 / valid / expiring / expired_error
dark: localStorage.getItem('vps_dark') === '1',
loading: false,
error: '',
assetModal: { show: false, editing: null, form: null },
providerModal: { show: false, editing: null, form: null },
accountModal: { show: false, editing: null, form: null },
accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名
accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部
// 凭据库(密码 + 2FA):全量列表,搜索/过滤交给服务端
credentials: [],
credentialModal: { show: false, editing: null, form: null },
credentialSaving: false, // 保存锁,防双击重复提交
vaultSearch: '',
vaultOtpOnly: false, // 只看已绑定 2FA 的条目
oauthCustom: _readOauthList(OAUTH_CUSTOM_KEY), // 手填来源候选(预设之外)
oauthIgnored: _readOauthList(OAUTH_IGNORED_KEY), // 已划掉的手填来源
});
function applyDark() {
document.documentElement.classList.toggle('dark', store.dark);
localStorage.setItem('vps_dark', store.dark ? '1' : '0');
}
/* ---------------- 表单工厂 ---------------- */
function emptyAssetForm() {
return {
name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly', renew_url: '', cancel_url: '',
account_id: null, expiry_date: '', auto_renew: false, cost: 0, currency: 'USD',
status: 'active', is_archived: false, remark: '',
vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '', ssh_user: '', login_method: 'key', ssh_key: '', password: '', purpose: '' },
domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '', bind_asset_id: null },
ai_detail: { provider: '', api_key: '', plan: '', balance: null, currency: 'USD', monthly_usage: null, monthly_limit: null },
cloudflare_detail: { account_email: '', sub_type: 'zone', sub_name: '', zone_name: '', status: '' },
};
}
function buildAssetPayload(f) {
const p = {
name: f.name, asset_type: f.asset_type, provider: f.provider,
provider_id: f.provider_id || null, renewal_cycle: f.renewal_cycle || null,
renew_url: f.renew_url || null, cancel_url: f.cancel_url || null,
account_id: f.account_id || null, expiry_date: f.expiry_date || null,
auto_renew: !!f.auto_renew, cost: Number(f.cost) || 0, currency: f.currency,
status: f.status, is_archived: !!f.is_archived, remark: f.remark || null,
};
if (f.asset_type === 'vps') {
const v = f.vps_detail;
p.vps_detail = { ip_address: v.ip_address, tailscale_ip: v.tailscale_ip || null, region: v.region || null, os: v.os || null, cpu_cores: Number(v.cpu_cores) || 1, memory_gb: Number(v.memory_gb) || 1, disk_gb: Number(v.disk_gb) || 20, bandwidth_gb: v.bandwidth_gb ? Number(v.bandwidth_gb) : null, ssh_port: Number(v.ssh_port) || 22, panel_url: v.panel_url || null, ssh_user: v.ssh_user || null, login_method: v.login_method || 'key', ssh_key: v.ssh_key || null, password: v.password || null, purpose: v.purpose || null };
} else if (f.asset_type === 'domain') {
const d = f.domain_detail;
p.domain_detail = { domain_name: d.domain_name, registrar: d.registrar || null, dns_provider: d.dns_provider || null, cloudflare_account: d.cloudflare_account || null, is_using: !!d.is_using, redirect_target: d.redirect_target || null, bind_asset_id: d.bind_asset_id || null };
} else if (f.asset_type === 'ai_agent') {
const a = f.ai_detail;
p.ai_detail = { provider: a.provider, api_key: a.api_key || null, plan: a.plan || null, balance: a.balance ? Number(a.balance) : null, currency: a.currency || 'USD', monthly_usage: a.monthly_usage ? Number(a.monthly_usage) : null, monthly_limit: a.monthly_limit ? Number(a.monthly_limit) : null };
} else if (f.asset_type === 'cloudflare') {
const c = f.cloudflare_detail;
p.cloudflare_detail = { account_email: c.account_email || null, sub_type: c.sub_type || 'zone', sub_name: c.sub_name || null, zone_name: c.zone_name || null, status: c.status || null };
}
return p;
}
/* ---------------- 数据加载 ---------------- */
// 后端 status 参数只认单个 AssetStatus 枚举值;「异常」是「已停止 + 已过期」的组合概念,
// 后端不支持多值筛选,这里当作前端本地筛选的哨兵值,不发给 API,由视图层自己过滤。
const CLIENT_SIDE_STATUS_FILTERS = new Set(['abnormal']);
async function loadAssets() {
const params = new URLSearchParams();
if (store.filterType) params.set('asset_type', store.filterType);
if (store.filterStatus && !CLIENT_SIDE_STATUS_FILTERS.has(store.filterStatus)) params.set('status', store.filterStatus);
if (store.filterProvider) params.set('provider', store.filterProvider);
if (store.search) params.set('q', store.search);
params.set('sort', 'expiry_date'); params.set('order', 'asc');
store.assets = await Api.get('/assets?' + params.toString());
}
async function loadProviders() { store.providers = await Api.get('/providers'); }
async function loadAccounts() { store.accounts = await Api.get('/accounts'); }
async function loadCredentials() {
const params = new URLSearchParams();
if (store.vaultSearch) params.set('q', store.vaultSearch);
if (store.vaultOtpOnly) params.set('has_otp', 'true');
store.credentials = await Api.get('/credentials?' + params.toString());
syncOauthCustom();
}
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
async function loadAll() {
store.loading = true; store.error = '';
try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); }
catch (e) { store.error = e.message; } finally { store.loading = false; }
}
/* ---------------- 资产操作 ---------------- */
function openAssetCreate(presetType) {
const form = emptyAssetForm();
if (presetType) form.asset_type = presetType;
store.assetModal = { show: true, editing: null, form };
}
function openAICreate(provider) {
const form = emptyAssetForm();
form.asset_type = 'ai_agent';
if (provider) {
form.provider = provider;
form.ai_detail.provider = provider;
const p = store.providers.find(x => x.slug === provider);
if (p) form.provider_id = p.id;
}
store.assetModal = { show: true, editing: null, form };
}
function openAssetEdit(a) {
const form = emptyAssetForm();
['name', 'asset_type', 'provider', 'provider_id', 'renewal_cycle', 'renew_url', 'cancel_url', 'account_id', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark'].forEach(k => { form[k] = a[k]; });
if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail);
if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail);
if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail);
if (a.cloudflare_detail) Object.assign(form.cloudflare_detail, a.cloudflare_detail);
form.vps_detail.ssh_key = ''; form.vps_detail.password = '';
store.assetModal = { show: true, editing: a.id, form };
}
async function saveAsset() {
store.error = '';
const f = store.assetModal.form;
try {
const payload = buildAssetPayload(f);
if (store.assetModal.editing) await Api.put('/assets/' + store.assetModal.editing, payload);
else await Api.post('/assets', payload);
store.assetModal.show = false;
await loadAll();
} catch (e) { store.error = e.message; }
}
async function deleteAsset(a) {
if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return;
store.error = '';
try { await Api.del('/assets/' + a.id); await loadAll(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 平台操作 ---------------- */
function openProviderCreate() {
store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', services: '', website: '', console_url: '', sdk_type: '', enabled: true, remark: '' } };
}
function openProviderEdit(p) {
store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, services: p.services || '', website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '' } };
}
async function saveProvider() {
store.error = '';
const f = store.providerModal.form;
// 凭证已下沉到账号层,平台不再携带 api_config
const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, services: f.services || null, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null };
try {
if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload);
else await Api.post('/providers', payload);
store.providerModal.show = false;
await loadProviders();
} catch (e) { store.error = e.message; }
}
async function seedProviders() {
store.error = '';
try { const r = await Api.post('/providers/seed', {}); await loadProviders(); alert('已初始化预设平台,新增 ' + r.added + ' 个'); }
catch (e) { store.error = e.message; }
}
async function deleteProvider(p) {
if (!confirm('确认删除平台「' + p.name + '」?')) return;
try { await Api.del('/providers/' + p.id); await loadProviders(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 账号操作 ---------------- */
function openAccountsView(providerSlug) {
store.accountsModal = { show: true, providerSlug: providerSlug || null };
}
function openAccountCreate(platform) {
store.accountModal = { show: true, editing: null, form: { name: '', platform: platform || '', remark: '', login_user: '', login_password: '', api_config: '' } };
}
function openAccountEdit(a) {
// login_password / api_config 留空 = 不修改原凭证
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '', login_user: a.login_user || '', login_password: '', api_config: '' } };
}
async function saveAccount() {
// 提交锁:双击/网络慢时重复点击会发出两次请求,第二次必然重名 400
if (store.accountSaving) return;
store.error = '';
store.accountSaving = true;
const f = store.accountModal.form;
const payload = {
name: f.name, platform: f.platform || null, remark: f.remark || null,
login_user: f.login_user || null,
login_password: f.login_password || null,
api_config: f.api_config || null,
};
try {
if (store.accountModal.editing) await Api.put('/accounts/' + store.accountModal.editing, payload);
else await Api.post('/accounts', payload);
store.accountModal.show = false;
// 重命名会同步更新资产端引用,需一并刷新资产
await Promise.all([loadAccounts(), loadAssets()]);
} catch (e) { store.error = e.message; }
finally { store.accountSaving = false; }
}
async function deleteAccount(a) {
let msg = '确认删除账号「' + a.name + '」?';
if (a.asset_count) msg += '\n有 ' + a.asset_count + ' 个资产引用该账号(资产中已填的账号名不受影响)。';
if (!confirm(msg)) return;
store.error = '';
try { await Api.del('/accounts/' + a.id); await loadAccounts(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 凭据库(密码 + 2FA) ---------------- */
// 手填来源记入候选:预设内的值不记(清单已在 api.js),并撤销先前的「划掉」
function rememberOauthProvider(raw) {
const key = Fmt.normalizeOauth(raw);
if (!key || Fmt.OAUTH_PRESETS.some(p => p.key === key)) return;
store.oauthIgnored = store.oauthIgnored.filter(x => x !== key);
_writeOauthList(OAUTH_IGNORED_KEY, store.oauthIgnored);
store.oauthCustom = [key, ...store.oauthCustom.filter(x => x !== key)];
_writeOauthList(OAUTH_CUSTOM_KEY, store.oauthCustom);
}
// 划掉一个候选(错别字等):同时进 ignore 名单,避免从凭据列表反推时再冒回来
function dropOauthProvider(raw) {
const key = Fmt.normalizeOauth(raw);
if (!key) return;
store.oauthCustom = store.oauthCustom.filter(x => x !== key);
_writeOauthList(OAUTH_CUSTOM_KEY, store.oauthCustom);
if (!store.oauthIgnored.includes(key)) {
store.oauthIgnored = [...store.oauthIgnored, key];
_writeOauthList(OAUTH_IGNORED_KEY, store.oauthIgnored);
}
}
// 候选跟服务端的值对账:换设备/别人录过的来源也能直接点(忽略名单优先)
function syncOauthCustom() {
const used = [];
for (const c of store.credentials) {
const key = Fmt.normalizeOauth(c.oauth_provider);
if (!key || store.oauthIgnored.includes(key)) continue;
if (Fmt.OAUTH_PRESETS.some(p => p.key === key)) continue;
if (!used.includes(key)) used.push(key);
}
const merged = [...store.oauthCustom, ...used.filter(k => !store.oauthCustom.includes(k))];
if (merged.length !== store.oauthCustom.length || merged.some((k, i) => k !== store.oauthCustom[i])) {
store.oauthCustom = merged;
_writeOauthList(OAUTH_CUSTOM_KEY, merged);
}
}
function emptyCredentialForm() {
return {
site: '', username: '', login_type: 'password', oauth_provider: '',
password: '', url: '', note: '', otp_secret: '', otp_code: '',
};
}
function openCredentialCreate(preset) {
const form = emptyCredentialForm();
if (preset) Object.assign(form, preset);
store.credentialModal = { show: true, editing: null, form };
}
function openCredentialEdit(c) {
// password / otp_secret 留空 = 不修改(与账号凭证惯例一致)
store.credentialModal = {
show: true, editing: c.id,
form: {
site: c.site, username: c.username || '', login_type: c.login_type,
oauth_provider: c.oauth_provider || '', password: '', url: c.url || '',
note: c.note || '', otp_secret: '', otp_code: '',
},
};
}
async function saveCredential() {
// 提交锁:双击/网络慢时重复点击会发出两次请求,造成重复条目
if (store.credentialSaving) return;
store.error = '';
store.credentialSaving = true;
const f = store.credentialModal.form;
const editing = store.credentialModal.editing;
// 授权来源允许手填(中文/英文均可),归一化后回写表单,保证候选高亮与入库值一致
const oauthKey = f.login_type === 'oauth' ? Fmt.normalizeOauth(f.oauth_provider) : '';
f.oauth_provider = oauthKey;
const payload = {
site: f.site, username: f.username || null, login_type: f.login_type,
oauth_provider: oauthKey || null, url: f.url || null, note: f.note || null,
};
try {
if (editing) {
// 密码留空 = 不修改(后端 None 语义),填了才提交
if (f.password) payload.password = f.password;
await Api.put('/credentials/' + editing, payload);
// 2FA 走独立接口(动态码可留空:有码则服务端校验,无码存为「未验证」)
if (f.otp_secret) {
await Api.put('/credentials/' + editing + '/otp', { secret: f.otp_secret, code: f.otp_code || null });
}
} else {
payload.password = f.password || null;
if (f.otp_secret) { payload.otp_secret = f.otp_secret; payload.otp_code = f.otp_code || null; }
await Api.post('/credentials', payload);
}
store.credentialModal.show = false;
if (oauthKey) rememberOauthProvider(oauthKey);
// 账号侧 has_login_password / has_otp 来自关联凭据,需一并刷新
await Promise.all([loadCredentials(), loadAccounts()]);
} catch (e) { store.error = e.message; }
finally { store.credentialSaving = false; }
}
async function deleteCredential(c) {
const label = c.site + (c.username ? ' · ' + c.username : '');
if (!confirm('确认删除凭据「' + label + '」?此操作不可恢复。')) return;
store.error = '';
try {
await Api.del('/credentials/' + c.id);
await Promise.all([loadCredentials(), loadAccounts()]);
} catch (e) { store.error = e.message; }
}
async function unbindCredentialOtp(c) {
if (!confirm('确认解绑「' + c.site + '」的 2FA?解绑后需重新录入 secret。')) return;
store.error = '';
try { await Api.del('/credentials/' + c.id + '/otp'); await loadCredentials(); }
catch (e) { store.error = e.message; }
}
// 补验证:secret 常是历史留存,录入时手上没验证器;事后从能出码的设备取一次当前码转正
async function verifyCredentialOtp(c, code) {
const trimmed = (code || '').trim();
if (!/^\d{6}$/.test(trimmed)) { store.error = '请输入 6 位数字动态码'; return false; }
store.error = '';
try {
await Api.post('/credentials/' + c.id + '/otp/verify', { code: trimmed });
await Promise.all([loadCredentials(), loadAccounts()]);
showToast('✓ ' + c.site + ' 的 2FA 已验证');
return true;
} catch (e) { store.error = e.message; return false; }
}
/* ---------------- 复制与轻提示(toast) ---------------- */
const toast = Vue.reactive({ show: false, text: '', _timer: null });
function showToast(text) {
toast.text = text;
toast.show = true;
if (toast._timer) clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.show = false; }, 1600);
}
function copyText(text, label) {
const done = () => showToast('✓ ' + (label || '内容') + '已复制');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => _fallbackCopy(text, done));
} else { _fallbackCopy(text, done); }
}
function _fallbackCopy(text, done) {
// 非安全上下文(http 局域网)无 clipboard API,降级用 execCommand
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); } catch (e) { showToast('复制失败,请手动选择'); }
document.body.removeChild(ta);
}
async function copyCredentialPassword(c) {
store.error = '';
try {
const r = await Api.get('/credentials/' + c.id + '/password');
copyText(r.password, '密码');
} catch (e) { store.error = e.message; }
}
async function copyAccountPassword(a) {
// 账号侧复制:后端已重定向到关联凭据,接口语义不变
store.error = '';
try {
const r = await Api.get('/accounts/' + a.id + '/password');
copyText(r.password, '密码');
} catch (e) { store.error = e.message; }
}
/* ---------------- 2FA 动态码(服务端生成,本地倒计时) ---------------- */
// otpState[credential_id] = { code, expiresIn, loading, timer };secret 永不下发到前端
const otpState = Vue.reactive({});
// 取码请求代号:stopOtp 会推进代号,让在途请求返回后不再写状态/重启倒计时
// (否则「请求在途时收起」会让已收起的条目在后台持续轮询动态码)
const otpGen = {};
async function fetchOtp(id) {
// 先经 Proxy 创建占位对象,再取回响应式代理。
// 注意不能写 `const s = otpState[id] || (otpState[id] = {...})`:赋值表达式的
// 返回值是 raw 对象,后续 s.code = ... 绕过 Proxy 不触发视图更新(Vue 3 陷阱)。
if (!otpState[id]) otpState[id] = { code: '', expiresIn: 0, loading: false, timer: null };
const s = otpState[id];
const gen = (otpGen[id] = (otpGen[id] || 0) + 1);
s.loading = true;
try {
const r = await Api.get('/credentials/' + id + '/otp');
if (otpGen[id] !== gen) return; // 期间已被 stopOtp 取消
s.code = r.code;
s.expiresIn = r.expires_in;
if (s.timer) clearInterval(s.timer);
// 本地逐秒倒计时,归零自动拉下一个码(不做秒级轮询)
s.timer = setInterval(() => {
s.expiresIn--;
if (s.expiresIn <= 0) { clearInterval(s.timer); s.timer = null; fetchOtp(id); }
}, 1000);
} catch (e) { store.error = e.message; }
finally { if (otpGen[id] === gen) s.loading = false; }
}
function stopOtp(id) {
otpGen[id] = (otpGen[id] || 0) + 1;
const s = otpState[id];
if (!s) return;
if (s.timer) { clearInterval(s.timer); s.timer = null; }
// 停表同时清掉旧码:otpState 是全局且条目常驻,留着会让下次展开
// 在重新取码前闪一下已过期的码(用户有可能把旧码复制走)
s.code = '';
s.expiresIn = 0;
s.loading = false;
}
function stopAllOtp() {
for (const id of Object.keys(otpState)) stopOtp(Number(id));
}
/* ---------------- 平台页跳转:按平台查看资产 ---------------- */
function openAssetsByProvider(p) {
store.filterProvider = p.slug;
store.assetTab = '';
store.filterType = '';
if (window.location.hash !== '#assets') window.location.hash = '#assets';
loadAssets();
}
function clearProviderFilter() {
store.filterProvider = '';
loadAssets();
}
/* ---------------- 总览页卡片跳转:带预设筛选条件切到资产页 ---------------- */
// 未显式传入的筛选项一律重置,避免带着上一页的旧筛选条件跳过去后数字对不上
function openAssetsWithFilter({ assetTab = '', filterStatus = '', filterType = '', filterProvider = '', search = '' } = {}) {
store.assetTab = assetTab;
store.filterStatus = filterStatus;
store.filterType = filterType;
store.filterProvider = filterProvider;
store.search = search;
if (window.location.hash !== '#assets') window.location.hash = '#assets';
else store.view = 'assets';
loadAssets();
}
/* ---------------- 总览页卡片跳转:带预设到期区间筛选切到监控中心 ---------------- */
function openMonitorWithExpiryFilter(filter) {
store.monitorTab = '';
store.monitorExpiryFilter = filter || '';
if (window.location.hash !== '#monitor') window.location.hash = '#monitor';
else store.view = 'monitor';
}
/* ---------------- 服务器监控 ---------------- */
async function viewServer(a) {
try {
const [metrics, info, checks, security] = await Promise.all([
Api.get('/monitor/' + a.id + '/metrics?limit=200'),
Api.get('/monitor/' + a.id + '/info'),
Api.get('/monitor/' + a.id + '/security'),
Api.get('/monitor/' + a.id + '/security-score'),
]);
store.serverMetrics = { asset: a, metrics, info, checks, security };
} catch (e) { store.serverMetrics = { asset: a, metrics: [], info: null, checks: [], security: null }; }
// 通过 hash 路由切换视图,保证刷新/分享链接后能回到 servers 视图
if (window.location.hash !== '#servers') {
window.location.hash = '#servers';
} else {
store.view = 'servers';
}
}
/* ---------------- 设置 ---------------- */
const settings = reactive({ apiKey: Api.getApiKey(), saved: false });
function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); }
function normalizedMonthly(cost, cycle) {
if (!cost) return 0;
if (cycle === 'yearly') return cost / 12;
if (cycle === 'quarterly') return cost / 3;
return cost;
}