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