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

147 lines
7.1 KiB
JavaScript

/* vps-manager 前端应用入口(Vue 3 组件化 + Hash 路由) */
/* ================= 导航与 Hash 路由 ================= */
const NAVS = [
{ key: 'dashboard', label: '总览', icon: '📊' },
{ key: 'assets', label: '资产', icon: '📦' },
{ key: 'monitor', label: '监控中心', icon: '⏳' },
{ key: 'vault', label: '凭据库', icon: '🔐' },
{ key: 'providers', label: '平台', icon: '🏢' },
{ key: 'settings', label: '设置', icon: '⚙️' },
];
const VIEW_MAP = {
dashboard: 'dashboard-view', assets: 'assets-view', monitor: 'monitor-view',
vault: 'vault-view', providers: 'providers-view', settings: 'settings-view',
// 服务器监控为隐藏路由(不进导航,从资产页 VPS「监控」进入)
servers: 'servers-view',
};
const VALID_VIEWS = new Set(Object.keys(VIEW_MAP));
function _viewFromHash() {
const h = (window.location.hash || '').replace(/^#\/?/, '').split('?')[0];
return VALID_VIEWS.has(h) ? h : 'dashboard';
}
function _applyView(key) {
store.view = key;
if (key !== 'servers') store.serverMetrics = {};
}
function navigate(key) {
if (!VALID_VIEWS.has(key)) key = 'dashboard';
if (window.location.hash !== '#' + key) {
window.location.hash = '#' + key;
} else {
_applyView(key);
}
}
function initRouter() {
_applyView(_viewFromHash());
window.addEventListener('hashchange', () => { _applyView(_viewFromHash()); });
}
/* ================= 根组件 ================= */
const AppRoot = {
template: `
<div class="min-h-screen md:flex">
<aside class="hidden md:flex flex-col w-52 shrink-0 border-r border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-3 sticky top-0 h-screen">
<div class="px-2 py-3 font-bold text-slate-900 dark:text-white">📦 {{ appName }}</div>
<nav class="space-y-1 mt-2">
<button v-for="n in navs" :key="n.key" @click="go(n.key)" class="w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition" :class="store.view===n.key ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400 font-medium' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'">
<span>{{ n.icon }}</span>{{ n.label }}
</button>
</nav>
<div class="mt-auto pt-3 border-t border-slate-100 dark:border-slate-800">
<button @click="toggleDark" class="w-full text-left px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800">{{ store.dark ? '☀️ 浅色' : '🌙 深色' }}</button>
<div class="px-3 pt-1 text-[11px] text-slate-400 dark:text-slate-500" :title="'commit ' + appCommit">v{{ appVersion }} · {{ appCommit }}</div>
</div>
</aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="sticky top-0 z-30 bg-white/80 dark:bg-slate-900/80 backdrop-blur border-b border-slate-200 dark:border-slate-800 px-4 py-3 flex items-center gap-3">
<h1 class="font-semibold text-slate-900 dark:text-white md:text-lg">{{ currentNav.label }}</h1>
<div class="ml-auto flex items-center gap-2">
<input v-if="store.view==='assets'" v-model="store.search" @input="reload" placeholder="搜索…" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-32 md:w-48">
<button @click="quickAdd" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700 whitespace-nowrap">+ 新增</button>
</div>
</header>
<div v-if="store.error" class="mx-4 mt-3 px-4 py-2.5 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400 text-sm flex justify-between">
<span>⚠️ {{ store.error }}</span><button @click="store.error=''" class="font-bold">✕</button>
</div>
<main class="flex-1 p-4 pb-20 md:pb-6 overflow-y-auto">
<component :is="viewComponent"></component>
</main>
</div>
<nav class="md:hidden fixed bottom-0 inset-x-0 z-30 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 flex">
<button v-for="n in navs" :key="n.key" @click="go(n.key)" class="flex-1 py-2 flex flex-col items-center gap-0.5 text-xs" :class="store.view===n.key ? 'text-blue-600 dark:text-blue-400' : 'text-slate-400'">
<span class="text-base">{{ n.icon }}</span>{{ n.label }}
</button>
</nav>
<asset-modal></asset-modal>
<provider-modal></provider-modal>
<account-modal></account-modal>
<accounts-view-modal></accounts-view-modal>
<credential-modal></credential-modal>
<action-modal></action-modal>
<dns-modal></dns-modal>
<!-- 复制等操作的轻提示(底部导航上方,不遮内容) -->
<div v-if="toast.show" class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[80] px-4 py-2 rounded-full bg-slate-900/90 dark:bg-slate-100/90 text-white dark:text-slate-900 text-xs shadow-lg pointer-events-none whitespace-nowrap">{{ toast.text }}</div>
</div>`,
setup() {
const appName = Api.CFG.appName;
const appVersion = Api.CFG.version || 'dev';
const appCommit = Api.CFG.commit || 'unknown';
const navs = NAVS;
const currentNav = Vue.computed(() => navs.find(n => n.key === store.view) || navs[0]);
const viewComponent = Vue.computed(() => VIEW_MAP[store.view] || 'dashboard-view');
function go(key) { navigate(key); }
function quickAdd() {
if (store.view === 'providers') return openProviderCreate();
if (store.view === 'vault') return openCredentialCreate();
// 资产页:按当前 TAB 预设类型;监控中心/总览/设置:默认 VPS
let preset = 'vps';
if (store.view === 'assets') {
const t = store.assetTab;
if (t === 'ai_agent') return openAICreate('');
if (t === 'domain' || t === 'cloudflare' || t === 'subscription' || t === 'vps') preset = t === 'subscription' ? 'vps' : t;
}
openAssetCreate(preset);
}
function toggleDark() { store.dark = !store.dark; applyDark(); }
// 搜索防抖:300ms 内连续输入只触发一次请求
let searchTimer = null;
function debouncedReload() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => { loadAssets(); }, 300);
}
return { store, toast, appName, appVersion, appCommit, navs, currentNav, viewComponent, go, quickAdd, toggleDark, reload: debouncedReload };
},
};
/* ================= 挂载 ================= */
const app = Vue.createApp(AppRoot);
app.component('dashboard-view', DashboardView);
app.component('assets-view', AssetsView);
app.component('monitor-view', MonitorView);
app.component('vault-view', VaultView);
app.component('providers-view', ProvidersView);
app.component('servers-view', ServersView);
app.component('settings-view', SettingsView);
app.component('asset-modal', AssetModal);
app.component('provider-modal', ProviderModal);
app.component('account-modal', AccountModal);
app.component('accounts-view-modal', AccountsViewModal);
app.component('credential-modal', CredentialModal);
app.component('action-modal', ActionModal);
app.component('dns-modal', DnsModal);
applyDark();
initRouter();
loadAll();
app.mount('#app');