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)
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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/axios/1.6.7/axios.min.js"></script>
|
||||
<link rel="stylesheet" href="/admin/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="app-shell">
|
||||
<aside id="admin-sidebar"></aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1 class="page-title">系统设置</h1>
|
||||
<p class="page-sub">鉴权、上游凭证、默认路由与跨域</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="settings && !settings.cors_configured" class="alert alert-warning">
|
||||
<span class="alert-icon">⚠</span>
|
||||
<div class="alert-body">
|
||||
<div class="alert-title">跨域 CORS 未配置</div>
|
||||
<div class="alert-desc">同源可用,跨域请求会被 403。在下方「跨域 CORS」卡片中配置允许的来源。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">鉴权 Key</div>
|
||||
<div class="card-sub">客户端调用 <code>/v1/audio/speech</code> 与登录 /admin 共用</div>
|
||||
</div>
|
||||
<span class="pill pill-accent">敏感</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>当前 Key</label>
|
||||
<div class="input-static">{{ settings.auth_key || '(未设置)' }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>替换为新 Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" v-model="authKeyInput" :placeholder="settings.auth_key_set ? '不改留空' : '输入新 Key'">
|
||||
<button class="btn btn-primary" @click="saveAuthKey" :disabled="!authKeyInput">更新</button>
|
||||
</div>
|
||||
<div class="hint">改了之后,你的浏览器 401 自动跳回登录页,用新 Key 重登。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">火山 TTS 上游凭证</div>
|
||||
<div class="card-sub">调火山 v3 上游用的凭证与默认路由</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>当前 API Key</label>
|
||||
<div class="input-static">{{ settings.api_key || '(未设置)' }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>替换为新 API Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" v-model="apiKeyInput" :placeholder="settings.api_key_set ? '不改留空' : '输入新 API Key'">
|
||||
<button class="btn btn-primary" @click="saveApiKey" :disabled="!apiKeyInput">更新</button>
|
||||
</div>
|
||||
<div class="hint">独立端点 PUT /api/settings/api-key,与其他字段分开传输</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label>默认资源 ID</label>
|
||||
<input v-model="settingsForm.default_resource_id" placeholder="seed-icl-2.0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>默认音色 (name)</label>
|
||||
<select v-model="settingsForm.default_speaker">
|
||||
<option value="">— 选择 —</option>
|
||||
<option v-for="v in voices" :key="v.name" :value="v.name">{{ v.name }}</option>
|
||||
</select>
|
||||
<div class="hint">必须是「音色」tab 里存在的 name</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>默认输出格式</label>
|
||||
<select v-model="settingsForm.default_format">
|
||||
<option value="mp3">mp3</option>
|
||||
<option value="wav">wav</option>
|
||||
<option value="opus">opus (ogg_opus)</option>
|
||||
<option value="pcm">pcm</option>
|
||||
<option value="aac">aac</option>
|
||||
<option value="flac">flac</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>采样率 (Hz)</label>
|
||||
<input type="number" v-model.number="settingsForm.sample_rate" min="8000" max="48000" step="1000">
|
||||
</div>
|
||||
<div class="field" style="grid-column: 1 / -1;">
|
||||
<label>Model (可选)</label>
|
||||
<input v-model="settingsForm.model" placeholder="seed-tts-2.0-standard">
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" @click="resetSettingsForm">重置</button>
|
||||
<button class="btn btn-primary" @click="saveSettings" :disabled="savingSettings">{{ savingSettings ? '保存中...' : '保存设置' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">跨域 CORS</div>
|
||||
<div class="card-sub">控制哪些前端域名能跨域调 <code>/v1/audio/speech</code></div>
|
||||
</div>
|
||||
<span class="pill" :class="settings.cors_configured ? 'pill-success' : 'pill-mute'">
|
||||
<span class="pill-dot"></span>
|
||||
{{ settings.cors_configured ? '已配置' : '未配置' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="check">
|
||||
<input type="checkbox" v-model="corsForm.allow_all">
|
||||
<span>允许所有来源 (<code>*</code>)</span>
|
||||
</label>
|
||||
<div class="hint">勾上后下面白名单失效。仅测试用</div>
|
||||
</div>
|
||||
<div class="field" v-if="!corsForm.allow_all">
|
||||
<label>允许的来源</label>
|
||||
<textarea v-model="corsForm.origins" rows="4" placeholder="https://app.example.com https://admin.example.com" class="textarea"></textarea>
|
||||
<div class="hint">一行一个。当前: <span class="text-accent mono">{{ corsCurrentLabel }}</span></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="saveCors" :disabled="savingCors">{{ savingCors ? '保存中...' : '保存 CORS' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/admin/admin-shell.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, computed, onMounted } = Vue;
|
||||
const { http, getKey, setKey, clearKey, toast, mountShell } = window.admin;
|
||||
createApp({
|
||||
setup() {
|
||||
const overview = ref({});
|
||||
const voices = ref([]);
|
||||
const settings = ref({});
|
||||
const settingsForm = ref({ default_resource_id: '', default_speaker: '', default_format: 'mp3', sample_rate: 24000, model: '' });
|
||||
const apiKeyInput = ref('');
|
||||
const authKeyInput = ref('');
|
||||
|
||||
const corsForm = ref({ allow_all: false, origins: '' });
|
||||
const savingCors = ref(false);
|
||||
const corsCurrentLabel = computed(() => {
|
||||
if (!settings.value) return '未配置';
|
||||
if (settings.value.cors_allow_all) return '允许所有来源(*)';
|
||||
if (settings.value.cors_origins) return settings.value.cors_origins;
|
||||
return '未配置(同源可用,跨域会被 403)';
|
||||
});
|
||||
|
||||
const savingSettings = ref(false);
|
||||
|
||||
const loadSettings = async () => {
|
||||
const r = await http.get('/settings');
|
||||
settings.value = r.data;
|
||||
settingsForm.value = {
|
||||
default_resource_id: r.data.default_resource_id || '',
|
||||
default_speaker: r.data.default_speaker || '',
|
||||
default_format: r.data.default_format || 'mp3',
|
||||
sample_rate: r.data.sample_rate || 24000,
|
||||
model: r.data.model || '',
|
||||
};
|
||||
corsForm.value = {
|
||||
allow_all: !!r.data.cors_allow_all,
|
||||
origins: r.data.cors_origins || '',
|
||||
};
|
||||
};
|
||||
const loadVoices = async () => {
|
||||
const r = await http.get('/voices');
|
||||
voices.value = r.data.voices || [];
|
||||
};
|
||||
const loadOverview = async () => {
|
||||
overview.value = (await http.get('/admin/overview')).data;
|
||||
};
|
||||
const reloadAll = () => { loadSettings(); loadVoices(); loadOverview(); };
|
||||
|
||||
const saveCors = async () => {
|
||||
savingCors.value = true;
|
||||
try {
|
||||
await http.put('/settings/cors', { allow_all: corsForm.value.allow_all, origins: corsForm.value.origins });
|
||||
await loadSettings();
|
||||
toast('ok', 'CORS 配置已保存');
|
||||
} catch (e) {
|
||||
toast('err', '保存 CORS 失败: ' + (e.response?.data?.error?.message || e.message));
|
||||
} finally { savingCors.value = false; }
|
||||
};
|
||||
const saveSettings = async () => {
|
||||
savingSettings.value = true;
|
||||
try {
|
||||
await http.put('/settings', {
|
||||
default_resource_id: settingsForm.value.default_resource_id,
|
||||
default_speaker: settingsForm.value.default_speaker,
|
||||
default_format: settingsForm.value.default_format,
|
||||
sample_rate: settingsForm.value.sample_rate,
|
||||
model: settingsForm.value.model,
|
||||
});
|
||||
await loadSettings();
|
||||
await loadOverview();
|
||||
toast('ok', '设置已保存');
|
||||
} catch (e) {
|
||||
toast('err', '保存失败: ' + (e.response?.data?.error?.message || e.message));
|
||||
} finally { savingSettings.value = false; }
|
||||
};
|
||||
const saveApiKey = async () => {
|
||||
if (!apiKeyInput.value) return;
|
||||
try {
|
||||
await http.put('/settings/api-key', { api_key: apiKeyInput.value });
|
||||
apiKeyInput.value = '';
|
||||
await loadSettings();
|
||||
toast('ok', 'API Key 已更新');
|
||||
} catch (e) {
|
||||
toast('err', '更新失败: ' + (e.response?.data?.error?.message || e.message));
|
||||
}
|
||||
};
|
||||
const saveAuthKey = async () => {
|
||||
if (!authKeyInput.value) return;
|
||||
try {
|
||||
await http.put('/settings/auth-key', { auth_key: authKeyInput.value });
|
||||
authKeyInput.value = '';
|
||||
setTimeout(() => {
|
||||
clearKey();
|
||||
location.href = '/admin/login';
|
||||
}, 200);
|
||||
toast('ok', 'Auth Key 已更新,请用新 Key 重新登录');
|
||||
} catch (e) {
|
||||
toast('err', '更新失败: ' + (e.response?.data?.error?.message || e.message));
|
||||
}
|
||||
};
|
||||
const resetSettingsForm = () => { loadSettings(); };
|
||||
|
||||
onMounted(() => {
|
||||
if (!getKey()) { location.href = '/admin/login'; return; }
|
||||
mountShell({
|
||||
activeNav: 'settings',
|
||||
overview: overview.value,
|
||||
corsConfigured: !!(settings.value && settings.value.cors_configured),
|
||||
onReload: reloadAll,
|
||||
});
|
||||
reloadAll();
|
||||
});
|
||||
|
||||
return {
|
||||
overview, voices, settings, settingsForm, apiKeyInput, authKeyInput,
|
||||
savingSettings, savingCors,
|
||||
corsForm, corsCurrentLabel, saveCors,
|
||||
saveSettings, saveApiKey, saveAuthKey, resetSettingsForm,
|
||||
reloadAll,
|
||||
};
|
||||
},
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user