评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 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
81 lines
6.2 KiB
JavaScript
81 lines
6.2 KiB
JavaScript
/* 总览视图 */
|
||
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 };
|
||
},
|
||
};
|