feat(router): v0.2.4 阶段二 管理入口迁移与前端同步

管理入口从 /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)、登录页公开、
各页面引用的静态资源均为新路径且无残留旧路径。
This commit is contained in:
sun
2026-10-04 01:17:26 +08:00
parent 1e2e37ae97
commit 5113941f44
8 changed files with 41 additions and 28 deletions
+1 -1
View File
@@ -244,7 +244,7 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"ok": true, "ok": true,
"message": "installed", "message": "installed",
"redirect": "/admin", "redirect": "/dashboard", // v0.3.0: 管理入口由 /admin 迁至 /dashboard
"settings": len(settingsKV), "settings": len(settingsKV),
"voices": inserted, "voices": inserted,
}) })
+4 -4
View File
@@ -6,7 +6,7 @@
<title>管理后台 · 登录</title> <title>管理后台 · 登录</title>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
<link rel="stylesheet" href="/admin/admin.css"> <link rel="stylesheet" href="/dashboard/admin.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -22,7 +22,7 @@
</div> </div>
</div> </div>
<script src="/admin/admin-shell.js"></script> <script src="/dashboard/admin-shell.js"></script>
<script> <script>
const { createApp, ref } = Vue; const { createApp, ref } = Vue;
const { http, setKey, clearKey, getKey } = window.admin; const { http, setKey, clearKey, getKey } = window.admin;
@@ -34,7 +34,7 @@
// 已登录则直接跳 dashboard // 已登录则直接跳 dashboard
if (getKey()) { if (getKey()) {
http.get('/admin/overview').then(() => { http.get('/admin/overview').then(() => {
location.href = '/admin'; location.href = '/dashboard';
}).catch(() => { clearKey(); }); }).catch(() => { clearKey(); });
} }
@@ -42,7 +42,7 @@
loginErr.value = ''; loginErr.value = '';
try { try {
await window.admin.login(keyInput.value); await window.admin.login(keyInput.value);
location.href = '/admin'; location.href = '/dashboard';
} catch (e) { } catch (e) {
loginErr.value = '鉴权失败: ' + (e.response?.data?.error?.message || e.message); loginErr.value = '鉴权失败: ' + (e.response?.data?.error?.message || e.message);
} }
+5 -5
View File
@@ -6,7 +6,7 @@
<title>火山 TTS · 系统设置</title> <title>火山 TTS · 系统设置</title>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
<link rel="stylesheet" href="/admin/admin.css"> <link rel="stylesheet" href="/dashboard/admin.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -34,7 +34,7 @@
<div class="card-head"> <div class="card-head">
<div> <div>
<div class="card-title">鉴权 Key</div> <div class="card-title">鉴权 Key</div>
<div class="card-sub">客户端调用 <code>/v1/audio/speech</code> 与登录 /admin 共用</div> <div class="card-sub">客户端调用 <code>/v1/audio/speech</code>。管理后台登录改用 <code>admin_key</code>(未配置时回退用本 Key)</div>
</div> </div>
<span class="pill pill-accent">敏感</span> <span class="pill pill-accent">敏感</span>
</div> </div>
@@ -143,7 +143,7 @@
</div> </div>
</div> </div>
<script src="/admin/admin-shell.js"></script> <script src="/dashboard/admin-shell.js"></script>
<script> <script>
const { createApp, ref, computed, onMounted } = Vue; const { createApp, ref, computed, onMounted } = Vue;
const { http, getKey, setKey, clearKey, toast, mountShell } = window.admin; const { http, getKey, setKey, clearKey, toast, mountShell } = window.admin;
@@ -236,7 +236,7 @@
authKeyInput.value = ''; authKeyInput.value = '';
setTimeout(() => { setTimeout(() => {
clearKey(); clearKey();
location.href = '/admin/login'; location.href = '/dashboard/login';
}, 200); }, 200);
toast('ok', 'Auth Key 已更新,请用新 Key 重新登录'); toast('ok', 'Auth Key 已更新,请用新 Key 重新登录');
} catch (e) { } catch (e) {
@@ -246,7 +246,7 @@
const resetSettingsForm = () => { loadSettings(); }; const resetSettingsForm = () => { loadSettings(); };
onMounted(() => { onMounted(() => {
if (!getKey()) { location.href = '/admin/login'; return; } if (!getKey()) { location.href = '/dashboard/login'; return; }
mountShell({ mountShell({
activeNav: 'settings', activeNav: 'settings',
overview: overview.value, overview: overview.value,
+10 -4
View File
@@ -112,21 +112,27 @@
<nav class="nav"> <nav class="nav">
<div class="nav-group"> <div class="nav-group">
<div class="nav-label">概览</div> <div class="nav-label">概览</div>
<a class="nav-item${activeNav === 'dashboard' ? ' active' : ''}" href="/admin"> <a class="nav-item${activeNav === 'dashboard' ? ' active' : ''}" href="/dashboard">
<span class="nav-icon">▦</span><span>仪表盘</span> <span class="nav-icon">▦</span><span>仪表盘</span>
</a> </a>
</div> </div>
<div class="nav-group"> <div class="nav-group">
<div class="nav-label">管理</div> <div class="nav-label">管理</div>
<a class="nav-item${activeNav === 'voices' ? ' active' : ''}" href="/admin/voices"> <a class="nav-item${activeNav === 'voices' ? ' active' : ''}" href="/dashboard/voices">
<span class="nav-icon">♪</span><span>音色</span> <span class="nav-icon">♪</span><span>音色</span>
<span class="nav-badge">${voiceCount}</span> <span class="nav-badge">${voiceCount}</span>
</a> </a>
<a class="nav-item${activeNav === 'settings' ? ' active' : ''}" href="/admin/settings"> <a class="nav-item${activeNav === 'settings' ? ' active' : ''}" href="/dashboard/settings">
<span class="nav-icon">⚙</span><span>设置</span> <span class="nav-icon">⚙</span><span>设置</span>
${corsConfigured ? '' : '<span class="nav-dot" title="CORS 未配置"></span>'} ${corsConfigured ? '' : '<span class="nav-dot" title="CORS 未配置"></span>'}
</a> </a>
</div> </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> </nav>
<div class="sidebar-foot"> <div class="sidebar-foot">
<button class="btn-icon" id="btn-reload" title="刷新">↻ 刷新</button> <button class="btn-icon" id="btn-reload" title="刷新">↻ 刷新</button>
@@ -142,7 +148,7 @@
if (btnLogout) btnLogout.addEventListener('click', () => { if (btnLogout) btnLogout.addEventListener('click', () => {
clearKey(); clearKey();
if (typeof options.onLogout === 'function') options.onLogout(); if (typeof options.onLogout === 'function') options.onLogout();
else location.href = '/admin/login'; else location.href = '/dashboard/login';
}); });
return {}; return {};
}; };
+3 -3
View File
@@ -6,7 +6,7 @@
<title>火山 TTS · 音色管理</title> <title>火山 TTS · 音色管理</title>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
<link rel="stylesheet" href="/admin/admin.css"> <link rel="stylesheet" href="/dashboard/admin.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -159,7 +159,7 @@
</teleport> </teleport>
</div> </div>
<script src="/admin/admin-shell.js"></script> <script src="/dashboard/admin-shell.js"></script>
<script> <script>
const { createApp, ref, computed, onMounted } = Vue; const { createApp, ref, computed, onMounted } = Vue;
const { http, getKey, toast, mountShell } = window.admin; const { http, getKey, toast, mountShell } = window.admin;
@@ -237,7 +237,7 @@
}; };
onMounted(() => { onMounted(() => {
if (!getKey()) { location.href = '/admin/login'; return; } if (!getKey()) { location.href = '/dashboard/login'; return; }
mountShell({ mountShell({
activeNav: 'voices', activeNav: 'voices',
overview: overview.value, overview: overview.value,
+3 -3
View File
@@ -6,7 +6,7 @@
<title>火山 TTS · 仪表盘</title> <title>火山 TTS · 仪表盘</title>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
<link rel="stylesheet" href="/admin/admin.css"> <link rel="stylesheet" href="/dashboard/admin.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -77,7 +77,7 @@
</div> </div>
</div> </div>
<script src="/admin/admin-shell.js"></script> <script src="/dashboard/admin-shell.js"></script>
<script> <script>
const { createApp, ref, onMounted } = Vue; const { createApp, ref, onMounted } = Vue;
const { http, getKey, formatUptime, shortPath, formatBytes, formatStart, mountShell } = window.admin; const { http, getKey, formatUptime, shortPath, formatBytes, formatStart, mountShell } = window.admin;
@@ -93,7 +93,7 @@
}; };
onMounted(() => { onMounted(() => {
if (!getKey()) { location.href = '/admin/login'; return; } if (!getKey()) { location.href = '/dashboard/login'; return; }
mountShell({ activeNav: 'dashboard', overview: overview.value, voiceCount: overview.value.voice_count, onReload: loadOverview }); mountShell({ activeNav: 'dashboard', overview: overview.value, voiceCount: overview.value.voice_count, onReload: loadOverview });
loadOverview(); loadOverview();
}); });
+10 -3
View File
@@ -6,6 +6,7 @@
<title>TTS 服务监控</title> <title>TTS 服务监控</title>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
<link rel="stylesheet" href="/dashboard/admin.css">
<style> <style>
:root { :root {
--bg: #0b0f1a; --bg: #0b0f1a;
@@ -148,6 +149,7 @@
</div> </div>
</div> </div>
<div class="row-flex"> <div class="row-flex">
<a class="btn" href="/dashboard" style="text-decoration:none;">← 返回控制台</a>
<span class="status-badge" :class="health.status === 'ok' ? 'ok' : 'error'"> <span class="status-badge" :class="health.status === 'ok' ? 'ok' : 'error'">
<span class="status-dot" :class="{ live: health.status === 'ok' }"></span> <span class="status-dot" :class="{ live: health.status === 'ok' }"></span>
{{ health.status === 'ok' ? '运行中' : (health.status || '加载中') }} {{ health.status === 'ok' ? '运行中' : (health.status || '加载中') }}
@@ -377,8 +379,11 @@
</div> </div>
</div> </div>
<script src="/dashboard/admin-shell.js"></script>
<script> <script>
const { createApp, ref, computed, onMounted } = Vue; const { createApp, ref, computed, onMounted } = Vue;
// v0.3.0:/health 与 /metrics 已收口到鉴权接口,统一走带凭证头的 /api/admin/*
const { http, getKey } = window.admin;
function parseProm(text) { function parseProm(text) {
const out = {}; const out = {};
@@ -597,16 +602,16 @@
const fetchHealth = async () => { const fetchHealth = async () => {
try { try {
const r = await axios.get("/health"); const r = await http.get("/admin/health");
health.value = r.data; health.value = r.data;
} catch (e) { } catch (e) {
error.value = "无法获取健康状态: " + (e.message || "未知错误"); error.value = "无法获取健康状态: " + (e.response?.data?.error?.message || e.message || "未知错误");
} }
}; };
const fetchMetrics = async () => { const fetchMetrics = async () => {
try { try {
const r = await axios.get("/metrics"); const r = await http.get("/admin/metrics");
metrics.value = parseProm(r.data); metrics.value = parseProm(r.data);
} catch (e) { } catch (e) {
// 不覆盖 error,让 health 错误更显眼 // 不覆盖 error,让 health 错误更显眼
@@ -650,6 +655,8 @@
}; };
onMounted(() => { onMounted(() => {
// v0.3.0:本页已收口到管理鉴权,未登录先回登录页
if (!getKey()) { location.href = '/dashboard/login'; return; }
refresh(); refresh();
setInterval(refresh, 10000); setInterval(refresh, 10000);
}); });
+5 -5
View File
@@ -202,7 +202,7 @@
<div class="field"> <div class="field">
<label>OpenAI 鉴权 Key <span class="req">*</span></label> <label>OpenAI 鉴权 Key <span class="req">*</span></label>
<input type="password" v-model="form.auth_key" placeholder="自定,例如 my-secret-key-2024" autocomplete="off"> <input type="password" v-model="form.auth_key" placeholder="自定,例如 my-secret-key-2024" autocomplete="off">
<div class="hint">客户端用这个 Key 调 <code>/v1/audio/speech</code>,也是登录 <code>/admin</code> 的密码。装完可在 <code>/admin → 设置</code> 改。</div> <div class="hint">客户端用这个 Key 调 <code>/v1/audio/speech</code>,也是登录 <code>/dashboard</code> 的密码。装完可在 <code>/dashboard → 设置</code> 改(可另配独立 <code>admin_key</code> 用于后台登录,避免与业务 Key 共用)。</div>
</div> </div>
<div class="field"> <div class="field">
<label>火山 TTS API Key <span class="req">*</span></label> <label>火山 TTS API Key <span class="req">*</span></label>
@@ -216,7 +216,7 @@
<div class="card-head"> <div class="card-head">
<div> <div>
<div class="card-title">默认路由与格式</div> <div class="card-title">默认路由与格式</div>
<div class="card-sub">未传 voice/format 时使用。装完可在 <code>/admin → 设置</code> 改。</div> <div class="card-sub">未传 voice/format 时使用。装完可在 <code>/dashboard → 设置</code> 改。</div>
</div> </div>
<span class="step-badge">第 2 步</span> <span class="step-badge">第 2 步</span>
</div> </div>
@@ -261,7 +261,7 @@
<div class="card-head"> <div class="card-head">
<div> <div>
<div class="card-title">音色列表 <span style="font-weight: 400; color: var(--text-dim); font-size: 12px;">(至少 1 条)</span></div> <div class="card-title">音色列表 <span style="font-weight: 400; color: var(--text-dim); font-size: 12px;">(至少 1 条)</span></div>
<div class="card-sub">填入火山 TTS 音色 ID(S_ 开头的为复刻音色)。可后续在 /admin 中增删。</div> <div class="card-sub">填入火山 TTS 音色 ID(S_ 开头的为复刻音色)。可后续在 /dashboard 中增删。</div>
</div> </div>
<span class="step-badge">第 3 步</span> <span class="step-badge">第 3 步</span>
</div> </div>
@@ -322,7 +322,7 @@
<!-- Nav --> <!-- Nav -->
<div class="actions"> <div class="actions">
<button class="btn" @click="prev" :disabled="step===1 || submitting">← 上一步</button> <button class="btn" @click="prev" :disabled="step===1 || submitting">← 上一步</button>
<div class="hint-text">提交后立即进入正常模式;后续管理请访问 <code>/admin</code>。</div> <div class="hint-text">提交后立即进入正常模式;后续管理请访问 <code>/dashboard</code>。</div>
<div style="display: flex; gap: 8px;"> <div style="display: flex; gap: 8px;">
<button v-if="step < 4" class="btn btn-primary" @click="next">下一步 →</button> <button v-if="step < 4" class="btn btn-primary" @click="next">下一步 →</button>
<button v-else class="btn btn-primary" @click="submit" :disabled="submitting"> <button v-else class="btn btn-primary" @click="submit" :disabled="submitting">
@@ -412,7 +412,7 @@
}, },
voices: form.value.voices, voices: form.value.voices,
}); });
window.location.href = r.data.redirect || '/admin'; window.location.href = r.data.redirect || '/dashboard';
} catch (e) { } catch (e) {
const msg = e.response?.data?.error?.message || e.message; const msg = e.response?.data?.error?.message || e.message;
errorMsg.value = '安装失败: ' + msg; errorMsg.value = '安装失败: ' + msg;