Files
Volcano-Engine-TTS-UI/router/setup.html
T
sun d22abecf12 fix(ui): store.Voice 加 JSON tag,修「音色是空的」+ UI 整体换皮
本次 3 个改动:

1. **【BUG 修复】store.Voice 加 JSON tag** — 用户报「进入系统音色是空的」。
   实际:DB 里有数据,API 也返了。Bug 在 store.Voice 结构体没加 json tag,
   Go 序列化为大写("Name"/"Speaker"/"ResourceID"/"Enabled"),而所有
   admin.html 用 v.name/v.speaker/v.resource_id/v.enabled 拿,全是 undefined。
   表渲染了但每格空。修复后 API 返小写,前端能正常读。
   写 store/voices_test.go 加 TestVoice_JSONTags 防退化。

2. **【UI 换皮】Codex 改造 admin.html + setup.html**
   - admin.html: 31KB → 58.8KB (1075 行)
     加: 侧边栏 + 品牌 + 4 步导航、toast 通知系统、grid 卡片、
     搜索/筛选/状态分页、stat-card 带 pill、modal + 过渡动画、
     按钮大小/颜色变体、响应式字体缩放
   - setup.html: 13KB → 23KB (428 行)
     加: 4 步向导 + 进度条 (步骤 1: 上游凭证 / 2: 默认路由 /
     3: 音色列表 / 4: 确认)、语音行 hover、确认页打码展示
   - 功能完全保留: API 路径/字段/hash 路由/401 拦截都未改

3. **【小修】setup.html 装完跳转回退用 /admin 不是 /dashboard**
   Codex 改的版本回退写成 /dashboard,是回归。已改回 /admin。

测试:
- 已有 store/installer/middleware/router/telemetry 测试全绿
- 新增 store/voices_test.go: TestVoice_JSONTags + TestVoice_UnmarshalTags

E2E 验证(本机):
- install 2 voices → DB 写入 ✓
- GET /api/voices 返小写 id/name/speaker/resource_id/enabled 等 ✓
- PATCH /api/voices/qian/toggle → 200 + lowercase 回包 ✓
- POST /api/voices → 201 + lowercase 回包 ✓
- GET /api/voices 再次列 3 条(原 2 + 新 1)✓

未 push (等用户测试「音色终于显示出来」)

二进制大小: ~18MB → ~18.5MB (admin.html +27KB embed)
2026-08-30 00:51:23 +08:00

429 lines
23 KiB
HTML
Raw 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>
<style>
:root {
--bg: #0a0e1a;
--bg-card: #131826;
--bg-elev: #1a2138;
--bg-hover: #1f2740;
--border: #232b42;
--border-strong: #2e3754;
--text: #e4e7ee;
--text-dim: #94a3b8;
--text-mute: #64748b;
--accent: #00d4ff;
--accent-hover: #33ddff;
--accent-2: #7b2ff7;
--accent-soft: rgba(0, 212, 255, 0.10);
--accent-glow: rgba(0, 212, 255, 0.35);
--success: #22c55e;
--success-soft: rgba(34, 197, 94, 0.12);
--warning: #f59e0b;
--warning-soft: rgba(245, 158, 11, 0.12);
--danger: #ef4444;
--danger-soft: rgba(239, 68, 68, 0.12);
--radius: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow: 0 4px 16px rgba(0,0,0,0.25);
--shadow-lg: 0 20px 50px rgba(0,0,0,0.5);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--text);
min-height: 100vh; padding: 40px 24px; font-size: 14px; line-height: 1.5;
-webkit-font-smoothing: antialiased;
background-image:
radial-gradient(ellipse 70% 50% at 30% 0%, rgba(0,212,255,0.07), transparent),
radial-gradient(ellipse 50% 40% at 100% 100%, rgba(123,47,247,0.05), transparent);
background-attachment: fixed;
}
#app { max-width: 760px; margin: 0 auto; }
/* === Header === */
.header { display: flex; align-items: center; gap: 16px; margin-bottom: 28px; }
.logo {
width: 52px; height: 52px; border-radius: 14px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 20px; color: #fff;
box-shadow: 0 6px 24px var(--accent-glow);
}
.header-text h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
.header-text .sub { font-size: 13px; color: var(--text-dim); margin-top: 4px; }
/* === Steps === */
.steps { display: flex; align-items: center; gap: 0; margin-bottom: 28px; padding: 16px 20px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); }
.step { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.step-num {
width: 28px; height: 28px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 600; flex-shrink: 0;
background: var(--bg-elev); color: var(--text-mute);
border: 1px solid var(--border);
}
.step.active .step-num { background: var(--accent); color: #00121a; border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
.step.done .step-num { background: var(--success-soft); color: var(--success); border-color: rgba(34,197,94,0.3); }
.step-label { font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.step.active .step-label, .step.done .step-label { color: var(--text); }
.step-line { flex: 1; height: 1px; background: var(--border); margin: 0 12px; min-width: 16px; }
.step.done + .step-line { background: var(--accent); opacity: 0.4; }
/* === Card === */
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 16px; }
.card-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 18px; gap: 12px; }
.card-title { font-size: 16px; font-weight: 600; }
.card-sub { font-size: 12px; color: var(--text-dim); margin-top: 4px; line-height: 1.6; }
.step-badge { font-size: 11px; padding: 3px 8px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); border: 1px solid rgba(0,212,255,0.3); font-weight: 500; }
/* === Form === */
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; font-weight: 500; }
.field label .req { color: var(--danger); margin-left: 2px; }
.field input, .field select {
width: 100%; background: var(--bg-elev); border: 1px solid var(--border);
color: var(--text); padding: 10px 12px; border-radius: var(--radius); font-size: 13px;
font-family: inherit; transition: border-color 0.15s, box-shadow 0.15s;
}
.field input:focus, .field select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.field input::placeholder { color: var(--text-mute); }
.field select {
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path fill='%238a92a6' d='M6 8L1 3h10z'/></svg>");
background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px;
}
.field .hint { font-size: 11px; color: var(--text-mute); margin-top: 5px; line-height: 1.5; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.field-row .field { margin-bottom: 0; }
code { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.9em; background: var(--bg-elev); padding: 1px 5px; border-radius: 4px; color: var(--accent); }
/* === Voice list === */
.voice-row {
display: grid; grid-template-columns: 1fr 1fr 1.4fr 1.2fr 36px;
gap: 10px; align-items: center; margin-bottom: 8px;
}
.voice-row input { font-size: 12px; padding: 8px 10px; font-family: ui-monospace, SFMono-Regular, monospace; }
.voice-row .rm {
background: transparent; border: 1px solid var(--border);
color: var(--text-mute); cursor: pointer; padding: 7px 0;
border-radius: var(--radius); font-size: 15px;
transition: all 0.12s;
}
.voice-row .rm:hover:not(:disabled) { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
.voice-row .rm:disabled { opacity: 0.3; cursor: not-allowed; }
.voice-row-head {
display: grid; grid-template-columns: 1fr 1fr 1.4fr 1.2fr 36px;
gap: 10px; font-size: 11px; color: var(--text-mute);
text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600;
padding: 0 2px 6px;
}
/* === Buttons === */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
background: var(--bg-elev); border: 1px solid var(--border); color: var(--text);
padding: 10px 18px; border-radius: var(--radius); cursor: pointer; font-size: 13px; font-weight: 500;
font-family: inherit; transition: all 0.12s;
}
.btn:hover:not(:disabled) { border-color: var(--border-strong); background: var(--bg-hover); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: #00121a; border-color: var(--accent); }
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-add { background: transparent; border: 1px dashed var(--border); color: var(--text-dim); width: 100%; justify-content: center; padding: 10px; }
.btn-add:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); border-style: solid; }
/* === Actions === */
.actions { display: flex; justify-content: space-between; align-items: center; margin-top: 24px; padding: 20px 24px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); }
.actions .hint-text { color: var(--text-mute); font-size: 12px; }
/* === Alerts === */
.alert { display: flex; align-items: flex-start; gap: 10px; padding: 10px 14px; border-radius: var(--radius); margin-bottom: 16px; font-size: 13px; }
.alert-icon { font-size: 15px; flex-shrink: 0; }
.alert-body { flex: 1; }
.alert-danger { background: var(--danger-soft); border: 1px solid rgba(239,68,68,0.3); color: var(--danger); }
.alert-warning { background: var(--warning-soft); border: 1px solid rgba(245,158,11,0.3); color: var(--warning); }
</style>
</head>
<body>
<div id="app">
<div class="header">
<div class="logo">TTS</div>
<div class="header-text">
<h1>火山 TTS 聚合平台 · 首次安装</h1>
<div class="sub">填写以下信息完成初始化。token 来自启动日志或环境变量 <code>TTS_ADMIN_KEY</code>。</div>
</div>
</div>
<!-- Steps -->
<div class="steps">
<div class="step" :class="{active: step===1, done: step>1}">
<div class="step-num">1</div>
<div class="step-label">上游凭证</div>
</div>
<div class="step-line"></div>
<div class="step" :class="{active: step===2, done: step>2}">
<div class="step-num">2</div>
<div class="step-label">默认路由</div>
</div>
<div class="step-line"></div>
<div class="step" :class="{active: step===3, done: step>3}">
<div class="step-num">3</div>
<div class="step-label">音色列表</div>
</div>
<div class="step-line"></div>
<div class="step" :class="{active: step===4}">
<div class="step-num">4</div>
<div class="step-label">完成安装</div>
</div>
</div>
<div v-if="errorMsg" class="alert alert-danger">
<span class="alert-icon">⚠</span>
<div class="alert-body">{{ errorMsg }}</div>
</div>
<!-- Step 1: 上游凭证 -->
<div v-show="step===1" class="card">
<div class="card-head">
<div>
<div class="card-title">OpenAI 鉴权 + 火山引擎凭证</div>
<div class="card-sub">两个 Key 仅在此页面输入,不会回显,装完不用再碰 env。</div>
</div>
<span class="step-badge">第 1 步</span>
</div>
<div class="field">
<label>OpenAI 鉴权 Key <span class="req">*</span></label>
<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>
<div class="field">
<label>火山 TTS API Key <span class="req">*</span></label>
<input type="password" v-model="form.api_key" placeholder="例如: 5b4d7c2a-..." autocomplete="off">
<div class="hint">调火山 v3 上游用的凭证,从 <a href="https://console.volcengine.com/" target="_blank" style="color: var(--accent);">火山控制台</a> 获取。</div>
</div>
</div>
<!-- Step 2: 默认路由 -->
<div v-show="step===2" class="card">
<div class="card-head">
<div>
<div class="card-title">默认路由与格式</div>
<div class="card-sub">未传 voice/format 时使用。装完可在 <code>/admin → 设置</code> 改。</div>
</div>
<span class="step-badge">第 2 步</span>
</div>
<div class="field-row">
<div class="field">
<label>默认资源 ID <span class="req">*</span></label>
<input v-model="form.default_resource_id" placeholder="volc.megatts.default">
<div class="hint">音色所属的计费资源 ID;volc.megatts.icl 用于复刻音色。</div>
</div>
<div class="field">
<label>默认音色 <span class="req">*</span></label>
<input v-model="form.default_speaker" placeholder="zh_female_qingxin">
<div class="hint">未传 voice 字段时使用。</div>
</div>
</div>
<div class="field-row">
<div class="field">
<label>默认输出格式</label>
<select v-model="form.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="form.sample_rate" placeholder="24000">
</div>
</div>
<div class="field">
<label>初始化 Token <span class="req">*</span></label>
<input type="password" v-model="form.token" placeholder="启动日志里打印的一次性 token,或 TTS_ADMIN_KEY" autocomplete="off">
<div class="hint">公网部署建议设置 TTS_ADMIN_KEY;否则每次启动会重新生成。</div>
</div>
</div>
<!-- Step 3: 音色列表 -->
<div v-show="step===3" class="card">
<div class="card-head">
<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>
<span class="step-badge">第 3 步</span>
</div>
<div class="voice-row-head">
<div>对外名 (voice)</div>
<div>Speaker</div>
<div>资源 ID</div>
<div>Model</div>
<div></div>
</div>
<div v-for="(v, i) in form.voices" :key="i" class="voice-row">
<input v-model="v.name" placeholder="qian">
<input v-model="v.speaker" placeholder="S_xxx">
<input v-model="v.resource_id" placeholder="volc.megatts.icl">
<input v-model="v.model" placeholder="seed-tts-2.0-standard">
<button class="rm" @click="removeVoice(i)" :disabled="form.voices.length <= 1" title="删除">×</button>
</div>
<button class="btn btn-add" @click="addVoice">+ 添加音色</button>
</div>
<!-- Step 4: 确认 -->
<div v-show="step===4" class="card">
<div class="card-head">
<div>
<div class="card-title">确认安装</div>
<div class="card-sub">检查以下配置,点击「完成安装」提交。提交后立即进入正常模式。</div>
</div>
<span class="step-badge">第 4 步</span>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 14px;">
<div>
<div style="font-size: 11px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">OpenAI Key</div>
<div style="font-family: ui-monospace, monospace; color: var(--accent); font-size: 13px;">{{ form.auth_key ? mask(form.auth_key) : '(未填)' }}</div>
</div>
<div>
<div style="font-size: 11px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">火山 API Key</div>
<div style="font-family: ui-monospace, monospace; color: var(--accent); font-size: 13px;">{{ form.api_key ? mask(form.api_key) : '(未填)' }}</div>
</div>
<div>
<div style="font-size: 11px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">默认资源 ID</div>
<div style="font-family: ui-monospace, monospace; font-size: 13px;">{{ form.default_resource_id || '—' }}</div>
</div>
<div>
<div style="font-size: 11px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">默认音色</div>
<div style="font-family: ui-monospace, monospace; font-size: 13px;">{{ form.default_speaker || '—' }}</div>
</div>
<div>
<div style="font-size: 11px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">输出格式 / 采样率</div>
<div style="font-size: 13px;">{{ form.default_format }} · {{ form.sample_rate }} Hz</div>
</div>
<div>
<div style="font-size: 11px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">音色数</div>
<div style="font-size: 13px;">{{ form.voices.length }} 条 <span class="text-dim" style="font-size: 12px;">({{ form.voices.filter(v => v.name).length }} 已填名)</span></div>
</div>
</div>
</div>
<!-- Nav -->
<div class="actions">
<button class="btn" @click="prev" :disabled="step===1 || submitting">← 上一步</button>
<div class="hint-text">提交后立即进入正常模式;后续管理请访问 <code>/admin</code>。</div>
<div style="display: flex; gap: 8px;">
<button v-if="step < 4" class="btn btn-primary" @click="next">下一步 →</button>
<button v-else class="btn btn-primary" @click="submit" :disabled="submitting">
{{ submitting ? '安装中...' : '完成安装' }}
</button>
</div>
</div>
</div>
<script>
const { createApp, ref, onMounted } = Vue;
createApp({
setup() {
const step = ref(1);
const form = ref({
auth_key: '',
api_key: '',
default_resource_id: 'volc.megatts.default',
default_speaker: '',
default_format: 'mp3',
sample_rate: 24000,
token: '',
voices: [{ name: '', speaker: '', resource_id: 'volc.megatts.icl', model: 'seed-tts-2.0-standard' }],
});
const errorMsg = ref('');
const submitting = ref(false);
const addVoice = () => {
form.value.voices.push({ name: '', speaker: '', resource_id: 'volc.megatts.icl', model: 'seed-tts-2.0-standard' });
};
const removeVoice = (i) => {
if (form.value.voices.length > 1) form.value.voices.splice(i, 1);
};
const next = () => {
errorMsg.value = '';
if (step.value === 1) {
if (!form.value.auth_key || !form.value.api_key) {
errorMsg.value = '请填写 OpenAI 鉴权 Key 和火山 API Key';
return;
}
} else if (step.value === 2) {
if (!form.value.default_resource_id || !form.value.default_speaker || !form.value.token) {
errorMsg.value = '请填写默认资源 ID、默认音色和初始化 Token';
return;
}
} else if (step.value === 3) {
if (!form.value.voices.length || form.value.voices.some(v => !v.name || !v.speaker || !v.resource_id)) {
errorMsg.value = '音色列表不完整(每条都需要 name + speaker + resource_id)';
return;
}
}
step.value = Math.min(4, step.value + 1);
};
const prev = () => { errorMsg.value = ''; step.value = Math.max(1, step.value - 1); };
const mask = (s) => {
if (!s) return '';
if (s.length <= 8) return '****';
return s.slice(0, 4) + '****' + s.slice(-4);
};
onMounted(async () => {
try {
const r = await axios.get('/api/setup/prefill');
const s = r.data.settings || {};
if (s.default_resource_id) form.value.default_resource_id = s.default_resource_id;
if (s.default_speaker) form.value.default_speaker = s.default_speaker;
if (s.default_format) form.value.default_format = s.default_format;
if (s.sample_rate) form.value.sample_rate = parseInt(s.sample_rate, 10) || 24000;
} catch (e) {
console.log('prefill skipped:', e.message);
}
});
const submit = async () => {
errorMsg.value = '';
submitting.value = true;
try {
const r = await axios.post('/api/setup', {
token: form.value.token,
settings: {
auth_key: form.value.auth_key,
api_key: form.value.api_key,
default_resource_id: form.value.default_resource_id,
default_speaker: form.value.default_speaker,
default_format: form.value.default_format,
sample_rate: String(form.value.sample_rate || 24000),
},
voices: form.value.voices,
});
window.location.href = r.data.redirect || '/admin';
} catch (e) {
const msg = e.response?.data?.error?.message || e.message;
errorMsg.value = '安装失败: ' + msg;
submitting.value = false;
}
};
return { step, form, errorMsg, submitting, addVoice, removeVoice, next, prev, mask, submit };
},
}).mount('#app');
</script>
</body>
</html>