feat(ui): 总览/监控中心顶部统计卡片支持点击筛选

- 总览页「总资产」「本年支出」「异常状态」卡片点击跳转资产页并带上对应筛选
  (全部/付费资产TAB/异常状态组合筛选);「30天内到期」及「另有N项已过期」
  子文案点击跳转监控中心并带上到期区间子筛选
- 监控中心顶部统计卡片(30天/7天/已过期/有到期日)支持点击筛选下方列表,
  再点一次取消;SSL 证书卡片(正常/即将到期/已过期或失败)同理
- 资产页状态筛选新增「异常(已停止/已过期)」组合选项:后端 status 只认单值,
  该组合作为前端本地筛选哨兵值处理,不发给 API;切到专有视图 TAB 时自动清空
- 顺带修复监控中心统计卡片数字不随类别 TAB 变化的问题(原先 stats 未按 TAB
  过滤,与列表数据口径不一致)
This commit is contained in:
gouki
2026-09-20 12:53:48 +00:00
parent cbb32ea3b0
commit 7045843db8
4 changed files with 114 additions and 47 deletions
+13 -5
View File
@@ -27,6 +27,7 @@ const AssetsView = {
<div class="flex flex-wrap gap-2 items-center">
<select v-model="store.filterStatus" @change="reload" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<option value="">全部状态</option>
<option value="abnormal">异常(已停止/已过期)</option>
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select>
<button v-if="store.assetTab===''" @click="syncAllAI" class="text-sm px-3 py-1.5 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20">同步AI余额</button>
@@ -114,15 +115,22 @@ const AssetsView = {
if (t === 'ai_agent') return AIAccountsView;
return null;
});
// 通用列表:全部显示所有,VPS 前端过滤类型
// 通用列表:全部显示所有,VPS 前端过滤类型;「异常」状态是后端不支持的组合筛选,也走前端过滤
const list = Vue.computed(() => {
if (store.assetTab === 'vps') return store.assets.filter(a => a.asset_type === 'vps');
return store.assets;
let arr = store.assets;
if (store.assetTab === 'vps') arr = arr.filter(a => a.asset_type === 'vps');
if (store.filterStatus === 'abnormal') arr = arr.filter(a => a.status === 'stopped' || a.status === 'expired');
return arr;
});
function switchTab(k) {
store.assetTab = k;
// 专有视图需要全量数据;若此前按类型过滤过则清空重载
if (k !== '' && k !== 'vps' && store.filterType) { store.filterType = ''; loadAssets(); }
// 专有视图(域名/Cloudflare/订阅/AI)有自己的筛选逻辑,通用的类型/状态筛选切过去后会失效,
// 直接清空避免用户以为筛过了但实际看不出来
if (k !== '' && k !== 'vps' && (store.filterType || store.filterStatus)) {
store.filterType = '';
store.filterStatus = '';
loadAssets();
}
}
// 平台筛选标签显示名:filterProvider 存 slug,回退显示原值
function providerName(slug) {
+19 -11
View File
@@ -3,23 +3,25 @@ const DashboardView = {
template: `
<div class="space-y-5">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800">
<button type="button" @click="goTotal" title="点击查看全部资产" class="text-left bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800 hover:border-blue-400 dark:hover:border-blue-600 transition">
<div class="text-xs text-slate-500">总资产</div>
<div class="text-2xl font-bold mt-1">{{ store.overview.total || 0 }}</div>
</button>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-red-200 dark:border-red-900/50 hover:border-red-400 dark:hover:border-red-700 transition">
<button type="button" @click="goExpiring30" title="点击到监控中心查看 30 天内到期的资产" class="w-full text-left group">
<div class="text-xs text-slate-500">30天内到期</div>
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400 group-hover:underline">{{ store.overview.expiring_30 || 0 }}</div>
</button>
<div v-if="store.overview.overdue_count" class="text-xs mt-1 font-medium text-red-600 dark:text-red-400">另有 <button type="button" @click="goOverdue" title="点击到监控中心查看已过期资产" class="underline hover:no-underline">{{ store.overview.overdue_count }}</button> 项已过期</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-red-200 dark:border-red-900/50">
<div class="text-xs text-slate-500">30天内到期</div>
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ store.overview.expiring_30 || 0 }}</div>
<div v-if="store.overview.overdue_count" class="text-xs mt-1 font-medium text-red-600 dark:text-red-400">另有 {{ store.overview.overdue_count }} 项已过期</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800">
<button type="button" @click="goYearCost" title="点击查看付费资产汇总" class="text-left bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800 hover:border-blue-400 dark:hover:border-blue-600 transition">
<div class="text-xs text-slate-500">本年支出</div>
<div class="text-2xl font-bold mt-1">{{ store.overview.year_cost || 0 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-amber-200 dark:border-amber-900/50">
</button>
<button type="button" @click="goAbnormal" title="点击查看已停止/已过期的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl p-4 border border-amber-200 dark:border-amber-900/50 hover:border-amber-400 dark:hover:border-amber-700 transition">
<div class="text-xs text-slate-500">异常状态</div>
<div class="text-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ store.overview.abnormal_count || 0 }}</div>
</div>
</button>
</div>
<!-- 到期日历:不限制在 30 天窗口内,每个有到期日的产品都列出“几号到期 / 还剩多久” -->
@@ -64,6 +66,12 @@ const DashboardView = {
if (a.cost) bits.push(a.cost + (a.currency || '') + '/' + (Fmt.CYCLE_LABELS[a.renewal_cycle] || '未设周期'));
return bits.filter(Boolean).join(' · ');
}
return { store, Fmt, expiryList, noExpiryCount, meta };
// 卡片点击:带上对应筛选条件跳到目标视图,数字点一下就能看到背后的具体清单
function goTotal() { openAssetsWithFilter({}); }
function goExpiring30() { openMonitorWithExpiryFilter('soon30'); }
function goOverdue() { openMonitorWithExpiryFilter('expired'); }
function goYearCost() { openAssetsWithFilter({ assetTab: 'subscription' }); }
function goAbnormal() { openAssetsWithFilter({ filterStatus: 'abnormal' }); }
return { store, Fmt, expiryList, noExpiryCount, meta, goTotal, goExpiring30, goOverdue, goYearCost, goAbnormal };
},
};
+55 -30
View File
@@ -10,44 +10,44 @@ const MonitorView = {
<span class="text-xs px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">绿=90天内</span>
</div>
<!-- 顶部统计(资产) -->
<!-- 顶部统计(资产):点卡片按到期区间筛选下方列表,再点一次取消 -->
<div v-if="store.monitorTab!=='ssl'" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<button type="button" @click="toggleExpiryFilter('soon30')" title="点击只看 30 天内到期的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorExpiryFilter==='soon30' ? 'border-red-500 ring-1 ring-red-500' : 'border-slate-200 dark:border-slate-800 hover:border-red-300 dark:hover:border-red-800'">
<div class="text-xs text-slate-500">30 天内到期</div>
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ stats.soon30 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
</button>
<button type="button" @click="toggleExpiryFilter('soon7')" title="点击只看 7 天内到期的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorExpiryFilter==='soon7' ? 'border-amber-500 ring-1 ring-amber-500' : 'border-slate-200 dark:border-slate-800 hover:border-amber-300 dark:hover:border-amber-800'">
<div class="text-xs text-slate-500">7 天内到期</div>
<div class="text-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ stats.soon7 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
</button>
<button type="button" @click="toggleExpiryFilter('expired')" title="点击只看已过期的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorExpiryFilter==='expired' ? 'border-slate-500 ring-1 ring-slate-500' : 'border-slate-200 dark:border-slate-800 hover:border-slate-400 dark:hover:border-slate-600'">
<div class="text-xs text-slate-500">已过期</div>
<div class="text-2xl font-bold mt-1 text-slate-700 dark:text-slate-200">{{ stats.expired }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
</button>
<button type="button" @click="clearExpiryFilter" title="点击显示当前类别下所有有到期日的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorExpiryFilter==='' ? 'border-blue-500 ring-1 ring-blue-500' : 'border-slate-200 dark:border-slate-800 hover:border-blue-300 dark:hover:border-blue-800'">
<div class="text-xs text-slate-500">有到期日的资产</div>
<div class="text-2xl font-bold mt-1">{{ total }}</div>
</div>
</button>
</div>
<!-- 顶部统计(SSL 证书) -->
<!-- 顶部统计(SSL 证书):点卡片按证书状态筛选下方列表 -->
<div v-if="store.monitorTab==='ssl'" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<button type="button" @click="clearSslStatusFilter" title="点击显示全部监控站点" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorSslStatusFilter==='' ? 'border-blue-500 ring-1 ring-blue-500' : 'border-slate-200 dark:border-slate-800 hover:border-blue-300 dark:hover:border-blue-800'">
<div class="text-xs text-slate-500">监控站点</div>
<div class="text-2xl font-bold mt-1">{{ sslStats.total }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
</button>
<button type="button" @click="toggleSslStatusFilter('valid')" title="点击只看证书正常的站点" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorSslStatusFilter==='valid' ? 'border-emerald-500 ring-1 ring-emerald-500' : 'border-slate-200 dark:border-slate-800 hover:border-emerald-300 dark:hover:border-emerald-800'">
<div class="text-xs text-slate-500">证书正常</div>
<div class="text-2xl font-bold mt-1 text-emerald-600 dark:text-emerald-400">{{ sslStats.valid }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
</button>
<button type="button" @click="toggleSslStatusFilter('expiring')" title="点击只看即将到期的证书" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorSslStatusFilter==='expiring' ? 'border-amber-500 ring-1 ring-amber-500' : 'border-slate-200 dark:border-slate-800 hover:border-amber-300 dark:hover:border-amber-800'">
<div class="text-xs text-slate-500">即将到期</div>
<div class="text-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ sslStats.expiring }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
</button>
<button type="button" @click="toggleSslStatusFilter('expired_error')" title="点击只看已过期或探测失败的证书" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorSslStatusFilter==='expired_error' ? 'border-red-500 ring-1 ring-red-500' : 'border-slate-200 dark:border-slate-800 hover:border-red-300 dark:hover:border-red-800'">
<div class="text-xs text-slate-500">已过期 / 失败</div>
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ sslStats.expired + sslStats.error }}</div>
</div>
</button>
</div>
<!-- 类别 TAB -->
@@ -135,7 +135,7 @@ const MonitorView = {
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-if="!sslList.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无证书监控(在「资产 → 域名」的子域名面板点「探测SSL」,或稍后支持直接添加)</td></tr>
<tr v-if="!sslList.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">{{ sslEmptyText }}</td></tr>
<tr v-for="c in sslList" :key="c.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
<td class="px-4 py-2.5 font-mono font-medium">{{ c.hostname }}<span v-if="c.port!==443" class="text-xs text-slate-400">:{{ c.port }}</span></td>
<td class="px-4 py-2.5 text-slate-500">{{ c.asset_name || '—' }}</td>
@@ -156,7 +156,7 @@ const MonitorView = {
</div>
<!-- SSL 移动卡片 -->
<div class="md:hidden space-y-2">
<div v-if="!sslList.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">暂无证书监控</div>
<div v-if="!sslList.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">{{ sslEmptyText }}</div>
<div v-for="c in sslList" :key="c.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3">
<div class="flex justify-between items-start">
<div class="min-w-0">
@@ -188,18 +188,32 @@ const MonitorView = {
{ key: 'ssl', label: 'SSL证书', tip: '站点 HTTPS 证书到期倒计时(独立于资产)' },
];
const hasExpiry = a => a.expiry_date && a.status !== 'cancelled';
const items = Vue.computed(() => {
// 当前 TAB 下、有到期日的资产(未叠加到期区间子筛选),作为统计卡片与列表的共同基础数据集
const tabScopedAssets = Vue.computed(() => {
if (store.monitorTab === 'ssl') return [];
let list = store.assets.filter(hasExpiry);
const t = store.monitorTab;
if (t === 'subscription') list = list.filter(a => a.cost > 0);
else if (t) list = list.filter(a => a.asset_type === t);
return list.sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999));
return list;
});
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);
return [...list].sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999));
});
// 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');
return [...list].sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999));
});
// SSL 证书:按剩余天数倒计时排序
const sslList = Vue.computed(() =>
[...store.siteCerts].sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999))
);
const sslStats = Vue.computed(() => {
const s = { total: store.siteCerts.length, valid: 0, expiring: 0, expired: 0, error: 0 };
for (const c of store.siteCerts) if (s[c.status] !== undefined) s[c.status]++;
@@ -214,10 +228,10 @@ const MonitorView = {
try { await Api.del('/site-certs/' + c.id); await loadSiteCerts(); }
catch (e) { alert('删除失败:' + e.message); }
}
const total = Vue.computed(() => items.value.length);
const total = Vue.computed(() => tabScopedAssets.value.length);
const stats = Vue.computed(() => {
const s = { soon30: 0, soon7: 0, expired: 0 };
for (const a of store.assets.filter(hasExpiry)) {
for (const a of tabScopedAssets.value) {
const d = a.days_to_expiry;
if (d === null || d === undefined) continue;
if (d < 0) s.expired++;
@@ -226,8 +240,19 @@ const MonitorView = {
}
return s;
});
const emptyText = Vue.computed(() => store.monitorTab ? '该类别暂无到期资产' : '暂无到期资产(资产设置到期日即可出现在这里)');
const emptyText = Vue.computed(() => {
if (store.monitorExpiryFilter) return '当前到期区间筛选下暂无资产,点上方已选中的卡片可取消筛选';
return store.monitorTab ? '该类别暂无到期资产' : '暂无到期资产(资产设置到期日即可出现在这里)';
});
const sslEmptyText = Vue.computed(() => {
if (store.monitorSslStatusFilter) return '当前状态筛选下暂无证书,点上方已选中的卡片可取消筛选';
return '暂无证书监控(在「资产 → 域名」的子域名面板点「探测SSL」,或稍后支持直接添加)';
});
function switchTab(k) { store.monitorTab = k; }
function toggleExpiryFilter(k) { store.monitorExpiryFilter = store.monitorExpiryFilter === k ? '' : k; }
function clearExpiryFilter() { store.monitorExpiryFilter = ''; }
function toggleSslStatusFilter(k) { store.monitorSslStatusFilter = store.monitorSslStatusFilter === k ? '' : k; }
function clearSslStatusFilter() { store.monitorSslStatusFilter = ''; }
function countdownText(d) {
if (d === null || d === undefined) return '—';
if (d < 0) return '已过期 ' + Math.abs(d) + ' 天';
@@ -241,6 +266,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, switchTab, 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 };
},
};