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

150 lines
8.6 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.
/* API 客户端与格式化工具 */
window.VpsApi = (function () {
const CFG = window.APP_CONFIG || { appName: 'VPS 资产管理系统' };
function getApiKey() { return localStorage.getItem('vps_api_key') || ''; }
function setApiKey(k) { localStorage.setItem('vps_api_key', k || ''); }
async function request(path, options = {}) {
const headers = { 'Content-Type': 'application/json' };
const key = getApiKey();
if (key) headers['X-API-Key'] = key;
const res = await fetch('/api' + path, { ...options, headers: { ...headers, ...(options.headers || {}) } });
if (!res.ok) {
let msg = res.statusText;
try { const e = await res.json(); msg = (typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail)) || msg; } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
return {
CFG, getApiKey, setApiKey,
get: (p) => request(p),
post: (p, body) => request(p, { method: 'POST', body: JSON.stringify(body) }),
put: (p, body) => request(p, { method: 'PUT', body: JSON.stringify(body) }),
del: (p) => request(p, { method: 'DELETE' }),
};
})();
/* 授权登录来源预设:key = 入库值(oauth_provider,统一英文 slug),label = 展示名,
* group = 表单分组顺序。国内三方登录排在最前(支付宝 / 淘宝 / 微博 等),
* 手填的自定义值由 store.oauthCustom 追加成候选,不需要动后端字典。 */
const OAUTH_PRESETS = [
{ key: 'alipay', label: '支付宝', group: '国内' },
{ key: 'taobao', label: '淘宝', group: '国内' },
{ key: 'wechat', label: '微信', group: '国内' },
{ key: 'qq', label: 'QQ', group: '国内' },
{ key: 'weibo', label: '微博', group: '国内' },
{ key: 'douyin', label: '抖音', group: '国内' },
{ key: 'baidu', label: '百度', group: '国内' },
{ key: 'jd', label: '京东', group: '国内' },
{ key: 'xiaohongshu', label: '小红书', group: '国内' },
{ key: 'bilibili', label: 'B站', group: '国内' },
{ key: 'meituan', label: '美团', group: '国内' },
{ key: 'dingtalk', label: '钉钉', group: '国内' },
{ key: 'feishu', label: '飞书', group: '国内' },
{ key: 'huawei', label: '华为', group: '国内' },
{ key: 'mi', label: '小米', group: '国内' },
{ key: 'google', label: 'Google', group: '国际' },
{ key: 'apple', label: 'Apple', group: '国际' },
{ key: 'microsoft', label: 'Microsoft', group: '国际' },
{ key: 'facebook', label: 'Facebook', group: '国际' },
{ key: 'x', label: 'X (Twitter)', group: '国际' },
{ key: 'yahoo', label: 'Yahoo', group: '国际' },
{ key: 'line', label: 'LINE', group: '国际' },
{ key: 'kakao', label: 'Kakao', group: '国际' },
{ key: 'naver', label: 'NAVER', group: '国际' },
{ key: 'amazon', label: 'Amazon', group: '国际' },
{ key: 'paypal', label: 'PayPal', group: '国际' },
{ key: 'discord', label: 'Discord', group: '国际' },
{ key: 'telegram', label: 'Telegram', group: '国际' },
{ key: 'reddit', label: 'Reddit', group: '国际' },
{ key: 'linkedin', label: 'LinkedIn', group: '国际' },
{ key: 'steam', label: 'Steam', group: '国际' },
{ key: 'epic', label: 'Epic Games', group: '国际' },
{ key: 'github', label: 'GitHub', group: '开发者' },
{ key: 'gitlab', label: 'GitLab', group: '开发者' },
{ key: 'bitbucket', label: 'Bitbucket', group: '开发者' },
{ key: 'cloudflare', label: 'Cloudflare', group: '开发者' },
{ key: 'huggingface', label: 'Hugging Face', group: '开发者' },
];
window.VpsFmt = {
TYPE_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
STATUS_LABELS: { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' },
CATEGORY_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
// 平台可提供的服务(综合平台多标签):字段对应 Provider.services 逗号分隔列表
SERVICES_LABELS: { vps: '云服务器', domain: '域名', ai_agent: 'AI服务', cloudflare: 'Cloudflare', ssl_cert: 'SSL证书', cdn: 'CDN', dns: 'DNS', other: '其他' },
CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' },
// 凭据库登录方式:字段对应 Credential.login_type
LOGIN_TYPE_LABELS: { password: '密码登录', oauth: '授权登录', other: '其他' },
/* 授权登录来源候选(表单 chips + datalist 建议)
*
* 清单在上面 OAUTH_PRESETS;这里只暴露给视图使用。
*/
OAUTH_PRESETS,
// 短展示名:命中预设给中文,否则原样返回(自定义值不加工)
oauthLabel(k) {
const p = OAUTH_PRESETS.find(x => x.key === (k || '').trim().toLowerCase());
return p ? p.label : (k || '');
},
// 详情展示:「支付宝(alipay)」,自定义值只显示原文
oauthDetail(k) {
const p = OAUTH_PRESETS.find(x => x.key === (k || '').trim().toLowerCase());
return p ? p.label + '(' + p.key + ')' : (k || '');
},
// 归一化:允许输入中文名或大小写不一致的 slug,落到规范 key 上,避免同来源存成多种写法
normalizeOauth(raw) {
const v = (raw || '').trim();
if (!v) return '';
const lower = v.toLowerCase();
const p = OAUTH_PRESETS.find(x => x.key === lower || x.label.toLowerCase() === lower);
return p ? p.key : v;
},
loginTypeBadge(t) {
return { password: 'bg-slate-500/10 text-slate-600 dark:text-slate-400', oauth: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', other: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' }[t] || 'bg-slate-500/10 text-slate-500';
},
typeBadge(t) {
return { vps: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', domain: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', ai_agent: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', cloudflare: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', other: 'bg-slate-500/10 text-slate-600 dark:text-slate-400' }[t] || 'bg-slate-500/10 text-slate-500';
},
statusBadge(s) {
return { active: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', expired: 'bg-red-500/10 text-red-600 dark:text-red-400', stopped: 'bg-slate-500/10 text-slate-500', cancelled: 'bg-slate-500/10 text-slate-400', unknown: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' }[s] || 'bg-slate-500/10 text-slate-500';
},
expiryText(d) {
if (d === null || d === undefined) return 'text-slate-400';
if (d < 0) return 'text-red-600 dark:text-red-400 font-semibold';
if (d <= 7) return 'text-red-600 dark:text-red-400 font-semibold';
if (d <= 30) return 'text-amber-600 dark:text-amber-400';
return 'text-slate-500 dark:text-slate-400';
},
expiryBadge(d) {
if (d !== null && d !== undefined && d <= 7) return 'bg-red-500/10 text-red-600 dark:text-red-400';
if (d !== null && d !== undefined && d <= 30) return 'bg-amber-500/10 text-amber-600 dark:text-amber-400';
return 'bg-slate-500/10 text-slate-500';
},
/* 倒计时文案:统一「距到期还有多久」的表述,负数归一为「已过期 N 天」,避免直出 "-3 天"
opts.unit 传 '' 时只说「N 天」(表格窄列用),默认「N 天后」
opts.empty 无到期日时的占位,默认「未设到期日」(表格里可传 '—' / '未知')
已有多处视图各自实现一份(且措辞不一),一律改为调这里 */
countdownText(d, opts = {}) {
if (d === null || d === undefined) return opts.empty === undefined ? '未设到期日' : opts.empty;
if (d < 0) return '已过期 ' + Math.abs(d) + ' 天';
if (d === 0) return '今天到期';
return d + (opts.unit === '' ? ' 天' : ' 天后');
},
/* 费用文案:cost 为 0 一律显示「未填」,不显示「0 USD」
0 有两种来源——用户没填、或云端接口压根不返回价格(阿里云/腾讯云/AWS 的
DescribeInstances 都没有价格字段)。显示成「0 USD」会被读成「这台不花钱」,
而汇总处(订阅页筛选、月度合计)又把 0 当作「无费用」排除掉,两处口径不一致。
统一走这里,让「没数据」和「真的免费」在界面上不再混为一谈。 */
costText(cost, currency) {
if (!cost) return '未填';
return currency ? cost + ' ' + currency : String(cost);
},
checkBadge(s) {
return { pass: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', fail: 'bg-red-500/10 text-red-600 dark:text-red-400', unknown: 'bg-slate-500/10 text-slate-500' }[s] || 'bg-slate-500/10 text-slate-500';
},
};