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

117 lines
8.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 ProvidersView = {
template: `
<div class="space-y-3">
<!-- 说明条:让用户明白平台是什么 -->
<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">
🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。凭证(登录信息/API Key)配在账号上:点平台卡片「👤 账号」新增账号并填写凭证,即可在账号上「测试/同步」拉取真实数据。
</div>
<div class="flex gap-2 flex-wrap">
<button @click="seed" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">初始化预设平台</button>
<button @click="create" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 自定义平台</button>
<button @click="manageAccounts" class="text-sm px-3 py-1.5 rounded-lg border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20">👤 账号管理</button>
<button @click="toggleShowAll" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800">{{ showAll ? '只显示在用' : '显示全部平台(' + store.providers.length + ')' }}</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-for="p in visibleProviders" :key="p.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="flex justify-between items-start">
<div>
<div class="font-medium text-sm">{{ p.name }}</div>
<div class="text-xs text-slate-400 mt-0.5">{{ p.slug }}</div>
</div>
<div v-if="serviceList(p).length" class="flex flex-wrap gap-1 justify-end max-w-[60%]">
<span v-for="s in serviceList(p)" :key="s" class="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400">{{ Fmt.SERVICES_LABELS[s] || s }}</span>
</div>
<span v-else class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(p.category)">{{ Fmt.CATEGORY_LABELS[p.category] }}</span>
</div>
<div class="text-xs text-slate-500 mt-2 space-y-1">
<!-- API 对接状态如实展示:能力矩阵来自后端 /providers/adapters,
即「适配器真的实现了哪些方法」,不是拿 sdk_type 有没有值充数 -->
<div v-if="isSupported(p)" class="flex flex-wrap items-center gap-1">
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">✓ API 已对接</span>
<span v-for="c in capsSummary(p)" :key="c" class="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400">{{ c }}</span>
</div>
<div v-else-if="p.sdk_type" class="text-[10px] px-1.5 py-0.5 inline-block rounded-full bg-red-500/10 text-red-600 dark:text-red-400" :title="'声明的 ' + p.sdk_type + ' 没有对应适配器实现'">
⚠ 已声明 {{ p.sdk_type }},但无对应适配器
</div>
<div v-else class="text-[10px] px-1.5 py-0.5 inline-block rounded-full bg-slate-100 dark:bg-slate-800 text-slate-400">未对接 API(仅台账登记)</div>
<div v-if="p.console_url"><a :href="p.console_url" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">管理面板 ↗</a></div>
<div class="flex items-center gap-2 mt-1">
<button @click="goAssets(p)" :disabled="!countOf(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400 disabled:hover:text-slate-500" :title="countOf(p) ? '查看该平台下的资产' : ''">📎 关联 {{ countOf(p) }} 个资产</button>
<button @click="openAccounts(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400">👤 账号 {{ accountsCountOf(p) }}</button>
<span :class="p.enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ p.enabled ? '启用' : '停用' }}</span>
</div>
<div v-if="p.last_synced_at" class="text-slate-400">最后同步:{{ fmtTime(p.last_synced_at) }}</div>
</div>
<div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs flex-wrap">
<button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</div>
<button v-if="!showAll && hiddenCount" @click="toggleShowAll" class="w-full text-center text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 py-2">
已隐藏 {{ hiddenCount }} 个未使用平台(无资产/账号/API 配置)· 点击显示全部
</button>
</div>`,
setup() {
// 凭证已下沉到账号层:平台卡片不再提供测试/同步(见账号弹窗)
// 平台关联资产数:优先按 provider_id 归并,其次按资产里填的平台名/slug
// 用 allAssets(全量口径):「关联 N 个资产」是平台的事实数量,
// 不能因为资产页正在筛选就少算(否则点进去看到的清单与数字对不上)
const assetCounts = Vue.computed(() => {
const m = {};
for (const a of store.allAssets) {
if (a.provider_id) m['id:' + a.provider_id] = (m['id:' + a.provider_id] || 0) + 1;
else if (a.provider) m['p:' + a.provider] = (m['p:' + a.provider] || 0) + 1;
}
return m;
});
function countOf(p) {
return (assetCounts.value['id:' + p.id] || 0) + (assetCounts.value['p:' + p.slug] || 0) + (assetCounts.value['p:' + p.name] || 0);
}
// 平台名下账号数(账号按 platform slug 归属平台)
function accountsCountOf(p) {
return store.accounts.filter(a => a.platform === p.slug).length;
}
// 在用判定:有资产/有账号/已配置 API 任一即算在用
function isUsed(p) {
return countOf(p) > 0 || accountsCountOf(p) > 0 || p.has_api_config;
}
// 默认隐藏未使用平台,避免预设平台撑满屏幕;偏好持久化到 localStorage
const showAll = Vue.ref(localStorage.getItem('vps_show_all_providers') === '1');
function toggleShowAll() {
showAll.value = !showAll.value;
localStorage.setItem('vps_show_all_providers', showAll.value ? '1' : '0');
}
const visibleProviders = Vue.computed(() => showAll.value ? store.providers : store.providers.filter(isUsed));
const hiddenCount = Vue.computed(() => store.providers.length - visibleProviders.value.length);
function serviceList(p) {
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
}
// API 对接状态:以适配器注册表为准,不看 sdk_type 有没有值
function isSupported(p) {
return !!p.sdk_type && (store.adapters.supported_types || []).includes(p.sdk_type);
}
// 能力摘要:把 capabilities 里为 true 的方法归并成用户看得懂的标签
const CAPS_LABELS = [
['list_vps', 'VPS同步'],
['list_domains', '域名同步'],
['get_account', '余额'],
['start_instance', '开关机'],
['reboot_instance', '重启'],
['renew_instance', '续费'],
['list_dns_records', 'DNS查询'],
['create_dns_record', 'DNS写入'],
];
function capsSummary(p) {
const caps = sdkCaps(p.sdk_type);
return CAPS_LABELS.filter(([k]) => caps[k]).map(([, label]) => label);
}
function fmtTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
return { store, Fmt, countOf, accountsCountOf, serviceList, isSupported, capsSummary, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
},
};