feat: 综合平台services多服务支持+SW网络优先缓存修复+适配层/定时任务完善
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
/* AI 账号视图 */
|
||||
const AIAccountsView = {
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center flex-wrap gap-2">
|
||||
<div class="text-sm text-slate-500">共 {{ totalAccounts }} 个账号 · 总余额 {{ totalBalance }}</div>
|
||||
<button @click="addAny" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 新增账号</button>
|
||||
</div>
|
||||
<div v-for="g in groups" :key="g.provider" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="flex justify-between items-center mb-3 flex-wrap gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-sm">{{ g.provider }}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400">{{ g.accounts.length }} 个账号</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span class="text-slate-500">余额合计 <b class="text-slate-800 dark:text-slate-200">{{ g.totalBalance }}</b></span>
|
||||
<button @click="add(g.provider)" class="text-blue-600 dark:text-blue-400 hover:underline">+ 添加账号</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="a in g.accounts" :key="a.id" class="flex items-center justify-between px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 text-sm gap-2">
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium">{{ a.name }}</span>
|
||||
<span v-if="a.ai_detail && a.ai_detail.plan" class="text-xs text-slate-400 ml-2">{{ a.ai_detail.plan }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs shrink-0">
|
||||
<span :class="balanceClass(a.ai_detail && a.ai_detail.balance)">余额 {{ fmtBal(a.ai_detail && a.ai_detail.balance) }}{{ a.ai_detail && a.ai_detail.balance !== null && a.ai_detail.balance !== undefined ? ' ' + a.ai_detail.currency : '' }}</span>
|
||||
<span v-if="a.ai_detail && a.ai_detail.monthly_usage !== null && a.ai_detail.monthly_usage !== undefined" class="text-slate-500">用量 {{ a.ai_detail.monthly_usage }}/{{ a.ai_detail.monthly_limit ?? '∞' }}</span>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!groups.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">暂无 AI 账号,点右上角「新增账号」添加(同一平台可添加多个)</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const aiList = () => store.assets.filter(a => a.asset_type === 'ai_agent');
|
||||
const groups = Vue.computed(() => {
|
||||
const map = {};
|
||||
for (const a of aiList()) {
|
||||
const prov = (a.ai_detail && a.ai_detail.provider) || a.provider || '其他';
|
||||
if (!map[prov]) map[prov] = { provider: prov, accounts: [], totalBalance: 0 };
|
||||
map[prov].accounts.push(a);
|
||||
const bal = a.ai_detail && a.ai_detail.balance;
|
||||
if (bal) map[prov].totalBalance += Number(bal);
|
||||
}
|
||||
return Object.values(map).map(g => ({ ...g, totalBalance: Math.round(g.totalBalance * 100) / 100 })).sort((x, y) => y.totalBalance - x.totalBalance);
|
||||
});
|
||||
const totalAccounts = Vue.computed(() => aiList().length);
|
||||
const totalBalance = Vue.computed(() => Math.round(aiList().reduce((s, a) => s + (a.ai_detail && a.ai_detail.balance ? Number(a.ai_detail.balance) : 0), 0) * 100) / 100);
|
||||
function balanceClass(b) {
|
||||
if (b === null || b === undefined) return 'text-slate-400';
|
||||
if (Number(b) < 10) return 'text-red-600 dark:text-red-400 font-semibold';
|
||||
if (Number(b) < 50) return 'text-amber-600 dark:text-amber-400';
|
||||
return 'text-emerald-600 dark:text-emerald-400';
|
||||
}
|
||||
function fmtBal(b) { return (b === null || b === undefined) ? '—' : b; }
|
||||
return { store, groups, totalAccounts, totalBalance, balanceClass, fmtBal, add: openAICreate, addAny: () => openAICreate(''), edit: openAssetEdit };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
/* 资产视图:TAB 容器(全部/VPS/域名/Cloudflare/订阅/AI账号)
|
||||
* 注意:专有视图(DomainsView 等)在后续脚本才定义,必须在 setup 运行时求值,
|
||||
* 不能在组件定义处直接引用(会因加载顺序抛 ReferenceError)。
|
||||
*/
|
||||
const AssetsView = {
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- 类型 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.assetTab===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>
|
||||
|
||||
<!-- 通用列表(全部 / VPS) -->
|
||||
<template v-if="store.assetTab==='' || store.assetTab==='vps'">
|
||||
<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 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>
|
||||
<span class="ml-auto text-xs text-slate-400">共 {{ list.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<!-- 桌面表格 -->
|
||||
<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="!list.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无资产</td></tr>
|
||||
<tr v-for="a in list" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30" :class="{ 'opacity-50': a.is_archived }">
|
||||
<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" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null && a.days_to_expiry!==undefined" class="text-xs ml-1">({{ 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"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">监控</button>
|
||||
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400 hover:underline mr-3 text-xs">刷新余额</button>
|
||||
<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="!list.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="a in list" :key="a.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3" :class="{ 'opacity-50': a.is_archived }">
|
||||
<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="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs">
|
||||
<span :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '无到期' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined"> ({{ a.days_to_expiry }}天)</span></span>
|
||||
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
|
||||
<span class="px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||||
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400">监控</button>
|
||||
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400">刷新余额</button>
|
||||
<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>
|
||||
|
||||
<!-- 专有视图(域名/Cloudflare/订阅/AI账号) -->
|
||||
<component v-else :is="tabView"></component>
|
||||
</div>`,
|
||||
setup() {
|
||||
// tip 为 TAB 悬浮说明:让用户一眼明白每个分类装的是什么
|
||||
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: 'ChatGPT 等 AI 服务账号与余额' },
|
||||
];
|
||||
const tabView = Vue.computed(() => {
|
||||
const t = store.assetTab;
|
||||
if (t === 'domain') return DomainsView;
|
||||
if (t === 'cloudflare') return CloudflareView;
|
||||
if (t === 'subscription') return SubscriptionsView;
|
||||
if (t === 'ai_agent') return AIAccountsView;
|
||||
return null;
|
||||
});
|
||||
// 通用列表:全部显示所有,VPS 前端过滤类型
|
||||
const list = Vue.computed(() => {
|
||||
if (store.assetTab === 'vps') return store.assets.filter(a => a.asset_type === 'vps');
|
||||
return store.assets;
|
||||
});
|
||||
function switchTab(k) {
|
||||
store.assetTab = k;
|
||||
// 专有视图需要全量数据;若此前按类型过滤过则清空重载
|
||||
if (k !== '' && k !== 'vps' && store.filterType) { store.filterType = ''; loadAssets(); }
|
||||
}
|
||||
async function refreshBalance(a) {
|
||||
if (!confirm('刷新「' + a.name + '」的余额?')) return;
|
||||
try {
|
||||
const r = await Api.post('/assets/' + a.id + '/refresh-balance', {});
|
||||
alert('余额已更新:' + r.balance + ' ' + r.currency);
|
||||
await loadAssets();
|
||||
} catch (e) { alert('刷新失败:' + e.message); }
|
||||
}
|
||||
async function syncAllAI() {
|
||||
if (!confirm('同步所有 AI 账号的余额?')) return;
|
||||
try {
|
||||
const r = await Api.post('/sync/ai-balances', {});
|
||||
alert('同步完成:成功 ' + r.success + ',失败 ' + r.failed + (r.errors && r.errors.length ? '\n' + r.errors.join('\n') : ''));
|
||||
await loadAssets();
|
||||
} catch (e) { alert('同步失败:' + e.message); }
|
||||
}
|
||||
return { store, Fmt, tabs, tabView, list, switchTab, reload: loadAssets, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Cloudflare 子资产视图 */
|
||||
const SUB_TYPE_LABELS = { zone: '解析', worker: 'Worker', r2: 'R2', tunnel: 'Tunnel', mail: 'Mail', dns_record: 'DNS记录', other: '其他' };
|
||||
|
||||
const CloudflareView = {
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div v-if="!groups.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">
|
||||
暂无 Cloudflare 子资产(新增资产时选 Cloudflare 类型即可)
|
||||
</div>
|
||||
<div v-for="g in groups" :key="g.account" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="font-semibold text-sm">☁️ {{ g.account || '未指定账号' }}</div>
|
||||
<span class="text-xs text-slate-400">{{ g.items.length }} 个子资产</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="a in g.items" :key="a.id" class="flex items-center justify-between px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 text-sm">
|
||||
<div class="min-w-0 flex items-center gap-2">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="subTypeBadge(a.cloudflare_detail.sub_type)">{{ SUB_TYPE_LABELS[a.cloudflare_detail.sub_type] || a.cloudflare_detail.sub_type }}</span>
|
||||
<span class="font-medium truncate">{{ a.cloudflare_detail.sub_name || a.cloudflare_detail.zone_name || a.name }}</span>
|
||||
<span v-if="a.cloudflare_detail.zone_name && a.cloudflare_detail.sub_type !== 'zone'" class="text-xs text-slate-400">@{{ a.cloudflare_detail.zone_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs shrink-0">
|
||||
<span v-if="a.cloudflare_detail.status" class="text-slate-500">{{ a.cloudflare_detail.status }}</span>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const groups = Vue.computed(() => {
|
||||
const cfAssets = store.assets.filter(a => a.asset_type === 'cloudflare' && a.cloudflare_detail);
|
||||
const map = {};
|
||||
for (const a of cfAssets) {
|
||||
const acc = a.cloudflare_detail.account_email || '';
|
||||
if (!map[acc]) map[acc] = { account: a.cloudflare_detail.account_email, items: [] };
|
||||
map[acc].items.push(a);
|
||||
}
|
||||
return Object.values(map);
|
||||
});
|
||||
function subTypeBadge(t) {
|
||||
return { zone: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', worker: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', r2: 'bg-purple-500/10 text-purple-600 dark:text-purple-400', tunnel: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', mail: 'bg-pink-500/10 text-pink-600 dark:text-pink-400', dns_record: 'bg-slate-500/10 text-slate-600', other: 'bg-slate-500/10 text-slate-500' }[t] || 'bg-slate-500/10 text-slate-500';
|
||||
}
|
||||
return { store, groups, subTypeBadge, SUB_TYPE_LABELS, edit: openAssetEdit };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/* 总览视图 */
|
||||
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">
|
||||
<div class="text-xs text-slate-500">总资产</div>
|
||||
<div class="text-2xl font-bold mt-1">{{ store.overview.total || 0 }}</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>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4" v-if="store.expiring.length">
|
||||
<h2 class="text-sm font-semibold mb-3">续费倒计时(30天内)</h2>
|
||||
<div class="space-y-2">
|
||||
<div v-for="a in store.expiring" :key="a.id" class="flex justify-between items-center px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium text-sm truncate">{{ a.name }}</span>
|
||||
<span class="text-xs text-slate-400 ml-2">{{ a.provider_name || a.provider }}</span>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.expiryBadge(a.days_to_expiry)">{{ a.days_to_expiry }}天 · {{ a.cost }}{{ a.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h2 class="text-sm font-semibold mb-3">按类型分布</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="(n, t) in store.overview.by_type" :key="t" class="text-xs px-3 py-1.5 rounded-lg" :class="Fmt.typeBadge(t)">{{ Fmt.TYPE_LABELS[t] }} {{ n }}</span>
|
||||
<span v-if="!store.overview.by_type || !Object.keys(store.overview.by_type).length" class="text-xs text-slate-400">暂无数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() { return { store, Fmt }; },
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
/* 域名视图:域名列表 + 子域名管理 + 站点 SSL 状态
|
||||
* 交互:每行可展开「子域名」面板;子域名可添加/编辑/删除、一键探测 SSL 证书;
|
||||
* 域名行显示该域名下最近的证书到期徽章。
|
||||
*/
|
||||
const DomainsView = {
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- 说明条 -->
|
||||
<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">
|
||||
🌐 域名资产下管理子域名与站点 SSL 证书:展开任意域名添加子域名(如 www/api),点「探测SSL」即可监控该站点证书到期情况。
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[820px]">
|
||||
<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">DNS</th>
|
||||
<th class="px-4 py-2.5 font-medium">到期</th>
|
||||
<th class="px-4 py-2.5 font-medium">站点SSL</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="!domains.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无域名资产</td></tr>
|
||||
<template v-for="a in domains" :key="a.id">
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||||
<td class="px-4 py-2.5 font-medium">{{ a.domain_detail.domain_name }}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.domain_detail.registrar || '—' }}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.domain_detail.dns_provider || '—' }}</td>
|
||||
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null" class="text-xs ml-1">({{ a.days_to_expiry }}天)</span></td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="sslByAsset[a.id]" class="text-xs px-2 py-0.5 rounded-full whitespace-nowrap" :class="certBadge(sslByAsset[a.id].days_to_expiry)" :title="sslByAsset[a.id].issuer || ''">
|
||||
{{ sslByAsset[a.id].hostname }} · {{ certText(sslByAsset[a.id]) }}
|
||||
</span>
|
||||
<span v-else class="text-xs text-slate-400">—</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="a.domain_detail.is_using ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-slate-500/10 text-slate-400'">{{ a.domain_detail.is_using ? '使用' : '闲置' }}</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<button @click="toggle(a)" class="text-indigo-600 dark:text-indigo-400 hover:underline mr-3 text-xs">{{ expanded[a.id] ? '收起' : '子域名' }}</button>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline text-xs">编辑/绑定</button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 子域名展开区 -->
|
||||
<tr v-if="expanded[a.id]">
|
||||
<td colspan="7" class="px-4 py-3 bg-slate-50/60 dark:bg-slate-800/20">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-slate-600 dark:text-slate-300">子域名({{ domainName(a) }})</span>
|
||||
<button @click="resetForm(a)" class="text-xs px-2 py-1 rounded-lg bg-blue-600 text-white">+ 新增子域名</button>
|
||||
<button @click="probeSsl(domainName(a), a)" class="text-xs px-2 py-1 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400">🔒 探测根域名SSL</button>
|
||||
<span class="ml-auto text-xs text-slate-400">共 {{ (subsByAsset[a.id]||[]).length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div v-if="(subsByAsset[a.id]||[]).length" class="space-y-1 mb-2">
|
||||
<div v-for="s in subsByAsset[a.id]||[]" :key="s.id" class="flex flex-wrap items-center gap-2 bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 px-3 py-2 text-xs">
|
||||
<span class="font-mono font-medium text-slate-700 dark:text-slate-200">{{ fullHost(s, a) }}</span>
|
||||
<span v-if="s.record_type" class="px-1.5 py-0.5 rounded bg-slate-500/10 text-slate-500">{{ s.record_type }}</span>
|
||||
<span v-if="s.record_value" class="text-slate-400 truncate max-w-[180px]" :title="s.record_value">{{ s.record_value }}</span>
|
||||
<span :class="s.is_active ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ s.is_active ? '启用' : '停用' }}</span>
|
||||
<span v-if="s.note" class="text-slate-400 truncate max-w-[120px]" :title="s.note">{{ s.note }}</span>
|
||||
<span class="ml-auto flex gap-3">
|
||||
<button @click="probeSsl(fullHost(s, a), a)" class="text-violet-600 dark:text-violet-400 hover:underline">🔒 探测SSL</button>
|
||||
<button @click="startEdit(a, s)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
|
||||
<button @click="delSub(s)" class="text-red-600 dark:text-red-400 hover:underline">删除</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-slate-400 mb-2">暂无子域名,点「+ 新增子域名」开始管理</div>
|
||||
|
||||
<!-- 新增/编辑表单 -->
|
||||
<form v-if="forms[a.id]" @submit.prevent="saveSub(a)" class="flex flex-wrap gap-2 items-end bg-white dark:bg-slate-900 rounded-lg border border-blue-200 dark:border-blue-900/50 px-3 py-2">
|
||||
<label class="text-xs text-slate-500">主机名
|
||||
<input v-model="forms[a.id].host" placeholder="www / api(@=根域名)" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-28 mt-0.5">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500">类型
|
||||
<select v-model="forms[a.id].record_type" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 mt-0.5">
|
||||
<option v-for="t in ['A','CNAME','AAAA','MX','TXT']" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-slate-500 flex-1 min-w-[130px]">记录值
|
||||
<input v-model="forms[a.id].record_value" placeholder="IP 或目标域名" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-full mt-0.5">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500">备注
|
||||
<input v-model="forms[a.id].note" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-24 mt-0.5">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500 flex items-center gap-1 pb-1.5"><input type="checkbox" v-model="forms[a.id].is_active" class="accent-blue-600">启用</label>
|
||||
<button type="submit" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white">{{ forms[a.id].editing ? '保存' : '添加' }}</button>
|
||||
<button type="button" @click="resetForm(a)" class="text-sm px-2 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-slate-500">取消</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const domains = Vue.computed(() => store.assets.filter(a => a.asset_type === 'domain' && a.domain_detail));
|
||||
const expanded = Vue.reactive({}); // asset_id -> 是否展开子域名面板
|
||||
const forms = Vue.reactive({}); // asset_id -> 新增/编辑表单
|
||||
|
||||
const subsByAsset = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const s of store.subdomains) { (m[s.asset_id] = m[s.asset_id] || []).push(s); }
|
||||
return m;
|
||||
});
|
||||
// 每个域名资产下最近到期的一条证书(用于主行徽章)
|
||||
const sslByAsset = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const c of store.siteCerts) {
|
||||
if (!c.asset_id) continue;
|
||||
if (!m[c.asset_id] || (c.days_to_expiry ?? 9999) < (m[c.asset_id].days_to_expiry ?? 9999)) m[c.asset_id] = c;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
function domainName(a) { return a.domain_detail.domain_name; }
|
||||
function fullHost(s, a) { return s.host === '@' ? domainName(a) : s.host + '.' + domainName(a); }
|
||||
function toggle(a) {
|
||||
expanded[a.id] = !expanded[a.id];
|
||||
if (expanded[a.id] && !forms[a.id]) resetForm(a);
|
||||
}
|
||||
function resetForm(a) {
|
||||
forms[a.id] = { host: '', record_type: 'A', record_value: '', note: '', is_active: true, editing: null };
|
||||
}
|
||||
function startEdit(a, s) {
|
||||
expanded[a.id] = true;
|
||||
forms[a.id] = { host: s.host, record_type: s.record_type || 'A', record_value: s.record_value || '', note: s.note || '', is_active: !!s.is_active, editing: s.id };
|
||||
}
|
||||
async function saveSub(a) {
|
||||
const f = forms[a.id];
|
||||
if (!f.host.trim()) { alert('请填写主机名'); 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); }
|
||||
}
|
||||
async function delSub(s) {
|
||||
if (!confirm('删除子域名「' + s.host + '」?')) return;
|
||||
try { await Api.del('/subdomains/' + s.id); await loadSubdomains(); }
|
||||
catch (e) { alert('删除失败:' + e.message); }
|
||||
}
|
||||
async function probeSsl(defaultHost, a) {
|
||||
const target = prompt('探测 SSL 证书的站点(留空取消):', defaultHost);
|
||||
if (!target) return;
|
||||
try {
|
||||
await Api.post('/site-certs', { hostname: target, asset_id: a.id });
|
||||
await loadSiteCerts();
|
||||
alert('已探测:' + target);
|
||||
} catch (e) { alert('探测失败:' + e.message); }
|
||||
}
|
||||
function certText(c) {
|
||||
const d = c.days_to_expiry;
|
||||
if (d === null || d === undefined) return '未知';
|
||||
if (d < 0) return '已过期' + Math.abs(d) + '天';
|
||||
if (d === 0) return '今天到期';
|
||||
return d + '天';
|
||||
}
|
||||
function certBadge(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, domains, subsByAsset, sslByAsset, expanded, forms, domainName, fullHost, toggle, resetForm, startEdit, saveSub, delSub, probeSsl, certText, certBadge, edit: openAssetEdit };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
/* 监控中心视图:默认到期倒计时,可按类别筛选 */
|
||||
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 };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
/* 平台管理视图 */
|
||||
const ProvidersView = {
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- 说明条:让用户明白平台是什么 -->
|
||||
<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 对接的平台配置好密钥后,可「测试连接」或「同步资产」拉取真实数据。
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div v-for="p in store.providers" :key="p.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="font-medium text-sm">{{ p.name }}</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">{{ p.slug }}</div>
|
||||
</div>
|
||||
<div v-if="serviceList(p).length" class="flex flex-wrap gap-1 justify-end max-w-[60%]">
|
||||
<span v-for="s in serviceList(p)" :key="s" class="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400">{{ Fmt.SERVICES_LABELS[s] || s }}</span>
|
||||
</div>
|
||||
<span v-else class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(p.category)">{{ Fmt.CATEGORY_LABELS[p.category] }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-2 space-y-1">
|
||||
<div v-if="p.sdk_type">API 对接: {{ p.sdk_type }}</div>
|
||||
<div v-if="p.console_url"><a :href="p.console_url" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">管理面板 ↗</a></div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-slate-500">📎 关联 {{ countOf(p) }} 个资产</span>
|
||||
<span v-if="p.has_api_config" class="text-emerald-600 dark:text-emerald-400">已配置 API</span>
|
||||
<span v-else class="text-slate-400">未配置 API</span>
|
||||
<span :class="p.enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ p.enabled ? '启用' : '停用' }}</span>
|
||||
</div>
|
||||
<div v-if="p.last_synced_at" class="text-slate-400">最后同步:{{ fmtTime(p.last_synced_at) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs flex-wrap">
|
||||
<button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button>
|
||||
<button v-if="isSupported(p.sdk_type)" @click="test(p)" :disabled="testing[p.id]" class="text-emerald-600 dark:text-emerald-400 disabled:opacity-50">{{ testing[p.id] ? '测试中…' : '测试连接' }}</button>
|
||||
<button v-if="canSync(p.sdk_type)" @click="sync(p)" :disabled="syncing[p.id]" class="text-violet-600 dark:text-violet-400 disabled:opacity-50">{{ syncing[p.id] ? '同步中…' : '同步资产' }}</button>
|
||||
<button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
</div>
|
||||
<div v-if="results[p.id]" class="mt-2 text-xs px-2 py-1.5 rounded-lg break-words" :class="results[p.id].ok ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400'">{{ results[p.id].text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const testing = Vue.reactive({});
|
||||
const syncing = Vue.reactive({});
|
||||
const results = Vue.reactive({});
|
||||
const supported = Vue.ref([]);
|
||||
const capabilities = Vue.ref({});
|
||||
// 平台关联资产数:优先按 provider_id 归并,其次按资产里填的平台名/slug
|
||||
const assetCounts = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const a of store.assets) {
|
||||
if (a.provider_id) m['id:' + a.provider_id] = (m['id:' + a.provider_id] || 0) + 1;
|
||||
else if (a.provider) m['p:' + a.provider] = (m['p:' + a.provider] || 0) + 1;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
function countOf(p) {
|
||||
return (assetCounts.value['id:' + p.id] || 0) + (assetCounts.value['p:' + p.slug] || 0) + (assetCounts.value['p:' + p.name] || 0);
|
||||
}
|
||||
function serviceList(p) {
|
||||
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
Vue.onMounted(async () => {
|
||||
try { const r = await Api.get('/providers/adapters'); supported.value = r.supported_types || []; capabilities.value = r.capabilities || {}; } catch (e) { /* ignore */ }
|
||||
});
|
||||
function isSupported(t) { return !!t && supported.value.includes(t); }
|
||||
function canSync(t) { const c = capabilities.value[t]; return !!(c && (c.list_vps || c.list_domains)); }
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
async function test(p) {
|
||||
testing[p.id] = true; delete results[p.id];
|
||||
try {
|
||||
const r = await Api.post('/providers/' + p.id + '/test', {});
|
||||
results[p.id] = { ok: r.ok, text: (r.ok ? '✓ ' : '✗ ') + r.message };
|
||||
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
|
||||
finally { testing[p.id] = false; }
|
||||
}
|
||||
async function sync(p) {
|
||||
syncing[p.id] = true; delete results[p.id];
|
||||
try {
|
||||
const r = await Api.post('/providers/' + p.id + '/sync', {});
|
||||
const parts = [];
|
||||
if (r.vps) parts.push('VPS 新增' + r.vps.created + '/更新' + r.vps.updated);
|
||||
if (r.domains) parts.push('域名 新增' + r.domains.created + '/更新' + r.domains.updated);
|
||||
if (r.vps_error) parts.push('VPS错误:' + r.vps_error);
|
||||
if (r.domains_error) parts.push('域名错误:' + r.domains_error);
|
||||
if (r.account && r.account.balance !== null && r.account.balance !== undefined) parts.push('余额 ' + r.account.balance);
|
||||
results[p.id] = { ok: true, text: '✓ 同步完成:' + (parts.join(',') || '无变化') };
|
||||
await loadAll();
|
||||
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
|
||||
finally { syncing[p.id] = false; }
|
||||
}
|
||||
return { store, Fmt, testing, syncing, results, countOf, serviceList, isSupported, canSync, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
/* 服务器监控视图 */
|
||||
const ServersView = {
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div v-if="!m.asset" 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">
|
||||
请在「资产」页点击 VPS 的「监控」查看服务器资源与安全状态
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="goBack" class="text-sm text-slate-500 hover:text-slate-700">← 返回</button>
|
||||
<h2 class="font-semibold">{{ m.asset.name }}</h2>
|
||||
</div>
|
||||
<div v-if="m.info" class="grid grid-cols-2 lg: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">CPU</div>
|
||||
<div class="text-xl font-bold mt-1">{{ latest ? latest.cpu_pct : '—' }}%</div>
|
||||
<div class="text-xs text-slate-400 mt-1">{{ m.info.cpu_cores }} 核</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-xl font-bold mt-1">{{ latest ? latest.mem_pct : '—' }}%</div>
|
||||
<div class="text-xs text-slate-400 mt-1">{{ m.info.mem_total_gb }} GB</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-xl font-bold mt-1">{{ latest ? latest.disk_pct : '—' }}%</div>
|
||||
<div class="text-xs text-slate-400 mt-1">{{ m.info.disk_total_gb }} GB</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-xl font-bold mt-1">{{ latest && latest.load_1m !== null ? latest.load_1m : '—' }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">{{ m.info.hostname }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="m.info" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4 text-sm">
|
||||
<h3 class="font-semibold mb-2">服务器信息</h3>
|
||||
<div class="grid grid-cols-2 gap-y-1 text-xs text-slate-500">
|
||||
<div>系统:{{ m.info.os }}</div><div>内核:{{ m.info.kernel }}</div>
|
||||
<div>公网 IP:{{ m.info.public_ip || '—' }}</div><div>状态:{{ m.info.status }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="m.metrics && m.metrics.length > 1" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold mb-2 text-sm">资源趋势(最近 {{ m.metrics.length }} 次采样)</h3>
|
||||
<canvas id="metricsChart" height="90"></canvas>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h3 class="font-semibold text-sm">安全检查</h3>
|
||||
<div v-if="m.security && m.security.score !== null && m.security.score !== undefined" class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500">安全评分</span>
|
||||
<span class="text-lg font-bold" :class="scoreClass(m.security.level)">{{ m.security.score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="c in m.checks" :key="c.id" class="flex items-start gap-2 text-sm">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.checkBadge(c.status)">{{ c.status }}</span>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-xs">{{ c.check_item }}</div>
|
||||
<div class="text-xs text-slate-500">{{ c.detail }}</div>
|
||||
<div v-if="c.suggestion" class="text-xs text-amber-600 dark:text-amber-400">建议:{{ c.suggestion }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!m.checks.length" class="text-xs text-slate-400">暂无安全检查数据(需 Agent 上报)</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>`,
|
||||
setup() {
|
||||
const m = Vue.computed(() => store.serverMetrics);
|
||||
const latest = Vue.computed(() => (m.value.metrics && m.value.metrics.length) ? m.value.metrics[0] : null);
|
||||
let chartInstance = null;
|
||||
function renderChart() {
|
||||
const canvas = document.getElementById('metricsChart');
|
||||
if (!canvas || !window.Chart) return;
|
||||
if (!m.value.metrics || m.value.metrics.length < 2) return;
|
||||
if (chartInstance) { chartInstance.destroy(); chartInstance = null; }
|
||||
const data = [...m.value.metrics].reverse();
|
||||
const labels = data.map(p => new Date(p.ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }));
|
||||
chartInstance = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'CPU %', data: data.map(p => p.cpu_pct), borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)', tension: 0.3, pointRadius: 0 },
|
||||
{ label: '内存 %', data: data.map(p => p.mem_pct), borderColor: '#10b981', backgroundColor: 'rgba(16,185,129,0.1)', tension: 0.3, pointRadius: 0 },
|
||||
{ label: '磁盘 %', data: data.map(p => p.disk_pct), borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.1)', tension: 0.3, pointRadius: 0 },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
|
||||
plugins: { legend: { position: 'top' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
Vue.watch(m, () => { Vue.nextTick(renderChart); }, { deep: true });
|
||||
Vue.onMounted(() => { Vue.nextTick(renderChart); });
|
||||
Vue.onUnmounted(() => { if (chartInstance) { chartInstance.destroy(); chartInstance = null; } });
|
||||
function scoreClass(level) {
|
||||
return { good: 'text-emerald-600 dark:text-emerald-400', warning: 'text-amber-600 dark:text-amber-400', risk: 'text-red-600 dark:text-red-400', unknown: 'text-slate-400' }[level] || 'text-slate-400';
|
||||
}
|
||||
function goBack() { window.location.hash = '#assets'; }
|
||||
return { store, Fmt, m, latest, scoreClass, goBack };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/* 设置视图 */
|
||||
const SettingsView = {
|
||||
template: `
|
||||
<div class="max-w-lg space-y-4">
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold text-sm mb-3">API Key(写操作鉴权)</h3>
|
||||
<p class="text-xs text-slate-500 mb-2">若服务端 .env 配置了 API_KEY,请在此填写相同的值,前端写操作会自动携带。</p>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="settings.apiKey" type="password" placeholder="留空表示服务端未启用鉴权" class="flex-1 text-sm px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
|
||||
<button @click="save" class="text-sm px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700">{{ settings.saved ? '已保存' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold text-sm mb-2">通知与续费提醒</h3>
|
||||
<div class="text-xs text-slate-500 mb-3">已配置渠道:
|
||||
<span v-if="notifyChannels.length" class="text-emerald-600 dark:text-emerald-400">{{ notifyChannels.join('、') }}</span>
|
||||
<span v-else class="text-slate-400">未配置(在 .env 设置 TELEGRAM_*/SMTP_*)</span>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button @click="testNotify" 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="checkRenewals" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">检查续费并提醒</button>
|
||||
</div>
|
||||
<div v-if="notifyResult" class="mt-3 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 whitespace-pre-wrap break-words">{{ notifyResult }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold text-sm mb-2">外观</h3>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" v-model="store.dark" @change="applyDark"> 深色模式
|
||||
</label>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold text-sm mb-2">数据管理(迁移/备份)</h3>
|
||||
<p class="text-xs text-slate-500 mb-2">导出所有资产为 JSON,可导入到新部署的实例,实现数据迁移。</p>
|
||||
<div class="flex gap-2 flex-wrap items-center">
|
||||
<button @click="exportData" 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>
|
||||
<label 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 cursor-pointer">导入数据<input type="file" accept=".json" @change="onImportFile" class="hidden"></label>
|
||||
</div>
|
||||
<div v-if="dataResult" class="mt-2 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">{{ dataResult }}</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const notifyChannels = Vue.ref([]);
|
||||
const notifyResult = Vue.ref('');
|
||||
const dataResult = Vue.ref('');
|
||||
Vue.onMounted(async () => {
|
||||
try { const r = await Api.get('/notify/channels'); notifyChannels.value = r.channels || []; } catch (e) { /* ignore */ }
|
||||
});
|
||||
async function testNotify() {
|
||||
notifyResult.value = '发送中…';
|
||||
try {
|
||||
const r = await Api.post('/notify/test', {});
|
||||
notifyResult.value = r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join('\n');
|
||||
} catch (e) { notifyResult.value = '失败:' + e.message; }
|
||||
}
|
||||
async function checkRenewals() {
|
||||
notifyResult.value = '检查中…';
|
||||
try {
|
||||
const r = await Api.post('/notify/renewals?send=true', {});
|
||||
let text = '即将到期 ' + r.expiring_count + ' 项(阈值 ' + r.threshold_days + ' 天)';
|
||||
if (r.expiring.length) text += '\n' + r.expiring.map(i => '• ' + i.name + ' [' + i.provider + '] ' + i.days + ' 天后').join('\n');
|
||||
if (r.notifications && r.notifications.length) text += '\n\n通知:' + r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join(',');
|
||||
notifyResult.value = text;
|
||||
} catch (e) { notifyResult.value = '失败:' + e.message; }
|
||||
}
|
||||
async function exportData() {
|
||||
dataResult.value = '导出中…';
|
||||
try {
|
||||
const r = await Api.get('/assets/export');
|
||||
const blob = new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'vps-manager-export-' + new Date().toISOString().slice(0, 10) + '.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
dataResult.value = '导出成功:' + r.count + ' 个资产';
|
||||
} catch (e) { dataResult.value = '导出失败:' + e.message; }
|
||||
}
|
||||
function onImportFile(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (ev) => {
|
||||
dataResult.value = '导入中…';
|
||||
try {
|
||||
const data = JSON.parse(ev.target.result);
|
||||
const r = await Api.post('/assets/import', data);
|
||||
dataResult.value = '导入完成:新增 ' + r.created + ',更新 ' + r.updated + (r.errors && r.errors.length ? ',错误 ' + r.errors.length : '');
|
||||
await loadAssets();
|
||||
} catch (err) { dataResult.value = '导入失败:' + err.message; }
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals, dataResult, exportData, onImportFile };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/* 订阅视图 */
|
||||
const SubscriptionsView = {
|
||||
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">
|
||||
💸 这里是「付费资产」汇总:自动收集所有设置了费用或到期日的资产(跨 VPS / 域名 / AI 账号),统一看每个月花多少钱、哪些该续费。
|
||||
</div>
|
||||
<div 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">{{ subs.length }}</div>
|
||||
</div>
|
||||
<div v-for="(v, cur) in monthlyByCurrency" :key="cur" 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">月度支出({{ cur }})</div>
|
||||
<div class="text-2xl font-bold mt-1">{{ v.toFixed(2) }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">年度约 {{ (v*12).toFixed(2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="subs.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold mb-2 text-sm">按平台月度支出(换算为月均)</h3>
|
||||
<canvas id="billingChart" height="80"></canvas>
|
||||
</div>
|
||||
<div class="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 text-right">管理</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
<tr v-if="!subs.length"><td colspan="5" class="px-4 py-10 text-center text-slate-400">暂无付费资产(给资产设置费用和到期日即可出现)</td></tr>
|
||||
<tr v-for="a in subs" :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 text-slate-500">{{ a.provider_name || a.provider }}</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" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined" class="text-xs ml-1">({{ a.days_to_expiry }}天)</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>
|
||||
<a v-if="a.cancel_url" :href="a.cancel_url" target="_blank" class="text-red-600 dark:text-red-400 hover:underline text-xs">取消</a>
|
||||
<span v-if="!a.renew_url && !a.cancel_url" class="text-xs text-slate-400">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const subs = Vue.computed(() => store.assets
|
||||
.filter(a => (a.cost > 0 || a.expiry_date) && a.status !== 'cancelled')
|
||||
.sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999)));
|
||||
const monthlyByCurrency = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const a of subs.value) {
|
||||
const cur = a.currency || 'USD';
|
||||
m[cur] = (m[cur] || 0) + normalizedMonthly(a.cost, a.renewal_cycle);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
const byProviderMonthly = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const a of subs.value) {
|
||||
const prov = a.provider_name || a.provider;
|
||||
m[prov] = (m[prov] || 0) + normalizedMonthly(a.cost, a.renewal_cycle);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
let billingChart = null;
|
||||
function renderBillingChart() {
|
||||
const canvas = document.getElementById('billingChart');
|
||||
if (!canvas || !window.Chart) return;
|
||||
const entries = Object.entries(byProviderMonthly.value).sort((x, y) => y[1] - x[1]);
|
||||
if (!entries.length) return;
|
||||
if (billingChart) { billingChart.destroy(); billingChart = null; }
|
||||
billingChart = new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: entries.map(e => e[0]),
|
||||
datasets: [{ label: '月度支出', data: entries.map(e => e[1].toFixed(2)), backgroundColor: '#6366f1', borderRadius: 6 }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: { y: { beginAtZero: true } },
|
||||
plugins: { legend: { display: false } },
|
||||
},
|
||||
});
|
||||
}
|
||||
Vue.watch([subs, byProviderMonthly], () => { Vue.nextTick(renderBillingChart); }, { deep: true });
|
||||
Vue.onMounted(() => { Vue.nextTick(renderBillingChart); });
|
||||
Vue.onUnmounted(() => { if (billingChart) { billingChart.destroy(); billingChart = null; } });
|
||||
return { store, Fmt, subs, monthlyByCurrency, byProviderMonthly };
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user