feat(ui): 全局弹窗替代原生 alert/confirm,添加防重复提交与搜索功能
- 新增 ConfirmModal / ResultModal 全局组件,替代原生 alert/confirm/prompt - 资产/平台/账号保存添加防重复提交锁(assetSaving / providerSaving) - 添加 Esc 键盘快捷键关闭模态框 - 平台/监控视图添加搜索过滤功能 - 修复监控中心 SSL TAB 下新增按钮的显示逻辑 - 调整 toast 位置避免遮挡底部导航 - 统一各弹窗 z-index 层级
This commit is contained in:
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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天」
|
||||
|
||||
@@ -50,13 +50,14 @@ const MonitorView = {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 类别 TAB -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<!-- 类别 TAB + 搜索 -->
|
||||
<div class="flex flex-wrap gap-1.5 items-center">
|
||||
<button v-for="t in tabs" :key="t.key" @click="switchTab(t.key)" :title="t.tip"
|
||||
class="text-sm px-3 py-1.5 rounded-full transition"
|
||||
:class="store.monitorTab===t.key ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'">
|
||||
{{ t.label }}
|
||||
</button>
|
||||
<input v-model="search" placeholder="搜索名称/平台…" class="ml-auto text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-32 md:w-40">
|
||||
</div>
|
||||
|
||||
<!-- 资产表格(非 SSL TAB) -->
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@ const ProvidersView = {
|
||||
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
|
||||
🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。凭证(登录信息/API Key)配在账号上:点平台卡片「👤 账号」新增账号并填写凭证,即可在账号上「测试/同步」拉取真实数据。
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<div class="flex gap-2 flex-wrap items-center">
|
||||
<input v-model="search" placeholder="搜索平台…" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-32 md:w-40">
|
||||
<button @click="seed" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">初始化预设平台</button>
|
||||
<button @click="create" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 自定义平台</button>
|
||||
<button @click="manageAccounts" class="text-sm px-3 py-1.5 rounded-lg border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20">👤 账号管理</button>
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user