Files
vps-manager/static/js/app.js
T
gouki eeb05372c4 feat(ui): 全局弹窗替代原生 alert/confirm,添加防重复提交与搜索功能
- 新增 ConfirmModal / ResultModal 全局组件,替代原生 alert/confirm/prompt
- 资产/平台/账号保存添加防重复提交锁(assetSaving / providerSaving)
- 添加 Esc 键盘快捷键关闭模态框
- 平台/监控视图添加搜索过滤功能
- 修复监控中心 SSL TAB 下新增按钮的显示逻辑
- 调整 toast 位置避免遮挡底部导航
- 统一各弹窗 z-index 层级
2026-10-17 21:17:18 +00:00

179 lines
8.8 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 v-if="store.loading" class="flex items-center gap-1.5 text-xs text-slate-400">
<span class="inline-block w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></span>
<span class="hidden sm:inline">加载中…</span>
</div>
<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 v-if="store.view!=='monitor' || store.monitorTab==='ssl'" @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>
<confirm-modal></confirm-modal>
<result-modal></result-modal>
<!-- 复制等操作的轻提示(底部导航上方,不遮内容) -->
<div v-if="toast.show" class="fixed bottom-24 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();
// 监控中心:SSL TAB 下添加证书监控,其他情况隐藏新增按钮(模板已控制)
if (store.view === 'monitor') {
if (store.monitorTab === 'ssl') return showResult('提示', '请在「资产 → 域名」的子域名面板点「探测SSL」添加证书监控');
return; // 非 SSL 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 };
},
};
/* ================= 键盘快捷键 ================= */
function initKeyboard() {
document.addEventListener('keydown', (e) => {
// Esc 关闭最上层模态框(按 z-index 从高到低检查)
if (e.key === 'Escape') {
if (store.resultModal.show) { closeResult(); return; }
if (store.confirmModal.show) { closeConfirm(false); return; }
if (store.credentialModal.show) { store.credentialModal.show = false; return; }
if (store.accountModal.show) { store.accountModal.show = false; return; }
if (store.assetModal.show) { store.assetModal.show = false; return; }
if (store.providerModal.show) { store.providerModal.show = false; return; }
if (store.accountsModal.show) { store.accountsModal.show = false; return; }
if (actionModal.show) { closeActions(); return; }
if (dnsModal.show) { closeDns(); return; }
}
});
}
/* ================= 挂载 ================= */
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);
app.component('confirm-modal', ConfirmModal);
app.component('result-modal', ResultModal);
applyDark();
initRouter();
initKeyboard();
loadAll();
app.mount('#app');