Files
Volcano-Engine-TTS-UI/router/admin-voices.html
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

261 lines
13 KiB
HTML
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.
<!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">管理火山 TTS 音色及其启用状态</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" @click="openAdd">+ 新增音色</button>
</div>
</header>
<div class="toolbar">
<div class="toolbar-left">
<div class="search">
<span class="search-icon">⌕</span>
<input v-model="voiceSearch" placeholder="搜索名称、Speaker、资源 ID...">
</div>
<div class="seg">
<button class="seg-btn" :class="{active: voiceFilter===null}" @click="voiceFilter=null">全部</button>
<button class="seg-btn" :class="{active: voiceFilter==='on'}" @click="voiceFilter='on'">启用</button>
<button class="seg-btn" :class="{active: voiceFilter==='off'}" @click="voiceFilter='off'">停用</button>
</div>
</div>
<div class="toolbar-right">
<span v-if="voiceSearch || voiceFilter !== null">{{ filteredVoices.length }} / {{ voices.length }}</span>
<span v-else>共 {{ voices.length }} 条</span>
</div>
</div>
<div v-if="actionErr" class="alert alert-danger">
<span class="alert-icon">⚠</span>
<div class="alert-body">
<div class="alert-title">操作失败</div>
<div class="alert-desc">{{ actionErr }}</div>
</div>
</div>
<div class="data-card">
<table class="data-table" v-if="filteredVoices.length">
<thead>
<tr>
<th>名称</th>
<th>Speaker</th>
<th>资源 ID</th>
<th>Model</th>
<th>状态</th>
<th class="th-actions">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="v in filteredVoices" :key="v.name">
<td>
<div class="cell-name">{{ v.name }}</div>
<div v-if="v.description" class="cell-sub">{{ v.description }}</div>
</td>
<td><span class="mono text-dim">{{ v.speaker }}</span></td>
<td><span class="mono text-dim">{{ v.resource_id }}</span></td>
<td><span class="mono text-dim">{{ v.model || '—' }}</span></td>
<td>
<span class="pill" :class="v.enabled ? 'pill-success' : 'pill-mute'">
<span class="pill-dot"></span>
{{ v.enabled ? '启用' : '停用' }}
</span>
</td>
<td class="th-actions">
<label class="switch" :title="v.enabled ? '点击停用' : '点击启用'" style="margin-right: 12px;">
<input type="checkbox" :checked="v.enabled" @change="toggle(v)">
<span class="switch-track"></span>
</label>
<button class="btn btn-sm btn-danger-ghost" @click="remove(v)">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-else-if="voices.length === 0" class="empty">
<div class="empty-icon">♪</div>
<div class="empty-title">还没有音色</div>
<div class="empty-desc">点击右上角「+ 新增音色」开始添加第一个火山 TTS 音色</div>
<button class="btn btn-primary" @click="openAdd">+ 新增音色</button>
</div>
<div v-else class="empty">
<div class="empty-icon">⌕</div>
<div class="empty-title">没有匹配的音色</div>
<div class="empty-desc">尝试调整搜索词或筛选条件</div>
<button class="btn" @click="voiceSearch=''; voiceFilter=null">清除筛选</button>
</div>
</div>
</main>
</div>
<!-- Add modal -->
<teleport to="body">
<transition name="modal">
<div v-if="showAdd" class="modal-backdrop" @click.self="showAdd=false">
<div class="modal" @click.stop>
<div class="modal-head">
<h2>新增音色</h2>
<button class="btn-icon" @click="showAdd=false" title="关闭" style="flex: 0; padding: 4px 10px;">×</button>
</div>
<div class="modal-body">
<div class="grid-2">
<div class="field">
<label>对外名 (voice) <span class="req">*</span></label>
<input v-model="form.name" placeholder="qian" class="mono">
</div>
<div class="field">
<label>Speaker (火山音色 ID) <span class="req">*</span></label>
<input v-model="form.speaker" placeholder="S_xxx" class="mono">
</div>
<div class="field">
<label>资源 ID <span class="req">*</span></label>
<input v-model="form.resource_id" placeholder="seed-icl-2.0" class="mono">
</div>
<div class="field">
<label>Model</label>
<input v-model="form.model" placeholder="seed-tts-2.0-standard" class="mono">
</div>
<div class="field">
<label>Language</label>
<input v-model="form.language" placeholder="zh" class="mono">
</div>
<div class="field">
<label>Description</label>
<input v-model="form.description" placeholder="可选">
</div>
</div>
<div v-if="addErr" class="alert alert-danger" style="margin-top: 14px;">
<span class="alert-icon">⚠</span>
<div class="alert-body">
<div class="alert-title">提交失败</div>
<div class="alert-desc">{{ addErr }}</div>
</div>
</div>
</div>
<div class="modal-foot">
<button class="btn" @click="showAdd=false">取消</button>
<button class="btn btn-primary" @click="submitAdd" :disabled="adding">{{ adding ? '提交中...' : '提交' }}</button>
</div>
</div>
</div>
</transition>
</teleport>
</div>
<script src="/admin/admin-shell.js"></script>
<script>
const { createApp, ref, computed, onMounted } = Vue;
const { http, getKey, toast, mountShell } = window.admin;
createApp({
setup() {
const overview = ref({});
const voices = ref([]);
const actionErr = ref('');
const voiceSearch = ref('');
const voiceFilter = ref(null);
const filteredVoices = computed(() => {
const q = voiceSearch.value.trim().toLowerCase();
return voices.value.filter(v => {
if (voiceFilter.value === 'on' && !v.enabled) return false;
if (voiceFilter.value === 'off' && v.enabled) return false;
if (!q) return true;
return (v.name || '').toLowerCase().includes(q)
|| (v.speaker || '').toLowerCase().includes(q)
|| (v.resource_id || '').toLowerCase().includes(q);
});
});
const showAdd = ref(false);
const adding = ref(false);
const addErr = ref('');
const form = ref({ name: '', speaker: '', resource_id: 'seed-icl-2.0', model: 'seed-tts-2.0-standard', language: '', description: '' });
const loadOverview = async () => {
try { overview.value = (await http.get('/admin/overview')).data; }
catch (e) { actionErr.value = '加载概览失败: ' + e.message; }
};
const loadVoices = async () => {
try { const r = await http.get('/voices'); voices.value = r.data.voices || []; }
catch (e) { actionErr.value = '加载音色失败: ' + e.message; }
};
const reloadAll = () => { loadOverview(); loadVoices(); };
const openAdd = () => {
form.value = { name: '', speaker: '', resource_id: 'seed-icl-2.0', model: 'seed-tts-2.0-standard', language: '', description: '' };
addErr.value = '';
showAdd.value = true;
};
const submitAdd = async () => {
addErr.value = '';
if (!form.value.name || !form.value.speaker || !form.value.resource_id) {
addErr.value = '请填写名称、Speaker、资源 ID';
return;
}
adding.value = true;
try {
await http.post('/voices', form.value);
showAdd.value = false;
await loadVoices();
await loadOverview();
toast('ok', '音色已添加');
} catch (e) {
addErr.value = e.response?.data?.error?.message || e.message;
} finally { adding.value = false; }
};
const toggle = async (v) => {
try { await http.patch(`/voices/${v.name}/toggle`, { enabled: !v.enabled }); await loadVoices(); }
catch (e) {
actionErr.value = '切换失败: ' + (e.response?.data?.error?.message || e.message);
toast('err', actionErr.value);
}
};
const remove = async (v) => {
if (!confirm(`确定删除音色 "${v.name}" 吗?`)) return;
try { await http.delete(`/voices/${v.name}`); await loadVoices(); await loadOverview(); toast('ok', '音色已删除'); }
catch (e) {
actionErr.value = '删除失败: ' + (e.response?.data?.error?.message || e.message);
toast('err', actionErr.value);
}
};
onMounted(() => {
if (!getKey()) { location.href = '/admin/login'; return; }
mountShell({
activeNav: 'voices',
overview: overview.value,
voiceCount: voices.value.length,
onReload: reloadAll,
});
reloadAll();
});
return {
overview, voices, actionErr,
voiceSearch, voiceFilter, filteredVoices,
showAdd, form, addErr, adding, openAdd, submitAdd, toggle, remove,
reloadAll,
};
},
}).mount('#app');
</script>
</body>
</html>