Files
vps-manager/static/js/views/subscriptions.js
T

98 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 订阅视图 */
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 };
},
};