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

755 lines
35 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: [], // 列表口径:按当前筛选条件(类型/状态/平台/搜索)的结果
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: '',
// 全局确认弹窗
confirmModal: { show: false, title: '', message: '', danger: false, resolve: null },
// 全局结果弹窗(替代 alert)
resultModal: { show: false, title: '', message: '', isError: false },
assetModal: { show: false, editing: null, form: null },
assetSaving: false, // 资产保存中锁,防止双击/重复提交
providerModal: { show: false, editing: null, form: null },
providerSaving: false, // 平台保存中锁
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());
// 前端补充 URL 匹配:后端搜索可能不覆盖 url 字段
if (store.vaultSearch) {
const kw = store.vaultSearch.trim().toLowerCase();
store.credentials = store.credentials.filter(c =>
(c.site || '').toLowerCase().includes(kw) ||
(c.username || '').toLowerCase().includes(kw) ||
(c.note || '').toLowerCase().includes(kw) ||
(c.url || '').toLowerCase().includes(kw)
);
}
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() {
if (store.assetSaving) return;
store.error = '';
store.assetSaving = true;
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; }
finally { store.assetSaving = false; }
}
async function deleteAsset(a) {
if (!await showConfirm('删除资产', '确认删除资产「' + a.name + '」?此操作不可恢复。', true)) 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() {
if (store.providerSaving) return;
store.error = '';
store.providerSaving = true;
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; }
finally { store.providerSaving = false; }
}
async function seedProviders() {
store.error = '';
try { const r = await Api.post('/providers/seed', {}); await loadProviders(); showResult('初始化完成', '已初始化预设平台,新增 ' + r.added + ' 个'); }
catch (e) { store.error = e.message; }
}
async function deleteProvider(p) {
if (!await showConfirm('删除平台', '确认删除平台「' + p.name + '」?', true)) 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 (!await showConfirm('删除账号', msg, true)) 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 (!await showConfirm('删除凭据', '确认删除凭据「' + label + '」?此操作不可恢复。', true)) 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 (!await showConfirm('解绑 2FA', '确认解绑「' + c.site + '」的 2FA?解绑后需重新录入 secret。', true)) 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);
}
/* ---------------- 全局确认弹窗(替代原生 confirm) ---------------- */
function showConfirm(title, message, danger = false) {
return new Promise((resolve) => {
store.confirmModal = { show: true, title, message, danger, resolve };
});
}
function closeConfirm(result) {
store.confirmModal.show = false;
if (store.confirmModal.resolve) store.confirmModal.resolve(result);
store.confirmModal.resolve = null;
}
/* ---------------- 全局结果弹窗(替代原生 alert) ---------------- */
function showResult(title, message, isError = false) {
store.resultModal = { show: true, title, message, isError };
}
function closeResult() {
store.resultModal.show = false;
}
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 (!await showConfirm('删除解析记录', '确认删除解析记录?\n\n' + r.name + ' ' + r.type + ' ' + r.content + '\n\n删除后该记录立即失效,可能中断线上访问。', true)) 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;
}