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

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>/dashboard</code> 的密码。装完可在 <code>/dashboard → 设置</code> 改(可另配独立 <code>admin_key</code> 用于后台登录,避免与业务 Key 共用)。</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>/dashboard → 设置</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="seed-icl-2.0">
<div class="hint">音色所属的计费资源 ID;本项目为豆包声音复刻大模型 2.0,固定使用 <code>seed-icl-2.0</code>。</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_ 开头的为复刻音色)。可后续在 /dashboard 中增删。</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="seed-icl-2.0">
<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>/dashboard</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: 'seed-icl-2.0',
default_speaker: '',
default_format: 'mp3',
sample_rate: 24000,
token: '',
voices: [{ name: '', speaker: '', resource_id: 'seed-icl-2.0', model: 'seed-tts-2.0-standard' }],
});
const errorMsg = ref('');
const submitting = ref(false);
const addVoice = () => {
form.value.voices.push({ name: '', speaker: '', resource_id: 'seed-icl-2.0', 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 || '/dashboard';
} 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>