🌐 DNS 解析 · {{ m.zoneId }}
@@ -797,3 +797,34 @@ const DnsModal = {
};
},
};
+
+/* ================= 全局确认弹窗(替代原生 confirm) ================= */
+const ConfirmModal = {
+ template: `
+
+
+
{{ store.confirmModal.title }}
+
{{ store.confirmModal.message }}
+
+
+
+
+
+
`,
+ setup() { return { store, closeConfirm }; },
+};
+
+/* ================= 全局结果弹窗(替代原生 alert) ================= */
+const ResultModal = {
+ template: `
+
+
+
{{ store.resultModal.title }}
+
{{ store.resultModal.message }}
+
+
+
+
+
`,
+ setup() { return { store, closeResult }; },
+};
diff --git a/static/js/store.js b/static/js/store.js
index d69b9dc..1c33791 100644
--- a/static/js/store.js
+++ b/static/js/store.js
@@ -48,8 +48,14 @@ const store = reactive({
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 时看全部
@@ -145,6 +151,16 @@ async function loadCredentials() {
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'); }
@@ -184,7 +200,9 @@ function openAssetEdit(a) {
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);
@@ -193,9 +211,10 @@ async function saveAsset() {
store.assetModal.show = false;
await loadAll();
} catch (e) { store.error = e.message; }
+ finally { store.assetSaving = false; }
}
async function deleteAsset(a) {
- if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return;
+ if (!await showConfirm('删除资产', '确认删除资产「' + a.name + '」?此操作不可恢复。', true)) return;
store.error = '';
try { await Api.del('/assets/' + a.id); await loadAll(); }
catch (e) { store.error = e.message; }
@@ -209,7 +228,9 @@ 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 };
@@ -219,14 +240,15 @@ async function saveProvider() {
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(); alert('已初始化预设平台,新增 ' + r.added + ' 个'); }
+ try { const r = await Api.post('/providers/seed', {}); await loadProviders(); showResult('初始化完成', '已初始化预设平台,新增 ' + r.added + ' 个'); }
catch (e) { store.error = e.message; }
}
async function deleteProvider(p) {
- if (!confirm('确认删除平台「' + p.name + '」?')) return;
+ if (!await showConfirm('删除平台', '确认删除平台「' + p.name + '」?', true)) return;
try { await Api.del('/providers/' + p.id); await loadProviders(); }
catch (e) { store.error = e.message; }
}
@@ -266,7 +288,7 @@ async function saveAccount() {
async function deleteAccount(a) {
let msg = '确认删除账号「' + a.name + '」?';
if (a.asset_count) msg += '\n有 ' + a.asset_count + ' 个资产引用该账号(资产中已填的账号名不受影响)。';
- if (!confirm(msg)) return;
+ if (!await showConfirm('删除账号', msg, true)) return;
store.error = '';
try { await Api.del('/accounts/' + a.id); await loadAccounts(); }
catch (e) { store.error = e.message; }
@@ -367,7 +389,7 @@ async function saveCredential() {
}
async function deleteCredential(c) {
const label = c.site + (c.username ? ' · ' + c.username : '');
- if (!confirm('确认删除凭据「' + label + '」?此操作不可恢复。')) return;
+ if (!await showConfirm('删除凭据', '确认删除凭据「' + label + '」?此操作不可恢复。', true)) return;
store.error = '';
try {
await Api.del('/credentials/' + c.id);
@@ -375,7 +397,7 @@ async function deleteCredential(c) {
} catch (e) { store.error = e.message; }
}
async function unbindCredentialOtp(c) {
- if (!confirm('确认解绑「' + c.site + '」的 2FA?解绑后需重新录入 secret。')) return;
+ 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; }
@@ -401,6 +423,26 @@ function showToast(text) {
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) {
@@ -689,7 +731,7 @@ async function saveDnsRecord() {
}
async function deleteDnsRecord(r) {
// 删解析会立刻影响线上访问,必须二次确认而不是直接删
- if (!window.confirm('确认删除解析记录?\n\n' + r.name + ' ' + r.type + ' ' + r.content + '\n\n删除后该记录立即失效,可能中断线上访问。')) return;
+ 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 {
diff --git a/static/js/views/assets.js b/static/js/views/assets.js
index 0ef79c2..015c085 100644
--- a/static/js/views/assets.js
+++ b/static/js/views/assets.js
@@ -141,26 +141,26 @@ const AssetsView = {
return p ? p.name : slug;
}
async function refreshBalance(a) {
- if (!confirm('刷新「' + a.name + '」的余额?')) return;
+ if (!await showConfirm('刷新余额', '刷新「' + a.name + '」的余额?')) return;
try {
const r = await Api.post('/assets/' + a.id + '/refresh-balance', {});
- alert('余额已更新:' + r.balance + ' ' + r.currency);
+ showResult('刷新成功', '余额已更新:' + r.balance + ' ' + r.currency);
await loadAssets();
- } catch (e) { alert('刷新失败:' + e.message); }
+ } catch (e) { showResult('刷新失败', e.message, true); }
}
async function syncAllAI() {
- if (!confirm('同步所有 AI 账号的余额?')) return;
+ if (!await showConfirm('同步 AI 余额', '同步所有 AI 账号的余额?')) return;
try {
const r = await Api.post('/sync/ai-balances', {});
// skipped:平台本身没余额接口(如 OpenAI 已下线 billing),不属于失败
- alert('同步完成:成功 ' + r.success + ',失败 ' + r.failed +
+ showResult('同步完成', '成功 ' + r.success + ',失败 ' + r.failed +
(r.skipped ? ',跳过 ' + r.skipped + '(平台无余额接口)' : '') +
(r.errors && r.errors.length ? '\n' + r.errors.join('\n') : ''));
await loadAssets();
- } catch (e) { alert('同步失败:' + e.message); }
+ } catch (e) { showResult('同步失败', e.message, true); }
}
async function syncAllAssets() {
- if (!confirm('从云端拉取所有已配 API 凭证账号下的 VPS 与域名?\n\n会按 (平台, 云端实例ID) 去重:已存在则更新状态/到期日,不存在则新建。')) return;
+ if (!await showConfirm('同步云端资产', '从云端拉取所有已配 API 凭证账号下的 VPS 与域名?\n\n会按 (平台, 云端实例ID) 去重:已存在则更新状态/到期日,不存在则新建。')) return;
try {
const r = await Api.post('/sync/assets', {});
const lines = [
@@ -172,9 +172,9 @@ const AssetsView = {
if (r.skipped_no_config) lines.push('跳过(账号未配 API 凭证):' + r.skipped_no_config);
if (r.skipped_no_adapter) lines.push('跳过(平台无可用适配器):' + r.skipped_no_adapter);
if (r.errors && r.errors.length) lines.push('\n' + r.errors.join('\n'));
- alert(lines.join('\n'));
+ showResult('同步完成', lines.join('\n'));
await Promise.all([loadAssets(), loadOverview()]);
- } catch (e) { alert('同步失败:' + e.message); }
+ } catch (e) { showResult('同步失败', e.message, true); }
}
return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI, syncAllAssets, canControl: assetCanControl, openAct: openActions };
},
diff --git a/static/js/views/domains.js b/static/js/views/domains.js
index 62c30d5..e19fe58 100644
--- a/static/js/views/domains.js
+++ b/static/js/views/domains.js
@@ -131,19 +131,19 @@ const DomainsView = {
}
async function saveSub(a) {
const f = forms[a.id];
- if (!f.host.trim()) { alert('请填写主机名'); return; }
+ if (!f.host.trim()) { showResult('提示', '请填写主机名', true); return; }
const payload = { host: f.host.trim(), record_type: f.record_type, record_value: f.record_value, note: f.note, is_active: !!f.is_active };
try {
if (f.editing) await Api.put('/subdomains/' + f.editing, payload);
else await Api.post('/subdomains', { ...payload, asset_id: a.id });
await loadSubdomains();
resetForm(a);
- } catch (e) { alert('保存失败:' + e.message); }
+ } catch (e) { showResult('保存失败', e.message, true); }
}
async function delSub(s) {
- if (!confirm('删除子域名「' + s.host + '」?')) return;
+ if (!await showConfirm('删除子域名', '删除子域名「' + s.host + '」?', true)) return;
try { await Api.del('/subdomains/' + s.id); await loadSubdomains(); }
- catch (e) { alert('删除失败:' + e.message); }
+ catch (e) { showResult('删除失败', e.message, true); }
}
async function probeSsl(defaultHost, a) {
const target = prompt('探测 SSL 证书的站点(留空取消):', defaultHost);
@@ -151,8 +151,8 @@ const DomainsView = {
try {
await Api.post('/site-certs', { hostname: target, asset_id: a.id });
await loadSiteCerts();
- alert('已探测:' + target);
- } catch (e) { alert('探测失败:' + e.message); }
+ showResult('探测成功', '已探测:' + target);
+ } catch (e) { showResult('探测失败', e.message, true); }
}
function certText(c) {
// 统一走 Fmt.countdownText:不再自己维护一份,也避免负数直出「-3天」
diff --git a/static/js/views/monitor.js b/static/js/views/monitor.js
index dea49f6..73b458d 100644
--- a/static/js/views/monitor.js
+++ b/static/js/views/monitor.js
@@ -50,13 +50,14 @@ const MonitorView = {
+
+
+
@@ -199,21 +200,26 @@ const MonitorView = {
else if (t) list = list.filter(a => a.asset_type === t);
return list;
});
+ const search = Vue.ref('');
const items = Vue.computed(() => {
let list = tabScopedAssets.value;
const ef = store.monitorExpiryFilter;
if (ef === 'soon30') list = list.filter(a => a.days_to_expiry >= 0 && a.days_to_expiry <= 30);
else if (ef === 'soon7') list = list.filter(a => a.days_to_expiry >= 0 && a.days_to_expiry <= 7);
else if (ef === 'expired') list = list.filter(a => a.days_to_expiry < 0);
+ const kw = search.value.trim().toLowerCase();
+ if (kw) list = list.filter(a => a.name.toLowerCase().includes(kw) || (a.provider_name || a.provider || '').toLowerCase().includes(kw));
return [...list].sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999));
});
- // SSL 证书:按剩余天数倒计时排序,可叠加状态子筛选
+ // SSL 证书:按剩余天数倒计时排序,可叠加状态子筛选与搜索
const sslList = Vue.computed(() => {
let list = store.siteCerts;
const f = store.monitorSslStatusFilter;
if (f === 'valid') list = list.filter(c => c.status === 'valid');
else if (f === 'expiring') list = list.filter(c => c.status === 'expiring');
else if (f === 'expired_error') list = list.filter(c => c.status === 'expired' || c.status === 'error');
+ const kw = search.value.trim().toLowerCase();
+ if (kw) list = list.filter(c => c.hostname.toLowerCase().includes(kw) || (c.asset_name || '').toLowerCase().includes(kw));
return [...list].sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999));
});
const sslStats = Vue.computed(() => {
@@ -223,12 +229,12 @@ const MonitorView = {
});
async function recheck(c) {
try { await Api.post('/site-certs/' + c.id + '/check', {}); await loadSiteCerts(); }
- catch (e) { alert('重测失败:' + e.message); }
+ catch (e) { showResult('重测失败', e.message, true); }
}
async function delCert(c) {
- if (!confirm('删除证书监控「' + c.hostname + '」?')) return;
+ if (!await showConfirm('删除证书监控', '删除证书监控「' + c.hostname + '」?', true)) return;
try { await Api.del('/site-certs/' + c.id); await loadSiteCerts(); }
- catch (e) { alert('删除失败:' + e.message); }
+ catch (e) { showResult('删除失败', e.message, true); }
}
const total = Vue.computed(() => tabScopedAssets.value.length);
const stats = Vue.computed(() => {
@@ -265,6 +271,6 @@ const MonitorView = {
if (d <= 90) return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
return 'bg-slate-500/10 text-slate-500';
}
- return { store, Fmt, tabs, items, total, stats, sslList, sslStats, emptyText, sslEmptyText, switchTab, toggleExpiryFilter, clearExpiryFilter, toggleSslStatusFilter, clearSslStatusFilter, countdownText, countdownBadge, recheck, delCert, edit: openAssetEdit, del: deleteAsset };
+ return { store, Fmt, tabs, items, total, stats, sslList, sslStats, emptyText, sslEmptyText, switchTab, toggleExpiryFilter, clearExpiryFilter, toggleSslStatusFilter, clearSslStatusFilter, countdownText, countdownBadge, recheck, delCert, edit: openAssetEdit, del: deleteAsset, search };
},
};
diff --git a/static/js/views/providers.js b/static/js/views/providers.js
index 9ba0224..eeae1b1 100644
--- a/static/js/views/providers.js
+++ b/static/js/views/providers.js
@@ -6,7 +6,8 @@ const ProvidersView = {
🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。凭证(登录信息/API Key)配在账号上:点平台卡片「👤 账号」新增账号并填写凭证,即可在账号上「测试/同步」拉取真实数据。
-
+
+
@@ -83,7 +84,14 @@ const ProvidersView = {
showAll.value = !showAll.value;
localStorage.setItem('vps_show_all_providers', showAll.value ? '1' : '0');
}
- const visibleProviders = Vue.computed(() => showAll.value ? store.providers : store.providers.filter(isUsed));
+ // 搜索过滤
+ const search = Vue.ref('');
+ const visibleProviders = Vue.computed(() => {
+ let list = showAll.value ? store.providers : store.providers.filter(isUsed);
+ const kw = search.value.trim().toLowerCase();
+ if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw) || p.slug.toLowerCase().includes(kw) || (p.name_en || '').toLowerCase().includes(kw));
+ return list;
+ });
const hiddenCount = Vue.computed(() => store.providers.length - visibleProviders.value.length);
function serviceList(p) {
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
@@ -111,6 +119,6 @@ const ProvidersView = {
if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
- return { store, Fmt, countOf, accountsCountOf, serviceList, isSupported, capsSummary, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
+ return { store, Fmt, countOf, accountsCountOf, serviceList, isSupported, capsSummary, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, search, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
},
};