98 lines
5.4 KiB
JavaScript
98 lines
5.4 KiB
JavaScript
/* 订阅视图 */
|
||
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 };
|
||
},
|
||
};
|