/* 全局响应式状态与工具函数 */ 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: [], // 列表口径:按当前筛选条件(类型/状态/平台/搜索)的结果 allAssets: [], // 统计口径:不带筛选的全量资产(总览/监控卡片/平台计数用) providers: [], // 适配器能力矩阵(/providers/adapters):{adapters, supported_types, capabilities} // 前端据此如实展示「这个平台到底能不能同步/能不能启停/能不能管 DNS」, // 而不是只看 Provider.sdk_type 有没有值就宣称支持 adapters: { adapters: [], supported_types: [], capabilities: {} }, accounts: [], subdomains: [], siteCerts: [], overview: {}, 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']); // 列表口径 vs 统计口径必须分开:总览到期日历、监控中心统计卡片、平台页 // 「关联 N 个资产」、账号弹窗的资产清单都是全量语义,如果跟着筛选走, // 用户在资产页点了筛选(或从统计卡片跳转筛选)后,其他视图的数字会跟着变小, // 看起来像数据丢了。 async function loadAllAssets() { const params = new URLSearchParams(); params.set('sort', 'expiry_date'); params.set('order', 'asc'); store.allAssets = await Api.get('/assets?' + params.toString()); } function _hasAssetFilter() { return !!(store.filterType || store.filterStatus || store.filterProvider || store.search); } async function loadAssets() { await loadAllAssets(); if (!_hasAssetFilter()) { store.assets = [...store.allAssets]; // 无筛选时两个口径同源,不重复请求 return; } 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 loadAdapters() { store.adapters = await Api.get('/providers/adapters'); } 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 loadAll() { store.loading = true; store.error = ''; try { await Promise.all([loadAssets(), loadProviders(), loadAdapters(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview()]); } 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'; } } /* ---------------- 云端能力查询 ---------------- * 能力矩阵来自后端 /providers/adapters(registry.sdk_capabilities()), * 是「适配器真的实现了这个方法」的事实源。前端一律以它为准, * 不再拿 Provider.sdk_type 有没有值当支持依据——填了不存在的 sdk_type * 会让按钮可点,用户点了才收到 400。 */ function sdkCaps(sdkType) { if (!sdkType) return {}; return (store.adapters.capabilities || {})[sdkType] || {}; } function providerSdkType(slug) { const p = store.providers.find(x => x.slug === slug || x.name === slug); return p ? (p.sdk_type || null) : null; } /** 某平台是否声明了可用适配器(如实展示「未对接」而不是显示一个假的 sdk_type) */ function providerIsSupported(slug) { const t = providerSdkType(slug); return !!t && (store.adapters.supported_types || []).includes(t); } /** 平台在某能力上是否可用,如 capsOf('aliyun','list_vps') */ function capsOf(slug, cap) { return !!sdkCaps(providerSdkType(slug))[cap]; } /** 资产 -> 所属平台的 sdk_type(优先 provider_id,回退平台名/slug) */ function assetSdkType(a) { if (!a) return null; const p = a.provider_id ? store.providers.find(x => x.id === a.provider_id) : store.providers.find(x => x.slug === a.provider || x.name === a.provider || x.slug === a.provider_name); return p ? (p.sdk_type || null) : null; } /** 资产在某能力上是否可用;没配适配器的平台一律 false,按钮不渲染 */ function assetCan(a, cap) { return !!sdkCaps(assetSdkType(a))[cap]; } const INSTANCE_CAPS = ['start_instance', 'stop_instance', 'reboot_instance', 'renew_instance']; /** VPS 是否可远程操作(任一实例动作可用即可) */ function assetCanControl(a) { return a && a.asset_type === 'vps' && !!a.external_id && INSTANCE_CAPS.some(c => assetCan(a, c)); } /** 域名是否可管 DNS 解析 */ function assetCanDns(a) { return a && a.asset_type === 'domain' && assetCan(a, 'list_dns_records'); } /* ---------------- 云资源动作(启停/重启/续费) ---------------- * 这些动作会真实改变云端资源:关机会中断线上服务、续费会真实扣款。 * 因此一律走二次确认弹窗,且按钮可用性由后端 /actions/{id}/capabilities 决定。 */ const ACTION_LABELS = { start: '开机', stop: '关机', reboot: '重启', renew: '续费' }; const ACTION_WARNS = { start: '将向云端下发开机指令。', stop: '将向云端下发关机指令,机器上运行的服务会立即中断。', reboot: '将向云端下发重启指令,机器会短暂不可用。', renew: '将向云端下发续费指令,会产生真实扣款。', }; const actionModal = reactive({ show: false, asset: null, caps: null, loading: true, busy: false, confirm: null, // 待二次确认的动作名 periodMonths: 1, error: '', }); function openActions(asset) { actionModal.asset = asset; actionModal.caps = null; actionModal.loading = true; actionModal.confirm = null; actionModal.periodMonths = 1; actionModal.error = ''; actionModal.show = true; Api.get('/actions/' + asset.id + '/capabilities') .then(r => { actionModal.caps = r; }) .catch(e => { actionModal.error = e.message; }) .finally(() => { actionModal.loading = false; }); } function closeActions() { actionModal.show = false; actionModal.confirm = null; } function askAction(name) { actionModal.confirm = name; } async function runAction(name) { if (actionModal.busy) return; actionModal.busy = true; actionModal.error = ''; try { const body = { action: name }; if (name === 'renew') body.period_months = actionModal.periodMonths; const r = await Api.post('/actions/' + actionModal.asset.id + '/instance', body); if (r.ok) { showToast('✓ ' + ACTION_LABELS[name] + '指令已下发'); actionModal.confirm = null; await loadAllAssets(); if (store.view === 'assets') await loadAssets(); } else { actionModal.error = r.message || '云端拒绝了该操作'; } } catch (e) { actionModal.error = e.message; } finally { actionModal.busy = false; } } /* ---------------- DNS 记录管理 ---------------- */ const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA']; const dnsModal = reactive({ show: false, asset: null, zoneId: '', provider: '', records: [], loading: true, busy: false, error: '', editing: null, // null=关闭表单 / 'new'=新建 / record 对象=编辑 form: null, }); function openDns(asset) { dnsModal.asset = asset; dnsModal.records = []; dnsModal.loading = true; dnsModal.error = ''; dnsModal.editing = null; // 先用资产自带的平台 slug 兜底:列表请求失败时 provider 拿不到后端返回值, // 而 isCloudflare 靠它决定是否显示「走 Cloudflare 代理」勾选框。 dnsModal.provider = asset.provider || ''; dnsModal.show = true; Api.get('/actions/' + asset.id + '/dns') .then(r => { dnsModal.records = r.records || []; dnsModal.zoneId = r.zone_id; dnsModal.provider = r.provider; }) .catch(e => { dnsModal.error = e.message; }) .finally(() => { dnsModal.loading = false; }); } function closeDns() { dnsModal.show = false; dnsModal.editing = null; } function reloadDns() { dnsModal.loading = true; dnsModal.error = ''; Api.get('/actions/' + dnsModal.asset.id + '/dns') .then(r => { dnsModal.records = r.records || []; }) .catch(e => { dnsModal.error = e.message; }) .finally(() => { dnsModal.loading = false; }); } function newDnsRecord() { dnsModal.form = { name: '', type: 'A', content: '', ttl: null, priority: null, proxied: null, comment: '' }; dnsModal.editing = 'new'; } function editDnsRecord(r) { dnsModal.form = { name: r.name || '', type: r.type || 'A', content: r.content || '', ttl: r.ttl || null, priority: r.priority ?? null, proxied: r.proxied ?? null, comment: r.comment || '', }; dnsModal.editing = r; } function cancelDnsEdit() { dnsModal.editing = null; dnsModal.form = null; } async function saveDnsRecord() { if (dnsModal.busy) return; dnsModal.busy = true; dnsModal.error = ''; const isNew = dnsModal.editing === 'new'; const body = { ...dnsModal.form }; // 空字符串会被后端的 ge/le 校验拒掉,未填的可选字段一律不发 if (body.ttl === '' || body.ttl === null) delete body.ttl; if (body.priority === '' || body.priority === null) delete body.priority; if (body.proxied === null || body.proxied === '') delete body.proxied; if (!body.comment) delete body.comment; try { const r = isNew ? await Api.post('/actions/' + dnsModal.asset.id + '/dns', body) : await Api.put('/actions/' + dnsModal.asset.id + '/dns/' + encodeURIComponent(dnsModal.editing.record_id), body); if (r.ok) { showToast('✓ 记录已' + (isNew ? '创建' : '更新')); cancelDnsEdit(); reloadDns(); } else dnsModal.error = r.message || '操作被拒绝'; } catch (e) { dnsModal.error = e.message; } finally { dnsModal.busy = false; } } async function deleteDnsRecord(r) { // 删解析会立刻影响线上访问,必须二次确认而不是直接删 if (!window.confirm('确认删除解析记录?\n\n' + r.name + ' ' + r.type + ' ' + r.content + '\n\n删除后该记录立即失效,可能中断线上访问。')) return; if (dnsModal.busy) return; dnsModal.busy = true; dnsModal.error = ''; try { const res = await Api.del('/actions/' + dnsModal.asset.id + '/dns/' + encodeURIComponent(r.record_id)); if (res.ok) { showToast('✓ 记录已删除'); reloadDns(); } else dnsModal.error = res.message || '删除被拒绝'; } catch (e) { dnsModal.error = e.message; } finally { dnsModal.busy = false; } } /* ---------------- 设置 ---------------- */ 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; }