Files
vps-manager/static/js/views/dashboard.js
T
gouki 46faadbcae feat(multicloud): 补齐上线前的多云管理缺口(适配器/动作层/DNS/定时同步)
评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐:

适配层
- 新增 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
2026-10-12 00:43:37 +00:00

81 lines
6.2 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 DashboardView = {
template: `
<div class="space-y-5">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<button type="button" @click="goTotal" title="点击查看全部资产" class="text-left bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800 hover:border-blue-400 dark:hover:border-blue-600 transition">
<div class="text-xs text-slate-500">总资产</div>
<div class="text-2xl font-bold mt-1">{{ store.overview.total || 0 }}</div>
</button>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-red-200 dark:border-red-900/50 hover:border-red-400 dark:hover:border-red-700 transition">
<button type="button" @click="goExpiring30" title="点击到监控中心查看 30 天内到期的资产" class="w-full text-left group">
<div class="text-xs text-slate-500">30天内到期</div>
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400 group-hover:underline">{{ store.overview.expiring_30 || 0 }}</div>
</button>
<div v-if="store.overview.overdue_count" class="text-xs mt-1 font-medium text-red-600 dark:text-red-400">另有 <button type="button" @click="goOverdue" title="点击到监控中心查看已过期资产" class="underline hover:no-underline">{{ store.overview.overdue_count }}</button> 项已过期</div>
</div>
<button type="button" @click="goYearCost" title="点击查看付费资产汇总" class="text-left bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800 hover:border-blue-400 dark:hover:border-blue-600 transition">
<div class="text-xs text-slate-500">本年支出</div>
<div class="text-2xl font-bold mt-1">{{ store.overview.year_cost || 0 }}</div>
<!-- 合计只统计填了费用的资产,缺口必须说出来:否则这个数看着像准数、实际是下限 -->
<div v-if="store.overview.year_cost_missing" class="text-[11px] mt-1 text-amber-600 dark:text-amber-400">另有 {{ store.overview.year_cost_missing }} 项未填费用,实际更高</div>
</button>
<button type="button" @click="goAbnormal" title="点击查看已停止/已过期的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl p-4 border border-amber-200 dark:border-amber-900/50 hover:border-amber-400 dark:hover:border-amber-700 transition">
<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>
</button>
</div>
<!-- 到期日历:不限制在 30 天窗口内,每个有到期日的产品都列出“几号到期 / 还剩多久” -->
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold">到期日历({{ expiryList.length }})</h2>
<a href="#monitor" class="text-xs text-blue-600 dark:text-blue-400 hover:underline">全部倒计时 →</a>
</div>
<div v-if="expiryList.length" class="space-y-2">
<div v-for="a in expiryList" :key="a.id" class="flex justify-between items-center gap-3 px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">
<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 truncate">{{ meta(a) }}</div>
</div>
<div class="text-right shrink-0">
<div class="text-sm" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date }}</div>
<span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.expiryBadge(a.days_to_expiry)">{{ Fmt.countdownText(a.days_to_expiry) }}</span>
</div>
</div>
</div>
<p v-else class="text-xs text-slate-400 py-4 text-center">没有资产设置了到期日,算不出倒计时</p>
<p v-if="noExpiryCount" class="text-xs text-slate-400 mt-2">另 {{ noExpiryCount }} 个资产未填到期日,不会出现在这里(到资产页编辑补上)</p>
</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() {
// 到期日历:只看未注销且有到期日的资产,最紧急(含已过期)排最前
// 用 allAssets(全量口径):总览是全局视图,不能跟着资产页的筛选变小
const expiryList = Vue.computed(() => store.allAssets
.filter(a => a.expiry_date && a.status !== 'cancelled')
.sort((x, y) => (x.days_to_expiry ?? 10 ** 9) - (y.days_to_expiry ?? 10 ** 9)));
const noExpiryCount = Vue.computed(() => store.allAssets.filter(a => !a.expiry_date && a.status !== 'cancelled').length);
// 副标题:类型 · 平台 · 费用/周期,缺失的字段不占位(避免出现 "VPS · · 3.5USD")
function meta(a) {
const bits = [Fmt.TYPE_LABELS[a.asset_type] || a.asset_type, a.provider_name || a.provider];
if (a.cost) bits.push(a.cost + (a.currency || '') + '/' + (Fmt.CYCLE_LABELS[a.renewal_cycle] || '未设周期'));
return bits.filter(Boolean).join(' · ');
}
// 卡片点击:带上对应筛选条件跳到目标视图,数字点一下就能看到背后的具体清单
function goTotal() { openAssetsWithFilter({}); }
function goExpiring30() { openMonitorWithExpiryFilter('soon30'); }
function goOverdue() { openMonitorWithExpiryFilter('expired'); }
function goYearCost() { openAssetsWithFilter({ assetTab: 'subscription' }); }
function goAbnormal() { openAssetsWithFilter({ filterStatus: 'abnormal' }); }
return { store, Fmt, expiryList, noExpiryCount, meta, goTotal, goExpiring30, goOverdue, goYearCost, goAbnormal };
},
};