Files
Volcano-Engine-TTS-UI/router/admin-shell.js
T
sun 9245c1339a refactor(admin): 把单文件 admin.html (1075 行) 拆成多页 SPA,URL 路由切换
原 admin.html 是 1075 行的单文件 SPA:登录/仪表盘/音色/设置 4 个面板
挤在一个 Vue 实例里 tab 切换(login -> app shell -> 3 个 section)。
文件太大,样式 + script + template 混在一起,改一个面板要 scroll 整页找位置。

拆分后:
  router/admin.html         5.4 KB  Dashboard (重定向目标,默认页)
  router/admin-login.html   2.1 KB  登录页 (无 sidebar)
  router/admin-voices.html  13.3 KB 音色管理 + Add modal
  router/admin-settings.html 14.1 KB 系统设置
  router/admin.css          19.8 KB 共享样式 (4 个页面 + admin-shell.js 共用)
  router/admin-shell.js     6.9 KB  共享:http client (401 自动清 key)、toast 系统、
                                login helper、sidebar 渲染、format helpers

最大的页面 14 KB,平均 10 KB,各页只负责自己逻辑。

技术细节:
- 栈保持: Vue 3 + Axios 走 CDN,纯静态 HTML,零构建
- URL 路由: /admin /admin/login /admin/voices /admin/settings,
  router.go 加 4 条 ServeFile 路由 + 2 条静态资源路由
  (/admin/admin.css /admin/admin-shell.js)
- 抽 serveAdmin(body []byte) http.HandlerFunc helper 避免 4 段重复
- 鉴权: 各页 onMounted 检 sessionStorage.ttsAdminKey,无则 location.href
  跳 /admin/login;http client 401 响应拦截器清 key,下个请求被跳到 login
- Sidebar 一致: shell.js mountShell({activeNav, overview, voiceCount, ...})
  渲染 <aside> + 高亮 active 项,各页只传 activeNav 不重复维护 DOM
- 静态资源 Content-Type 显式设置:text/css / application/javascript

未变更:
- 不动 /api/* 后端接口
- 不动 /setup /dashboard /health /v1/audio/speech 路由
- 鉴权语义: HTML 公开 / API 鉴权 (与原版一致)
- 不引入构建工具 / 不引入新依赖

验证 (PowerShell 起真 server + setup API + curl):
  /admin                          200  5433 bytes  text/html
  /admin/login                    200  2082 bytes  text/html
  /admin/voices                   200 13282 bytes  text/html
  /admin/settings                 200 14110 bytes  text/html
  /admin/admin.css                200 19815 bytes  text/css
  /admin/admin-shell.js           200  6887 bytes  application/javascript
  /api/admin/overview (无 auth)   401
  /api/admin/overview (有 auth)   200
  各页内容 marker 命中 (filteredVoices / corsForm / voice_count / login-card)
2026-09-21 23:20:59 +08:00

171 lines
6.7 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.
/* admin 共享脚本 — 多个 admin-* 页面共用
加载方式: <script src="/admin/admin-shell.js"></script>
暴露到 window.admin: { http, getKey, setKey, clearKey, toast, dismissToast, mountShell,
formatUptime, shortPath, formatBytes, formatStart, logout }
用法:
const { http, toast, mountShell } = window.admin;
const { apiKey, overview } = mountShell({ activeNav: 'voices' });
*/
(function () {
const KEY_STORAGE = 'ttsAdminKey';
// === Key 持久化(sessionStorage,跨页面同标签页共享)===
const getKey = () => sessionStorage.getItem(KEY_STORAGE) || '';
const setKey = (k) => { if (k) sessionStorage.setItem(KEY_STORAGE, k); else sessionStorage.removeItem(KEY_STORAGE); };
const clearKey = () => sessionStorage.removeItem(KEY_STORAGE);
// === HTTP client(401 自动清 key,业务层 redirect 到登录)===
const http = axios.create({ baseURL: '/api' });
http.interceptors.request.use(c => {
const k = getKey();
if (k) c.headers.Authorization = 'Bearer ' + k;
return c;
});
http.interceptors.response.use(r => r, err => {
if (err.response && err.response.status === 401) {
clearKey();
}
return Promise.reject(err);
});
// === Toasts(全局单例)===
let toasts = [];
let toastSeq = 0;
let toastStackEl = null;
const ensureToastStack = () => {
if (toastStackEl) return toastStackEl;
toastStackEl = document.createElement('div');
toastStackEl.className = 'toast-stack';
document.body.appendChild(toastStackEl);
return toastStackEl;
};
const renderToasts = () => {
const stack = ensureToastStack();
stack.innerHTML = toasts.map(t => `
<div class="toast toast-${t.type}">
<span class="toast-icon">${t.type === 'ok' ? '✓' : t.type === 'err' ? '!' : '⚠'}</span>
<span class="toast-msg">${escapeHTML(t.msg)}</span>
<button class="toast-close" data-id="${t.id}">×</button>
</div>
`).join('');
stack.querySelectorAll('.toast-close').forEach(btn => {
btn.addEventListener('click', () => dismissToast(Number(btn.dataset.id)));
});
};
const toast = (type, msg, ttl) => {
const id = ++toastSeq;
toasts.push({ id, type, msg });
renderToasts();
if (ttl !== 0) setTimeout(() => dismissToast(id), ttl || 3200);
};
const dismissToast = (id) => {
toasts = toasts.filter(t => t.id !== id);
renderToasts();
};
// === HTML escape(toast msg 兜底)===
const escapeHTML = (s) => String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
// === format helpers ===
const formatUptime = (s) => {
if (!s) return '—';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
};
const shortPath = (p) => p ? p.split(/[\\/]/).pop() : '';
const formatBytes = (n) => {
if (n === null || n === undefined) return '—';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(2) + ' MB';
};
const formatStart = (iso) => {
if (!iso) return '—';
try {
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
} catch (e) { return '—'; }
};
// === Sidebar 渲染(每个 admin-* 页面共用)===
// 占位符 <aside id="admin-sidebar"></aside> 由 shell 填入。
// options: { activeNav: 'dashboard' | 'voices' | 'settings', overview, voiceCount, corsConfigured, onReload, onLogout }
const mountShell = (options = {}) => {
const slot = document.getElementById('admin-sidebar');
if (!slot) return {};
const activeNav = options.activeNav || '';
const ov = options.overview || {};
const voiceCount = options.voiceCount != null ? options.voiceCount : '';
const corsConfigured = !!options.corsConfigured;
slot.outerHTML = `
<aside class="sidebar">
<div class="brand">
<div class="brand-logo">TTS</div>
<div>
<div class="brand-name">控制台</div>
<div class="brand-version">v${escapeHTML(ov.version || '—')} · ${escapeHTML(ov.mode || '...')}</div>
</div>
</div>
<nav class="nav">
<div class="nav-group">
<div class="nav-label">概览</div>
<a class="nav-item${activeNav === 'dashboard' ? ' active' : ''}" href="/admin">
<span class="nav-icon">▦</span><span>仪表盘</span>
</a>
</div>
<div class="nav-group">
<div class="nav-label">管理</div>
<a class="nav-item${activeNav === 'voices' ? ' active' : ''}" href="/admin/voices">
<span class="nav-icon">♪</span><span>音色</span>
<span class="nav-badge">${voiceCount}</span>
</a>
<a class="nav-item${activeNav === 'settings' ? ' active' : ''}" href="/admin/settings">
<span class="nav-icon">⚙</span><span>设置</span>
${corsConfigured ? '' : '<span class="nav-dot" title="CORS 未配置"></span>'}
</a>
</div>
</nav>
<div class="sidebar-foot">
<button class="btn-icon" id="btn-reload" title="刷新">↻ 刷新</button>
<button class="btn-icon" id="btn-logout" title="登出">⏻ 登出</button>
</div>
</aside>`;
const btnReload = document.getElementById('btn-reload');
const btnLogout = document.getElementById('btn-logout');
if (btnReload) btnReload.addEventListener('click', () => {
if (typeof options.onReload === 'function') options.onReload();
else location.reload();
});
if (btnLogout) btnLogout.addEventListener('click', () => {
clearKey();
if (typeof options.onLogout === 'function') options.onLogout();
else location.href = '/admin/login';
});
return {};
};
// === Login helper(login 页用)===
const login = async (key) => {
setKey(key);
try {
await http.get('/admin/overview');
return true;
} catch (e) {
clearKey();
throw e;
}
};
// 暴露
window.admin = {
http, getKey, setKey, clearKey,
toast, dismissToast,
mountShell, login,
formatUptime, shortPath, formatBytes, formatStart,
escapeHTML,
};
})();