评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补 list_domains 与 get_account 余额 - NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到 到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由 - base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商 把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、 腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本 连同 MDN 链接弹给用户 动作层与 DNS - 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计 - 新增 Cloudflare DNS 记录的增删改查 定时同步 - scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30) - 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日 - sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期 会显示「成功 1」,用户以为数据拉到了、实际一条没有 数据一致性 - AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染, 缺了则所有按钮永远出不来 - import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步 把所有云端资产当新增再建一份 - _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未 实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量 补标签不覆盖,用户自建平台不动 前端如实展示 - cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回 价格,而汇总处又把 0 当「无费用」排除,两处口径不一致 - 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来 - DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底, 否则 Cloudflare 代理勾选框永远不出现 测试:257 -> 309 passed
100 lines
5.7 KiB
JavaScript
100 lines
5.7 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" :class="a.cost ? 'text-slate-600 dark:text-slate-300' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}<span v-if="a.cost && 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">({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }})</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() {
|
||
// 用 allAssets(全量口径):本页有自己的口径(付费/有到期日),
|
||
// 不该被资产页的类型/状态/搜索筛选影响,否则月度支出统计会跟着缩水
|
||
const subs = Vue.computed(() => store.allAssets
|
||
.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 };
|
||
},
|
||
};
|