Files
vps-manager/static/js/views/assets.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

182 lines
12 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.
/* 资产视图:TAB 容器(全部/VPS/域名/Cloudflare/订阅/AI账号)
* 注意:专有视图(DomainsView 等)在后续脚本才定义,必须在 setup 运行时求值,
* 不能在组件定义处直接引用(会因加载顺序抛 ReferenceError)。
*/
const AssetsView = {
template: `
<div class="space-y-3">
<!-- 类型 TAB -->
<div class="flex flex-wrap gap-1.5">
<button v-for="t in tabs" :key="t.key" @click="switchTab(t.key)" :title="t.tip"
class="text-sm px-3 py-1.5 rounded-full transition"
:class="store.assetTab===t.key ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'">
{{ t.label }}
</button>
</div>
<!-- 平台筛选标签(从平台页跳转而来,点 ✕ 取消) -->
<div v-if="store.filterProvider" class="flex items-center">
<span class="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
🏢 平台:{{ providerName(store.filterProvider) }}
<button @click="clearProvider" class="font-bold hover:opacity-70" title="取消平台筛选">✕</button>
</span>
</div>
<!-- 通用列表(全部 / VPS) -->
<template v-if="store.assetTab==='' || store.assetTab==='vps'">
<div class="flex flex-wrap gap-2 items-center">
<select v-model="store.filterStatus" @change="reload" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<option value="">全部状态</option>
<option value="abnormal">异常(已停止/已过期)</option>
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select>
<button v-if="store.assetTab===''" @click="syncAllAI" class="text-sm px-3 py-1.5 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20">同步AI余额</button>
<button v-if="store.assetTab==='' || store.assetTab==='vps'" @click="syncAllAssets" class="text-sm px-3 py-1.5 rounded-lg border border-emerald-300 dark:border-emerald-700 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20" title="拉取所有已配 API 凭证账号下的云端 VPS 与域名">同步云端资产</button>
<span class="ml-auto text-xs text-slate-400">共 {{ list.length }} 条</span>
</div>
<!-- 桌面表格 -->
<div class="hidden md:block 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">费用</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="!list.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无资产</td></tr>
<tr v-for="a in list" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30" :class="{ 'opacity-50': a.is_archived }">
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span></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="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" :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"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span></td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">监控</button>
<button v-if="canControl(a)" @click="openAct(a)" class="text-amber-600 dark:text-amber-400 hover:underline mr-3 text-xs" title="远程开机/关机/重启/续费">云操作</button>
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400 hover:underline mr-3 text-xs">刷新余额</button>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动卡片 -->
<div class="md:hidden space-y-2">
<div v-if="!list.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">暂无资产</div>
<div v-for="a in list" :key="a.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3" :class="{ 'opacity-50': a.is_archived }">
<div class="flex justify-between items-start">
<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">{{ a.provider_name || a.provider }}</div>
</div>
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
</div>
<div class="flex items-center gap-3 mt-2 text-xs">
<span :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '无到期' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined"> ({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }})</span></span>
<span :class="a.cost ? 'text-slate-500' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}</span>
<span class="px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span>
</div>
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400">监控</button>
<button v-if="canControl(a)" @click="openAct(a)" class="text-amber-600 dark:text-amber-400">云操作</button>
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400">刷新余额</button>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</div>
</template>
<!-- 专有视图(域名/Cloudflare/订阅/AI账号) -->
<component v-else :is="tabView"></component>
</div>`,
setup() {
// tip 为 TAB 悬浮说明:让用户一眼明白每个分类装的是什么
const tabs = [
{ key: '', label: '全部', tip: '所有类型的资产' },
{ key: 'vps', label: 'VPS', tip: '云服务器(可查看资源监控)' },
{ key: 'domain', label: '域名', tip: '域名与解析' },
{ key: 'cloudflare', label: 'Cloudflare', tip: 'Cloudflare 账号下的资源' },
{ key: 'subscription', label: '付费资产', tip: '有费用或到期日、需要续费的项目(跨类型汇总)' },
{ key: 'ai_agent', label: 'AI账号', tip: 'ChatGPT 等 AI 服务账号与余额' },
];
const tabView = Vue.computed(() => {
const t = store.assetTab;
if (t === 'domain') return DomainsView;
if (t === 'cloudflare') return CloudflareView;
if (t === 'subscription') return SubscriptionsView;
if (t === 'ai_agent') return AIAccountsView;
return null;
});
// 通用列表:全部显示所有,VPS 前端过滤类型;「异常」状态是后端不支持的组合筛选,也走前端过滤
const list = Vue.computed(() => {
let arr = store.assets;
if (store.assetTab === 'vps') arr = arr.filter(a => a.asset_type === 'vps');
if (store.filterStatus === 'abnormal') arr = arr.filter(a => a.status === 'stopped' || a.status === 'expired');
return arr;
});
function switchTab(k) {
store.assetTab = k;
// 专有视图(域名/Cloudflare/订阅/AI)有自己的筛选逻辑,通用的类型/状态筛选切过去后会失效,
// 直接清空避免用户以为筛过了但实际看不出来
if (k !== '' && k !== 'vps' && (store.filterType || store.filterStatus)) {
store.filterType = '';
store.filterStatus = '';
loadAssets();
}
}
// 平台筛选标签显示名:filterProvider 存 slug,回退显示原值
function providerName(slug) {
const p = store.providers.find(x => x.slug === slug);
return p ? p.name : slug;
}
async function refreshBalance(a) {
if (!confirm('刷新「' + a.name + '」的余额?')) return;
try {
const r = await Api.post('/assets/' + a.id + '/refresh-balance', {});
alert('余额已更新:' + r.balance + ' ' + r.currency);
await loadAssets();
} catch (e) { alert('刷新失败:' + e.message); }
}
async function syncAllAI() {
if (!confirm('同步所有 AI 账号的余额?')) return;
try {
const r = await Api.post('/sync/ai-balances', {});
// skipped:平台本身没余额接口(如 OpenAI 已下线 billing),不属于失败
alert('同步完成:成功 ' + r.success + ',失败 ' + r.failed +
(r.skipped ? ',跳过 ' + r.skipped + '(平台无余额接口)' : '') +
(r.errors && r.errors.length ? '\n' + r.errors.join('\n') : ''));
await loadAssets();
} catch (e) { alert('同步失败:' + e.message); }
}
async function syncAllAssets() {
if (!confirm('从云端拉取所有已配 API 凭证账号下的 VPS 与域名?\n\n会按 (平台, 云端实例ID) 去重:已存在则更新状态/到期日,不存在则新建。')) return;
try {
const r = await Api.post('/sync/assets', {});
const lines = [
'同步完成:成功 ' + r.success + ',失败 ' + r.failed,
'新增 ' + r.created + ',更新 ' + r.updated,
];
// 未配凭证 / 平台无适配器属于「跳过」而非失败,但必须如实报出来,
// 否则用户以为同步过了、实际一条都没拉
if (r.skipped_no_config) lines.push('跳过(账号未配 API 凭证):' + r.skipped_no_config);
if (r.skipped_no_adapter) lines.push('跳过(平台无可用适配器):' + r.skipped_no_adapter);
if (r.errors && r.errors.length) lines.push('\n' + r.errors.join('\n'));
alert(lines.join('\n'));
await Promise.all([loadAssets(), loadOverview()]);
} catch (e) { alert('同步失败:' + e.message); }
}
return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI, syncAllAssets, canControl: assetCanControl, openAct: openActions };
},
};