247 lines
15 KiB
JavaScript
247 lines
15 KiB
JavaScript
/* 监控中心视图:默认到期倒计时,可按类别筛选 */
|
||
const MonitorView = {
|
||
template: `
|
||
<div class="space-y-4">
|
||
<!-- 说明条:让用户明白这个页面是干嘛的 -->
|
||
<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 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||
<span>⏳ 所有设置了到期日的资产按剩余天数倒计时排序,越靠前越紧急</span>
|
||
<span class="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-600 dark:text-red-400">红=7天内</span>
|
||
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400">橙=30天内</span>
|
||
<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">
|
||
<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">
|
||
<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">
|
||
<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">
|
||
<div class="text-xs text-slate-500">有到期日的资产</div>
|
||
<div class="text-2xl font-bold mt-1">{{ total }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 顶部统计(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">
|
||
<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">
|
||
<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">
|
||
<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">
|
||
<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>
|
||
</div>
|
||
|
||
<!-- 类别 TAB -->
|
||
<div class="flex flex-wrap gap-1.5">
|
||
<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>
|
||
</div>
|
||
|
||
<!-- 资产表格(非 SSL TAB) -->
|
||
<template v-if="store.monitorTab!=='ssl'">
|
||
<!-- 桌面表格 -->
|
||
<div class="hidden md:block bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||
<table class="w-full text-sm">
|
||
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
|
||
<tr>
|
||
<th class="px-4 py-2.5 font-medium">名称</th>
|
||
<th class="px-4 py-2.5 font-medium">类型</th>
|
||
<th class="px-4 py-2.5 font-medium">平台</th>
|
||
<th class="px-4 py-2.5 font-medium">到期日</th>
|
||
<th class="px-4 py-2.5 font-medium">倒计时</th>
|
||
<th class="px-4 py-2.5 font-medium">费用</th>
|
||
<th class="px-4 py-2.5 font-medium text-right">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||
<tr v-if="!items.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">{{ emptyText }}</td></tr>
|
||
<tr v-for="a in items" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
|
||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span></td>
|
||
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
|
||
<td class="px-4 py-2.5">{{ a.expiry_date }}</td>
|
||
<td class="px-4 py-2.5"><span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="countdownBadge(a.days_to_expiry)">{{ countdownText(a.days_to_expiry) }}</span></td>
|
||
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>
|
||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
|
||
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 移动卡片 -->
|
||
<div class="md:hidden space-y-2">
|
||
<div v-if="!items.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">{{ emptyText }}</div>
|
||
<div v-for="a in items" :key="a.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">
|
||
<div class="font-medium text-sm truncate">{{ a.name }}</div>
|
||
<div class="text-xs text-slate-400 mt-0.5">{{ a.provider_name || a.provider }}</div>
|
||
</div>
|
||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="countdownBadge(a.days_to_expiry)">{{ countdownText(a.days_to_expiry) }}</span>
|
||
</div>
|
||
<div class="flex items-center gap-3 mt-2 text-xs">
|
||
<span class="text-slate-500">{{ a.expiry_date }}</span>
|
||
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
|
||
<span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
|
||
</div>
|
||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400">续费</a>
|
||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
|
||
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- SSL 证书表格 -->
|
||
<template v-if="store.monitorTab==='ssl'">
|
||
<div class="hidden md:block bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||
<table class="w-full text-sm">
|
||
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
|
||
<tr>
|
||
<th class="px-4 py-2.5 font-medium">站点</th>
|
||
<th class="px-4 py-2.5 font-medium">所属资产</th>
|
||
<th class="px-4 py-2.5 font-medium">签发机构</th>
|
||
<th class="px-4 py-2.5 font-medium">到期日</th>
|
||
<th class="px-4 py-2.5 font-medium">倒计时</th>
|
||
<th class="px-4 py-2.5 font-medium">状态</th>
|
||
<th class="px-4 py-2.5 font-medium text-right">操作</th>
|
||
</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-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>
|
||
<td class="px-4 py-2.5 text-slate-500 max-w-[220px] truncate" :title="c.issuer || ''">{{ c.issuer || '—' }}</td>
|
||
<td class="px-4 py-2.5">{{ c.valid_to || '—' }}</td>
|
||
<td class="px-4 py-2.5"><span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="countdownBadge(c.days_to_expiry)">{{ countdownText(c.days_to_expiry) }}</span></td>
|
||
<td class="px-4 py-2.5">
|
||
<span v-if="c.status==='error'" class="text-xs text-red-600 dark:text-red-400" :title="c.error || ''">探测失败</span>
|
||
<span v-else class="text-xs text-slate-500" :title="c.error || ''">{{ c.last_checked_at ? '已检查 ' + c.last_checked_at.slice(5,16).replace('T',' ') : '未检查' }}</span>
|
||
</td>
|
||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||
<button @click="recheck(c)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">重测</button>
|
||
<button @click="delCert(c)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</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-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">
|
||
<div class="font-mono font-medium text-sm truncate">{{ c.hostname }}</div>
|
||
<div class="text-xs text-slate-400 mt-0.5">{{ c.asset_name || '独立站点' }}</div>
|
||
</div>
|
||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="countdownBadge(c.days_to_expiry)">{{ countdownText(c.days_to_expiry) }}</span>
|
||
</div>
|
||
<div class="flex items-center gap-3 mt-2 text-xs text-slate-500">
|
||
<span>{{ c.valid_to || '—' }}</span>
|
||
<span v-if="c.status==='error'" class="text-red-600 dark:text-red-400" :title="c.error || ''">探测失败</span>
|
||
</div>
|
||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||
<button @click="recheck(c)" class="text-emerald-600 dark:text-emerald-400">重测</button>
|
||
<button @click="delCert(c)" class="text-red-600 dark:text-red-400">删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>`,
|
||
setup() {
|
||
const tabs = [
|
||
{ key: '', label: '全部', tip: '所有有到期日的资产' },
|
||
{ key: 'vps', label: 'VPS', tip: '仅看云服务器' },
|
||
{ key: 'domain', label: '域名', tip: '仅看域名' },
|
||
{ key: 'cloudflare', label: 'Cloudflare', tip: '仅看 Cloudflare 资源' },
|
||
{ key: 'subscription', label: '付费资产', tip: '仅看有费用、需要续费的项目' },
|
||
{ key: 'ai_agent', label: 'AI账号', tip: '仅看 AI 服务账号' },
|
||
{ key: 'ssl', label: 'SSL证书', tip: '站点 HTTPS 证书到期倒计时(独立于资产)' },
|
||
];
|
||
const hasExpiry = a => a.expiry_date && a.status !== 'cancelled';
|
||
const items = 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));
|
||
});
|
||
// 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]++;
|
||
return s;
|
||
});
|
||
async function recheck(c) {
|
||
try { await Api.post('/site-certs/' + c.id + '/check', {}); await loadSiteCerts(); }
|
||
catch (e) { alert('重测失败:' + e.message); }
|
||
}
|
||
async function delCert(c) {
|
||
if (!confirm('删除证书监控「' + c.hostname + '」?')) return;
|
||
try { await Api.del('/site-certs/' + c.id); await loadSiteCerts(); }
|
||
catch (e) { alert('删除失败:' + e.message); }
|
||
}
|
||
const total = Vue.computed(() => items.value.length);
|
||
const stats = Vue.computed(() => {
|
||
const s = { soon30: 0, soon7: 0, expired: 0 };
|
||
for (const a of store.assets.filter(hasExpiry)) {
|
||
const d = a.days_to_expiry;
|
||
if (d === null || d === undefined) continue;
|
||
if (d < 0) s.expired++;
|
||
else if (d <= 7) { s.soon7++; s.soon30++; }
|
||
else if (d <= 30) s.soon30++;
|
||
}
|
||
return s;
|
||
});
|
||
const emptyText = Vue.computed(() => store.monitorTab ? '该类别暂无到期资产' : '暂无到期资产(资产设置到期日即可出现在这里)');
|
||
function switchTab(k) { store.monitorTab = k; }
|
||
function countdownText(d) {
|
||
if (d === null || d === undefined) return '—';
|
||
if (d < 0) return '已过期 ' + Math.abs(d) + ' 天';
|
||
if (d === 0) return '今天到期';
|
||
return d + ' 天';
|
||
}
|
||
function countdownBadge(d) {
|
||
if (d === null || d === undefined) return 'bg-slate-500/10 text-slate-500';
|
||
if (d < 0 || d === 0 || d <= 7) return 'bg-red-500/10 text-red-600 dark:text-red-400';
|
||
if (d <= 30) return 'bg-amber-500/10 text-amber-600 dark:text-amber-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 { store, Fmt, tabs, items, total, stats, sslList, sslStats, emptyText, switchTab, countdownText, countdownBadge, recheck, delCert, edit: openAssetEdit, del: deleteAsset };
|
||
},
|
||
};
|