feat(ui): 全局弹窗替代原生 alert/confirm,添加防重复提交与搜索功能
- 新增 ConfirmModal / ResultModal 全局组件,替代原生 alert/confirm/prompt - 资产/平台/账号保存添加防重复提交锁(assetSaving / providerSaving) - 添加 Esc 键盘快捷键关闭模态框 - 平台/监控视图添加搜索过滤功能 - 修复监控中心 SSL TAB 下新增按钮的显示逻辑 - 调整 toast 位置避免遮挡底部导航 - 统一各弹窗 z-index 层级
This commit is contained in:
+35
-3
@@ -61,9 +61,13 @@ const AppRoot = {
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="sticky top-0 z-30 bg-white/80 dark:bg-slate-900/80 backdrop-blur border-b border-slate-200 dark:border-slate-800 px-4 py-3 flex items-center gap-3">
|
||||
<h1 class="font-semibold text-slate-900 dark:text-white md:text-lg">{{ currentNav.label }}</h1>
|
||||
<div v-if="store.loading" class="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<span class="inline-block w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></span>
|
||||
<span class="hidden sm:inline">加载中…</span>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<input v-if="store.view==='assets'" v-model="store.search" @input="reload" 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-48">
|
||||
<button @click="quickAdd" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700 whitespace-nowrap">+ 新增</button>
|
||||
<button v-if="store.view!=='monitor' || store.monitorTab==='ssl'" @click="quickAdd" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700 whitespace-nowrap">+ 新增</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -89,9 +93,11 @@ const AppRoot = {
|
||||
<credential-modal></credential-modal>
|
||||
<action-modal></action-modal>
|
||||
<dns-modal></dns-modal>
|
||||
<confirm-modal></confirm-modal>
|
||||
<result-modal></result-modal>
|
||||
|
||||
<!-- 复制等操作的轻提示(底部导航上方,不遮内容) -->
|
||||
<div v-if="toast.show" class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[80] px-4 py-2 rounded-full bg-slate-900/90 dark:bg-slate-100/90 text-white dark:text-slate-900 text-xs shadow-lg pointer-events-none whitespace-nowrap">{{ toast.text }}</div>
|
||||
<div v-if="toast.show" class="fixed bottom-24 md:bottom-6 left-1/2 -translate-x-1/2 z-[80] px-4 py-2 rounded-full bg-slate-900/90 dark:bg-slate-100/90 text-white dark:text-slate-900 text-xs shadow-lg pointer-events-none whitespace-nowrap">{{ toast.text }}</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const appName = Api.CFG.appName;
|
||||
@@ -104,7 +110,12 @@ const AppRoot = {
|
||||
function quickAdd() {
|
||||
if (store.view === 'providers') return openProviderCreate();
|
||||
if (store.view === 'vault') return openCredentialCreate();
|
||||
// 资产页:按当前 TAB 预设类型;监控中心/总览/设置:默认 VPS
|
||||
// 监控中心:SSL TAB 下添加证书监控,其他情况隐藏新增按钮(模板已控制)
|
||||
if (store.view === 'monitor') {
|
||||
if (store.monitorTab === 'ssl') return showResult('提示', '请在「资产 → 域名」的子域名面板点「探测SSL」添加证书监控');
|
||||
return; // 非 SSL TAB 不响应(模板已隐藏按钮)
|
||||
}
|
||||
// 总览/设置:默认创建 VPS
|
||||
let preset = 'vps';
|
||||
if (store.view === 'assets') {
|
||||
const t = store.assetTab;
|
||||
@@ -124,6 +135,24 @@ const AppRoot = {
|
||||
},
|
||||
};
|
||||
|
||||
/* ================= 键盘快捷键 ================= */
|
||||
function initKeyboard() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Esc 关闭最上层模态框(按 z-index 从高到低检查)
|
||||
if (e.key === 'Escape') {
|
||||
if (store.resultModal.show) { closeResult(); return; }
|
||||
if (store.confirmModal.show) { closeConfirm(false); return; }
|
||||
if (store.credentialModal.show) { store.credentialModal.show = false; return; }
|
||||
if (store.accountModal.show) { store.accountModal.show = false; return; }
|
||||
if (store.assetModal.show) { store.assetModal.show = false; return; }
|
||||
if (store.providerModal.show) { store.providerModal.show = false; return; }
|
||||
if (store.accountsModal.show) { store.accountsModal.show = false; return; }
|
||||
if (actionModal.show) { closeActions(); return; }
|
||||
if (dnsModal.show) { closeDns(); return; }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= 挂载 ================= */
|
||||
const app = Vue.createApp(AppRoot);
|
||||
app.component('dashboard-view', DashboardView);
|
||||
@@ -140,7 +169,10 @@ app.component('accounts-view-modal', AccountsViewModal);
|
||||
app.component('credential-modal', CredentialModal);
|
||||
app.component('action-modal', ActionModal);
|
||||
app.component('dns-modal', DnsModal);
|
||||
app.component('confirm-modal', ConfirmModal);
|
||||
app.component('result-modal', ResultModal);
|
||||
applyDark();
|
||||
initRouter();
|
||||
initKeyboard();
|
||||
loadAll();
|
||||
app.mount('#app');
|
||||
|
||||
+44
-13
@@ -1,7 +1,7 @@
|
||||
/* 资产编辑模态框 */
|
||||
const AssetModal = {
|
||||
template: `
|
||||
<div v-if="store.assetModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div v-if="store.assetModal.show" class="fixed inset-0 bg-black/40 z-[50] flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-lg md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
|
||||
<h3 class="font-semibold text-sm">{{ store.assetModal.editing ? '编辑资产' : '新增资产' }}</h3>
|
||||
@@ -109,7 +109,7 @@ const AssetModal = {
|
||||
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<button type="button" @click="store.assetModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">{{ store.assetModal.editing ? '保存' : '创建' }}</button>
|
||||
<button type="submit" :disabled="store.assetSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.assetSaving ? '保存中…' : (store.assetModal.editing ? '保存' : '创建') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ const AssetModal = {
|
||||
/* 平台编辑模态框 */
|
||||
const ProviderModal = {
|
||||
template: `
|
||||
<div v-if="store.providerModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div v-if="store.providerModal.show" class="fixed inset-0 bg-black/40 z-[50] flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-sm">{{ store.providerModal.editing ? '编辑平台' : '新增平台' }}</h3>
|
||||
@@ -169,7 +169,7 @@ const ProviderModal = {
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" @click="store.providerModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">保存</button>
|
||||
<button type="submit" :disabled="store.providerSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.providerSaving ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -267,7 +267,7 @@ const AccountModal = {
|
||||
/* 账号查看弹窗:平台 → 账号 → 资产 三层结构,展开账号看名下资产 */
|
||||
const AccountsViewModal = {
|
||||
template: `
|
||||
<div v-if="store.accountsModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div v-if="store.accountsModal.show" class="fixed inset-0 bg-black/40 z-[50] flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-lg md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
|
||||
<h3 class="font-semibold text-sm">👤 {{ title }}({{ accountList.length }})</h3>
|
||||
@@ -315,7 +315,7 @@ const AccountsViewModal = {
|
||||
</div>
|
||||
</div>
|
||||
<!-- 密码查看弹窗:点击账号的 🔑 后展示明文,几秒后自动隐藏 -->
|
||||
<div v-if="pwd.show" class="absolute inset-0 bg-black/50 flex items-center justify-center p-4" @click.self="pwd.show=false">
|
||||
<div v-if="pwd.show" class="absolute inset-0 bg-black/50 z-[70] flex items-center justify-center p-4" @click.self="pwd.show=false">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl w-full max-w-xs p-5 shadow-xl">
|
||||
<h4 class="font-semibold text-sm mb-3">🔑 {{ pwd.name }} 的登录凭证</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
@@ -391,8 +391,8 @@ const AccountsViewModal = {
|
||||
async function testAcct(a) {
|
||||
try {
|
||||
const r = await Api.post('/accounts/' + a.id + '/test', {});
|
||||
alert(r.ok ? '✅ ' + r.message : '❌ ' + r.message);
|
||||
} catch (e) { alert('测试失败:' + e.message); }
|
||||
showResult(r.ok ? '测试成功' : '测试失败', r.message, !r.ok);
|
||||
} catch (e) { showResult('测试失败', e.message, true); }
|
||||
}
|
||||
async function syncAcct(a) {
|
||||
try {
|
||||
@@ -402,11 +402,11 @@ const AccountsViewModal = {
|
||||
if (r.domains) lines.push('域名:新增 ' + r.domains.created + ' / 更新 ' + r.domains.updated);
|
||||
if (r.vps_error) lines.push('VPS 错误:' + r.vps_error);
|
||||
if (r.domains_error) lines.push('域名错误:' + r.domains_error);
|
||||
alert(lines.join('\n'));
|
||||
showResult('同步完成', lines.join('\n'));
|
||||
// 同步会新建/更新资产(含到期日),总览的统计卡片也得跟着刷,
|
||||
// 否则回到总览看到的还是同步前的数字
|
||||
await Promise.all([loadAccounts(), loadAssets(), loadOverview()]);
|
||||
} catch (e) { alert('同步失败:' + e.message); }
|
||||
} catch (e) { showResult('同步失败', e.message, true); }
|
||||
}
|
||||
// 每次打开重置展开状态;关闭时连带收掉密码弹窗
|
||||
// 外层弹窗用 v-if 控制,但本组件实例并不卸载,onUnmounted 不会触发:
|
||||
@@ -439,7 +439,7 @@ const AccountsViewModal = {
|
||||
if (pwd.countdown <= 0) closePwd();
|
||||
}, 1000);
|
||||
}
|
||||
} catch (e) { alert('获取密码失败:' + e.message); }
|
||||
} catch (e) { showResult('获取密码失败', e.message, true); }
|
||||
}
|
||||
function closePwd() {
|
||||
pwd.show = false;
|
||||
@@ -602,7 +602,7 @@ const CredentialModal = {
|
||||
* 3. 不支持时如实说明原因,而不是给一个点了才报错的按钮 */
|
||||
const ActionModal = {
|
||||
template: `
|
||||
<div v-if="m.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div v-if="m.show" class="fixed inset-0 bg-black/40 z-[60] flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
|
||||
<h3 class="font-semibold text-sm">⚡ 云资源操作</h3>
|
||||
@@ -687,7 +687,7 @@ const ActionModal = {
|
||||
/* ================= DNS 解析记录管理弹窗 ================= */
|
||||
const DnsModal = {
|
||||
template: `
|
||||
<div v-if="m.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div v-if="m.show" class="fixed inset-0 bg-black/40 z-[60] flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-2xl md:rounded-xl rounded-t-xl max-h-[92vh] flex flex-col">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-sm">🌐 DNS 解析<span v-if="m.zoneId" class="text-slate-400 font-normal"> · {{ m.zoneId }}</span></h3>
|
||||
@@ -797,3 +797,34 @@ const DnsModal = {
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* ================= 全局确认弹窗(替代原生 confirm) ================= */
|
||||
const ConfirmModal = {
|
||||
template: `
|
||||
<div v-if="store.confirmModal.show" class="fixed inset-0 bg-black/40 z-[70] flex items-center justify-center p-4" @click.self="closeConfirm(false)">
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl w-full max-w-sm p-5 shadow-xl">
|
||||
<h3 class="font-semibold text-sm mb-2">{{ store.confirmModal.title }}</h3>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-300 whitespace-pre-wrap">{{ store.confirmModal.message }}</p>
|
||||
<div class="flex gap-2 mt-4 justify-end">
|
||||
<button @click="closeConfirm(false)" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
|
||||
<button @click="closeConfirm(true)" class="px-4 py-2 rounded-lg text-sm text-white" :class="store.confirmModal.danger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() { return { store, closeConfirm }; },
|
||||
};
|
||||
|
||||
/* ================= 全局结果弹窗(替代原生 alert) ================= */
|
||||
const ResultModal = {
|
||||
template: `
|
||||
<div v-if="store.resultModal.show" class="fixed inset-0 bg-black/40 z-[70] flex items-center justify-center p-4" @click.self="closeResult">
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl w-full max-w-md p-5 shadow-xl">
|
||||
<h3 class="font-semibold text-sm mb-2" :class="store.resultModal.isError ? 'text-red-600 dark:text-red-400' : ''">{{ store.resultModal.title }}</h3>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-300 whitespace-pre-wrap max-h-64 overflow-y-auto">{{ store.resultModal.message }}</p>
|
||||
<div class="flex gap-2 mt-4 justify-end">
|
||||
<button @click="closeResult" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() { return { store, closeResult }; },
|
||||
};
|
||||
|
||||
+49
-7
@@ -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 {
|
||||
|
||||
@@ -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