原 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)
171 lines
6.7 KiB
JavaScript
171 lines
6.7 KiB
JavaScript
/* 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 => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||
}[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,
|
||
};
|
||
})();
|