From eeb05372c4667a82672c02a715f59629f085e227 Mon Sep 17 00:00:00 2001 From: gouki Date: Sat, 17 Oct 2026 21:17:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=85=A8=E5=B1=80=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E6=9B=BF=E4=BB=A3=E5=8E=9F=E7=94=9F=20alert/confirm?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E9=98=B2=E9=87=8D=E5=A4=8D=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E4=B8=8E=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ConfirmModal / ResultModal 全局组件,替代原生 alert/confirm/prompt - 资产/平台/账号保存添加防重复提交锁(assetSaving / providerSaving) - 添加 Esc 键盘快捷键关闭模态框 - 平台/监控视图添加搜索过滤功能 - 修复监控中心 SSL TAB 下新增按钮的显示逻辑 - 调整 toast 位置避免遮挡底部导航 - 统一各弹窗 z-index 层级 --- static/js/app.js | 38 ++++++++++++++++++++++-- static/js/modals.js | 57 ++++++++++++++++++++++++++++-------- static/js/store.js | 56 ++++++++++++++++++++++++++++++----- static/js/views/assets.js | 18 ++++++------ static/js/views/domains.js | 12 ++++---- static/js/views/monitor.js | 20 ++++++++----- static/js/views/providers.js | 14 +++++++-- 7 files changed, 167 insertions(+), 48 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index e2fd3d1..45b2157 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -61,9 +61,13 @@ const AppRoot = {

{{ currentNav.label }}

+
+ + +
- +
@@ -89,9 +93,11 @@ const AppRoot = { + + -
{{ toast.text }}
+
{{ toast.text }}
`, 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'); diff --git a/static/js/modals.js b/static/js/modals.js index dd0a9c2..5a36107 100644 --- a/static/js/modals.js +++ b/static/js/modals.js @@ -1,7 +1,7 @@ /* 资产编辑模态框 */ const AssetModal = { template: ` -
+

{{ store.assetModal.editing ? '编辑资产' : '新增资产' }}

@@ -109,7 +109,7 @@ const AssetModal = {
- +
@@ -145,7 +145,7 @@ const AssetModal = { /* 平台编辑模态框 */ const ProviderModal = { template: ` -
+

{{ store.providerModal.editing ? '编辑平台' : '新增平台' }}

@@ -169,7 +169,7 @@ const ProviderModal = {
- +
@@ -267,7 +267,7 @@ const AccountModal = { /* 账号查看弹窗:平台 → 账号 → 资产 三层结构,展开账号看名下资产 */ const AccountsViewModal = { template: ` -
+

👤 {{ title }}({{ accountList.length }})

@@ -315,7 +315,7 @@ const AccountsViewModal = {
-
+

🔑 {{ pwd.name }} 的登录凭证

@@ -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: ` -
+

⚡ 云资源操作

@@ -687,7 +687,7 @@ const ActionModal = { /* ================= DNS 解析记录管理弹窗 ================= */ const DnsModal = { template: ` -
+

🌐 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 }; }, };