feat(ui): 全局弹窗替代原生 alert/confirm,添加防重复提交与搜索功能

- 新增 ConfirmModal / ResultModal 全局组件,替代原生 alert/confirm/prompt
- 资产/平台/账号保存添加防重复提交锁(assetSaving / providerSaving)
- 添加 Esc 键盘快捷键关闭模态框
- 平台/监控视图添加搜索过滤功能
- 修复监控中心 SSL TAB 下新增按钮的显示逻辑
- 调整 toast 位置避免遮挡底部导航
- 统一各弹窗 z-index 层级
This commit is contained in:
gouki
2026-10-17 21:17:18 +00:00
parent a63fcb786f
commit eeb05372c4
7 changed files with 167 additions and 48 deletions
+35 -3
View File
@@ -61,9 +61,13 @@ const AppRoot = {
<div class="flex-1 flex flex-col min-w-0"> <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"> <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> <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"> <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"> <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> </div>
</header> </header>
@@ -89,9 +93,11 @@ const AppRoot = {
<credential-modal></credential-modal> <credential-modal></credential-modal>
<action-modal></action-modal> <action-modal></action-modal>
<dns-modal></dns-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>`, </div>`,
setup() { setup() {
const appName = Api.CFG.appName; const appName = Api.CFG.appName;
@@ -104,7 +110,12 @@ const AppRoot = {
function quickAdd() { function quickAdd() {
if (store.view === 'providers') return openProviderCreate(); if (store.view === 'providers') return openProviderCreate();
if (store.view === 'vault') return openCredentialCreate(); 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'; let preset = 'vps';
if (store.view === 'assets') { if (store.view === 'assets') {
const t = store.assetTab; 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); const app = Vue.createApp(AppRoot);
app.component('dashboard-view', DashboardView); app.component('dashboard-view', DashboardView);
@@ -140,7 +169,10 @@ app.component('accounts-view-modal', AccountsViewModal);
app.component('credential-modal', CredentialModal); app.component('credential-modal', CredentialModal);
app.component('action-modal', ActionModal); app.component('action-modal', ActionModal);
app.component('dns-modal', DnsModal); app.component('dns-modal', DnsModal);
app.component('confirm-modal', ConfirmModal);
app.component('result-modal', ResultModal);
applyDark(); applyDark();
initRouter(); initRouter();
initKeyboard();
loadAll(); loadAll();
app.mount('#app'); app.mount('#app');
+44 -13
View File
@@ -1,7 +1,7 @@
/* 资产编辑模态框 */ /* 资产编辑模态框 */
const AssetModal = { const AssetModal = {
template: ` 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="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"> <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> <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"> <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="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> </div>
</form> </form>
</div> </div>
@@ -145,7 +145,7 @@ const AssetModal = {
/* 平台编辑模态框 */ /* 平台编辑模态框 */
const ProviderModal = { const ProviderModal = {
template: ` 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="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"> <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> <h3 class="font-semibold text-sm">{{ store.providerModal.editing ? '编辑平台' : '新增平台' }}</h3>
@@ -169,7 +169,7 @@ const ProviderModal = {
</div> </div>
<div class="flex justify-end gap-2"> <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="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> </div>
</form> </form>
</div> </div>
@@ -267,7 +267,7 @@ const AccountModal = {
/* 账号查看弹窗:平台 → 账号 → 资产 三层结构,展开账号看名下资产 */ /* 账号查看弹窗:平台 → 账号 → 资产 三层结构,展开账号看名下资产 */
const AccountsViewModal = { const AccountsViewModal = {
template: ` 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="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"> <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> <h3 class="font-semibold text-sm">👤 {{ title }}({{ accountList.length }})</h3>
@@ -315,7 +315,7 @@ const AccountsViewModal = {
</div> </div>
</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"> <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> <h4 class="font-semibold text-sm mb-3">🔑 {{ pwd.name }} 的登录凭证</h4>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
@@ -391,8 +391,8 @@ const AccountsViewModal = {
async function testAcct(a) { async function testAcct(a) {
try { try {
const r = await Api.post('/accounts/' + a.id + '/test', {}); const r = await Api.post('/accounts/' + a.id + '/test', {});
alert(r.ok ? '✅ ' + r.message : '❌ ' + r.message); showResult(r.ok ? '测试成功' : '测试失败', r.message, !r.ok);
} catch (e) { alert('测试失败:' + e.message); } } catch (e) { showResult('测试失败', e.message, true); }
} }
async function syncAcct(a) { async function syncAcct(a) {
try { try {
@@ -402,11 +402,11 @@ const AccountsViewModal = {
if (r.domains) lines.push('域名:新增 ' + r.domains.created + ' / 更新 ' + r.domains.updated); if (r.domains) lines.push('域名:新增 ' + r.domains.created + ' / 更新 ' + r.domains.updated);
if (r.vps_error) lines.push('VPS 错误:' + r.vps_error); if (r.vps_error) lines.push('VPS 错误:' + r.vps_error);
if (r.domains_error) lines.push('域名错误:' + r.domains_error); if (r.domains_error) lines.push('域名错误:' + r.domains_error);
alert(lines.join('\n')); showResult('同步完成', lines.join('\n'));
// 同步会新建/更新资产(含到期日),总览的统计卡片也得跟着刷, // 同步会新建/更新资产(含到期日),总览的统计卡片也得跟着刷,
// 否则回到总览看到的还是同步前的数字 // 否则回到总览看到的还是同步前的数字
await Promise.all([loadAccounts(), loadAssets(), loadOverview()]); await Promise.all([loadAccounts(), loadAssets(), loadOverview()]);
} catch (e) { alert('同步失败:' + e.message); } } catch (e) { showResult('同步失败', e.message, true); }
} }
// 每次打开重置展开状态;关闭时连带收掉密码弹窗 // 每次打开重置展开状态;关闭时连带收掉密码弹窗
// 外层弹窗用 v-if 控制,但本组件实例并不卸载,onUnmounted 不会触发: // 外层弹窗用 v-if 控制,但本组件实例并不卸载,onUnmounted 不会触发:
@@ -439,7 +439,7 @@ const AccountsViewModal = {
if (pwd.countdown <= 0) closePwd(); if (pwd.countdown <= 0) closePwd();
}, 1000); }, 1000);
} }
} catch (e) { alert('获取密码失败:' + e.message); } } catch (e) { showResult('获取密码失败', e.message, true); }
} }
function closePwd() { function closePwd() {
pwd.show = false; pwd.show = false;
@@ -602,7 +602,7 @@ const CredentialModal = {
* 3. 不支持时如实说明原因,而不是给一个点了才报错的按钮 */ * 3. 不支持时如实说明原因,而不是给一个点了才报错的按钮 */
const ActionModal = { const ActionModal = {
template: ` 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="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"> <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> <h3 class="font-semibold text-sm">⚡ 云资源操作</h3>
@@ -687,7 +687,7 @@ const ActionModal = {
/* ================= DNS 解析记录管理弹窗 ================= */ /* ================= DNS 解析记录管理弹窗 ================= */
const DnsModal = { const DnsModal = {
template: ` 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="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"> <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> <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
View File
@@ -48,8 +48,14 @@ const store = reactive({
dark: localStorage.getItem('vps_dark') === '1', dark: localStorage.getItem('vps_dark') === '1',
loading: false, loading: false,
error: '', 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 }, assetModal: { show: false, editing: null, form: null },
assetSaving: false, // 资产保存中锁,防止双击/重复提交
providerModal: { show: false, editing: null, form: null }, providerModal: { show: false, editing: null, form: null },
providerSaving: false, // 平台保存中锁
accountModal: { show: false, editing: null, form: null }, accountModal: { show: false, editing: null, form: null },
accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名 accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名
accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部 accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部
@@ -145,6 +151,16 @@ async function loadCredentials() {
if (store.vaultSearch) params.set('q', store.vaultSearch); if (store.vaultSearch) params.set('q', store.vaultSearch);
if (store.vaultOtpOnly) params.set('has_otp', 'true'); if (store.vaultOtpOnly) params.set('has_otp', 'true');
store.credentials = await Api.get('/credentials?' + params.toString()); 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(); syncOauthCustom();
} }
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); } async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
@@ -184,7 +200,9 @@ function openAssetEdit(a) {
store.assetModal = { show: true, editing: a.id, form }; store.assetModal = { show: true, editing: a.id, form };
} }
async function saveAsset() { async function saveAsset() {
if (store.assetSaving) return;
store.error = ''; store.error = '';
store.assetSaving = true;
const f = store.assetModal.form; const f = store.assetModal.form;
try { try {
const payload = buildAssetPayload(f); const payload = buildAssetPayload(f);
@@ -193,9 +211,10 @@ async function saveAsset() {
store.assetModal.show = false; store.assetModal.show = false;
await loadAll(); await loadAll();
} catch (e) { store.error = e.message; } } catch (e) { store.error = e.message; }
finally { store.assetSaving = false; }
} }
async function deleteAsset(a) { async function deleteAsset(a) {
if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return; if (!await showConfirm('删除资产', '确认删除资产「' + a.name + '」?此操作不可恢复。', true)) return;
store.error = ''; store.error = '';
try { await Api.del('/assets/' + a.id); await loadAll(); } try { await Api.del('/assets/' + a.id); await loadAll(); }
catch (e) { store.error = e.message; } 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 || '' } }; 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() { async function saveProvider() {
if (store.providerSaving) return;
store.error = ''; store.error = '';
store.providerSaving = true;
const f = store.providerModal.form; const f = store.providerModal.form;
// 凭证已下沉到账号层,平台不再携带 api_config // 凭证已下沉到账号层,平台不再携带 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 }; 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; store.providerModal.show = false;
await loadProviders(); await loadProviders();
} catch (e) { store.error = e.message; } } catch (e) { store.error = e.message; }
finally { store.providerSaving = false; }
} }
async function seedProviders() { async function seedProviders() {
store.error = ''; 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; } catch (e) { store.error = e.message; }
} }
async function deleteProvider(p) { 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(); } try { await Api.del('/providers/' + p.id); await loadProviders(); }
catch (e) { store.error = e.message; } catch (e) { store.error = e.message; }
} }
@@ -266,7 +288,7 @@ async function saveAccount() {
async function deleteAccount(a) { async function deleteAccount(a) {
let msg = '确认删除账号「' + a.name + '」?'; let msg = '确认删除账号「' + a.name + '」?';
if (a.asset_count) msg += '\n有 ' + a.asset_count + ' 个资产引用该账号(资产中已填的账号名不受影响)。'; if (a.asset_count) msg += '\n有 ' + a.asset_count + ' 个资产引用该账号(资产中已填的账号名不受影响)。';
if (!confirm(msg)) return; if (!await showConfirm('删除账号', msg, true)) return;
store.error = ''; store.error = '';
try { await Api.del('/accounts/' + a.id); await loadAccounts(); } try { await Api.del('/accounts/' + a.id); await loadAccounts(); }
catch (e) { store.error = e.message; } catch (e) { store.error = e.message; }
@@ -367,7 +389,7 @@ async function saveCredential() {
} }
async function deleteCredential(c) { async function deleteCredential(c) {
const label = c.site + (c.username ? ' · ' + c.username : ''); const label = c.site + (c.username ? ' · ' + c.username : '');
if (!confirm('确认删除凭据「' + label + '」?此操作不可恢复。')) return; if (!await showConfirm('删除凭据', '确认删除凭据「' + label + '」?此操作不可恢复。', true)) return;
store.error = ''; store.error = '';
try { try {
await Api.del('/credentials/' + c.id); await Api.del('/credentials/' + c.id);
@@ -375,7 +397,7 @@ async function deleteCredential(c) {
} catch (e) { store.error = e.message; } } catch (e) { store.error = e.message; }
} }
async function unbindCredentialOtp(c) { async function unbindCredentialOtp(c) {
if (!confirm('确认解绑「' + c.site + '」的 2FA?解绑后需重新录入 secret。')) return; if (!await showConfirm('解绑 2FA', '确认解绑「' + c.site + '」的 2FA?解绑后需重新录入 secret。', true)) return;
store.error = ''; store.error = '';
try { await Api.del('/credentials/' + c.id + '/otp'); await loadCredentials(); } try { await Api.del('/credentials/' + c.id + '/otp'); await loadCredentials(); }
catch (e) { store.error = e.message; } catch (e) { store.error = e.message; }
@@ -401,6 +423,26 @@ function showToast(text) {
if (toast._timer) clearTimeout(toast._timer); if (toast._timer) clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.show = false; }, 1600); 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) { function copyText(text, label) {
const done = () => showToast('✓ ' + (label || '内容') + '已复制'); const done = () => showToast('✓ ' + (label || '内容') + '已复制');
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -689,7 +731,7 @@ async function saveDnsRecord() {
} }
async function deleteDnsRecord(r) { 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; if (dnsModal.busy) return;
dnsModal.busy = true; dnsModal.error = ''; dnsModal.busy = true; dnsModal.error = '';
try { try {
+9 -9
View File
@@ -141,26 +141,26 @@ const AssetsView = {
return p ? p.name : slug; return p ? p.name : slug;
} }
async function refreshBalance(a) { async function refreshBalance(a) {
if (!confirm('刷新「' + a.name + '」的余额?')) return; if (!await showConfirm('刷新余额', '刷新「' + a.name + '」的余额?')) return;
try { try {
const r = await Api.post('/assets/' + a.id + '/refresh-balance', {}); const r = await Api.post('/assets/' + a.id + '/refresh-balance', {});
alert('余额已更新:' + r.balance + ' ' + r.currency); showResult('刷新成功', '余额已更新:' + r.balance + ' ' + r.currency);
await loadAssets(); await loadAssets();
} catch (e) { alert('刷新失败:' + e.message); } } catch (e) { showResult('刷新失败', e.message, true); }
} }
async function syncAllAI() { async function syncAllAI() {
if (!confirm('同步所有 AI 账号的余额?')) return; if (!await showConfirm('同步 AI 余额', '同步所有 AI 账号的余额?')) return;
try { try {
const r = await Api.post('/sync/ai-balances', {}); const r = await Api.post('/sync/ai-balances', {});
// skipped:平台本身没余额接口(如 OpenAI 已下线 billing),不属于失败 // skipped:平台本身没余额接口(如 OpenAI 已下线 billing),不属于失败
alert('同步完成:成功 ' + r.success + ',失败 ' + r.failed + showResult('同步完成', '成功 ' + r.success + ',失败 ' + r.failed +
(r.skipped ? ',跳过 ' + r.skipped + '(平台无余额接口)' : '') + (r.skipped ? ',跳过 ' + r.skipped + '(平台无余额接口)' : '') +
(r.errors && r.errors.length ? '\n' + r.errors.join('\n') : '')); (r.errors && r.errors.length ? '\n' + r.errors.join('\n') : ''));
await loadAssets(); await loadAssets();
} catch (e) { alert('同步失败:' + e.message); } } catch (e) { showResult('同步失败', e.message, true); }
} }
async function syncAllAssets() { async function syncAllAssets() {
if (!confirm('从云端拉取所有已配 API 凭证账号下的 VPS 与域名?\n\n会按 (平台, 云端实例ID) 去重:已存在则更新状态/到期日,不存在则新建。')) return; if (!await showConfirm('同步云端资产', '从云端拉取所有已配 API 凭证账号下的 VPS 与域名?\n\n会按 (平台, 云端实例ID) 去重:已存在则更新状态/到期日,不存在则新建。')) return;
try { try {
const r = await Api.post('/sync/assets', {}); const r = await Api.post('/sync/assets', {});
const lines = [ const lines = [
@@ -172,9 +172,9 @@ const AssetsView = {
if (r.skipped_no_config) lines.push('跳过(账号未配 API 凭证):' + r.skipped_no_config); 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.skipped_no_adapter) lines.push('跳过(平台无可用适配器):' + r.skipped_no_adapter);
if (r.errors && r.errors.length) lines.push('\n' + r.errors.join('\n')); 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()]); 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 }; return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI, syncAllAssets, canControl: assetCanControl, openAct: openActions };
}, },
+6 -6
View File
@@ -131,19 +131,19 @@ const DomainsView = {
} }
async function saveSub(a) { async function saveSub(a) {
const f = forms[a.id]; 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 }; 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 { try {
if (f.editing) await Api.put('/subdomains/' + f.editing, payload); if (f.editing) await Api.put('/subdomains/' + f.editing, payload);
else await Api.post('/subdomains', { ...payload, asset_id: a.id }); else await Api.post('/subdomains', { ...payload, asset_id: a.id });
await loadSubdomains(); await loadSubdomains();
resetForm(a); resetForm(a);
} catch (e) { alert('保存失败:' + e.message); } } catch (e) { showResult('保存失败', e.message, true); }
} }
async function delSub(s) { 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(); } 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) { async function probeSsl(defaultHost, a) {
const target = prompt('探测 SSL 证书的站点(留空取消):', defaultHost); const target = prompt('探测 SSL 证书的站点(留空取消):', defaultHost);
@@ -151,8 +151,8 @@ const DomainsView = {
try { try {
await Api.post('/site-certs', { hostname: target, asset_id: a.id }); await Api.post('/site-certs', { hostname: target, asset_id: a.id });
await loadSiteCerts(); await loadSiteCerts();
alert('已探测:' + target); showResult('探测成功', '已探测:' + target);
} catch (e) { alert('探测失败:' + e.message); } } catch (e) { showResult('探测失败', e.message, true); }
} }
function certText(c) { function certText(c) {
// 统一走 Fmt.countdownText:不再自己维护一份,也避免负数直出「-3天」 // 统一走 Fmt.countdownText:不再自己维护一份,也避免负数直出「-3天」
+13 -7
View File
@@ -50,13 +50,14 @@ const MonitorView = {
</button> </button>
</div> </div>
<!-- 类别 TAB --> <!-- 类别 TAB + 搜索 -->
<div class="flex flex-wrap gap-1.5"> <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" <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="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'"> :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 }} {{ t.label }}
</button> </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> </div>
<!-- 资产表格(非 SSL TAB) --> <!-- 资产表格(非 SSL TAB) -->
@@ -199,21 +200,26 @@ const MonitorView = {
else if (t) list = list.filter(a => a.asset_type === t); else if (t) list = list.filter(a => a.asset_type === t);
return list; return list;
}); });
const search = Vue.ref('');
const items = Vue.computed(() => { const items = Vue.computed(() => {
let list = tabScopedAssets.value; let list = tabScopedAssets.value;
const ef = store.monitorExpiryFilter; const ef = store.monitorExpiryFilter;
if (ef === 'soon30') list = list.filter(a => a.days_to_expiry >= 0 && a.days_to_expiry <= 30); 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 === '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); 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)); return [...list].sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999));
}); });
// SSL 证书:按剩余天数倒计时排序,可叠加状态子筛选 // SSL 证书:按剩余天数倒计时排序,可叠加状态子筛选与搜索
const sslList = Vue.computed(() => { const sslList = Vue.computed(() => {
let list = store.siteCerts; let list = store.siteCerts;
const f = store.monitorSslStatusFilter; const f = store.monitorSslStatusFilter;
if (f === 'valid') list = list.filter(c => c.status === 'valid'); if (f === 'valid') list = list.filter(c => c.status === 'valid');
else if (f === 'expiring') list = list.filter(c => c.status === 'expiring'); 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'); 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)); return [...list].sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999));
}); });
const sslStats = Vue.computed(() => { const sslStats = Vue.computed(() => {
@@ -223,12 +229,12 @@ const MonitorView = {
}); });
async function recheck(c) { async function recheck(c) {
try { await Api.post('/site-certs/' + c.id + '/check', {}); await loadSiteCerts(); } 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) { 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(); } 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 total = Vue.computed(() => tabScopedAssets.value.length);
const stats = Vue.computed(() => { 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'; if (d <= 90) return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
return 'bg-slate-500/10 text-slate-500'; 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 };
}, },
}; };
+11 -3
View File
@@ -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"> <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)配在账号上:点平台卡片「👤 账号」新增账号并填写凭证,即可在账号上「测试/同步」拉取真实数据。 🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。凭证(登录信息/API Key)配在账号上:点平台卡片「👤 账号」新增账号并填写凭证,即可在账号上「测试/同步」拉取真实数据。
</div> </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="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="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> <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; showAll.value = !showAll.value;
localStorage.setItem('vps_show_all_providers', showAll.value ? '1' : '0'); 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); const hiddenCount = Vue.computed(() => store.providers.length - visibleProviders.value.length);
function serviceList(p) { function serviceList(p) {
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean); return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
@@ -111,6 +119,6 @@ const ProvidersView = {
if (!iso) return ''; if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); 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 };
}, },
}; };