Files
sun 7b8f557f54 feat(router): v0.3.0 阶段二 管理入口迁移与前端同步
管理入口从 /admin 迁至 /dashboard:
- /dashboard、/dashboard/voices、/dashboard/settings 为新入口
- 原挂在 /dashboard 上的服务状态预览页(health.html)迁至 /dashboard/status,
  侧边栏新增「观测 → 详细状态」入口
- /admin 及子路径 301 永久重定向到对应新路径(页面用 301 是正确的)
- 静态资源同时注册 /dashboard/ 与 /admin/ 两套路径,
  避免过渡期出现"页面能开但丢样式"

前端同步:
- admin-shell.js:侧边栏导航、登出、登录跳转改指 /dashboard/*
- admin.html / admin-login.html / admin-voices.html / admin-settings.html:
  静态资源引用与登录跳转改指 /dashboard/*
- health.html:改用 admin-shell 的 http 实例并改调 /admin/health、/admin/metrics
  (该实例自动附加 Authorization: Bearer;原实现用裸 axios,不带凭证),
  并加登录校验与「返回控制台」入口
- setup.html:安装完成跳转目标改为 /dashboard,页面内 /admin 文案同步更新
- 各页面的 API 调用路径无需修改:axios baseURL 为 /api,
  故 /voices、/admin/overview 等自动落成 /api/admin/*

controller/setup.go:安装成功响应的 redirect 字段由 /admin 改为 /dashboard。

验证:go build / go vet / go test ./... -count=1 全绿;
另用真实服务器端到端验证:旧入口 301 全部正确、静态资源新旧路径均 200、
管理页面内容协商(html 200 / 非 html 401)、登录页公开、
各页面引用的静态资源均为新路径且无残留旧路径。
2026-10-04 01:17:26 +08:00

177 lines
7.0 KiB
JavaScript
Raw Permalink 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="/dashboard">
<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="/dashboard/voices">
<span class="nav-icon">♪</span><span>音色</span>
<span class="nav-badge">${voiceCount}</span>
</a>
<a class="nav-item${activeNav === 'settings' ? ' active' : ''}" href="/dashboard/settings">
<span class="nav-icon">⚙</span><span>设置</span>
${corsConfigured ? '' : '<span class="nav-dot" title="CORS 未配置"></span>'}
</a>
</div>
<div class="nav-group">
<div class="nav-label">观测</div>
<a class="nav-item${activeNav === 'status' ? ' active' : ''}" href="/dashboard/status">
<span class="nav-icon">♥</span><span>详细状态</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 = '/dashboard/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,
};
})();