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