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)
This commit is contained in:
@@ -32,3 +32,9 @@ tts.db
|
||||
tts.db-*
|
||||
tts.db.*
|
||||
installed.lock
|
||||
|
||||
# UI handoff bundle (for external AI redesign work; not deployed)
|
||||
ui-handoff/
|
||||
|
||||
# Docs (本地草稿,不入库)
|
||||
docs/
|
||||
|
||||
+736
-236
File diff suppressed because it is too large
Load Diff
+245
-63
@@ -8,96 +8,197 @@
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0f1a;
|
||||
--bg: #0a0e1a;
|
||||
--bg-card: #131826;
|
||||
--bg-elev: #1a2138;
|
||||
--bg-hover: #1f2740;
|
||||
--border: #232b42;
|
||||
--border-strong: #2e3754;
|
||||
--text: #e4e7ee;
|
||||
--text-dim: #8a92a6;
|
||||
--text-mute: #5a6275;
|
||||
--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: 32px 24px; font-size: 14px;
|
||||
min-height: 100vh; padding: 40px 24px; font-size: 14px; line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0,212,255,0.08), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(123,47,247,0.06), transparent);
|
||||
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: 48px; height: 48px; border-radius: 12px;
|
||||
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 4px 20px rgba(0,212,255,0.3);
|
||||
font-weight: 700; font-size: 20px; color: #fff;
|
||||
box-shadow: 0 6px 24px var(--accent-glow);
|
||||
}
|
||||
.header h1 { font-size: 22px; font-weight: 600; }
|
||||
.header .sub { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
|
||||
.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; }
|
||||
|
||||
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin-bottom: 16px; }
|
||||
.card-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
||||
.card-desc { font-size: 12px; color: var(--text-dim); margin-bottom: 16px; }
|
||||
/* === 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; }
|
||||
|
||||
.field { margin-bottom: 14px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; }
|
||||
.field label .req { color: var(--danger); margin-left: 4px; }
|
||||
/* === 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: 8px; font-size: 13px;
|
||||
font-family: inherit; transition: border-color 0.2s;
|
||||
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); }
|
||||
.field .hint { font-size: 11px; color: var(--text-mute); margin-top: 4px; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.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-row { display: grid; grid-template-columns: 1fr 1fr 1.4fr 1.2fr 32px; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.voice-row input { font-size: 12px; padding: 8px 10px; }
|
||||
.voice-row .rm { background: transparent; border: 1px solid var(--border); color: var(--danger); cursor: pointer; padding: 6px; border-radius: 6px; font-size: 14px; }
|
||||
.voice-row .rm:hover { border-color: var(--danger); }
|
||||
/* === 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: 8px; cursor: pointer; font-size: 13px;
|
||||
transition: all 0.2s; display: inline-flex; align-items: center; gap: 6px;
|
||||
font-family: inherit;
|
||||
padding: 10px 18px; border-radius: var(--radius); cursor: pointer; font-size: 13px; font-weight: 500;
|
||||
font-family: inherit; transition: all 0.12s;
|
||||
}
|
||||
.btn:hover { border-color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); color: #00121a; border-color: var(--accent); font-weight: 500; }
|
||||
.btn-primary:hover { background: #33ddff; }
|
||||
.btn-primary:disabled { background: var(--text-mute); border-color: var(--text-mute); cursor: not-allowed; color: var(--bg); }
|
||||
.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 { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-add:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); border-style: solid; }
|
||||
|
||||
.actions { display: flex; justify-content: space-between; align-items: center; margin-top: 24px; }
|
||||
.err { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||||
.ok { color: var(--success); font-size: 12px; margin-top: 8px; }
|
||||
/* === 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; }
|
||||
|
||||
.banner { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); border-radius: 8px; padding: 10px 14px; color: var(--warning); font-size: 12px; margin-bottom: 16px; }
|
||||
/* === 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>
|
||||
<div class="header-text">
|
||||
<h1>火山 TTS 聚合平台 · 首次安装</h1>
|
||||
<div class="sub">填写以下信息完成初始化。token 来自启动日志或环境变量 <code>TTS_ADMIN_KEY</code>。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="banner">⚠ {{ errorMsg }}</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 class="card">
|
||||
<div class="card-title">OpenAI 鉴权 + 火山引擎 TTS 凭证</div>
|
||||
<div class="card-desc">从控制台/面板获取。两个 Key 仅在此页面输入,不会回显,装完不用再碰 env。</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">
|
||||
@@ -106,7 +207,18 @@
|
||||
<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">火山控制台</a> 获取。</div>
|
||||
<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">
|
||||
@@ -144,11 +256,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<!-- 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-desc">填入火山 TTS 音色 ID(S_ 开头的为复刻音色)。可后续在 /admin 中增删。</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.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">
|
||||
@@ -157,20 +282,61 @@
|
||||
<button class="btn btn-add" @click="addVoice">+ 添加音色</button>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<div style="color: var(--text-mute); font-size: 12px;">
|
||||
提交后立即进入正常模式;后续管理请访问 <code>/admin</code>。
|
||||
<!-- 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>
|
||||
<button class="btn btn-primary" @click="submit" :disabled="submitting">
|
||||
<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: '',
|
||||
@@ -190,9 +356,34 @@
|
||||
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 () => {
|
||||
// 预填旧 env 变量(只填非空字段;api_key 永不预填,需手动输入)
|
||||
try {
|
||||
const r = await axios.get('/api/setup/prefill');
|
||||
const s = r.data.settings || {};
|
||||
@@ -207,14 +398,6 @@
|
||||
|
||||
const submit = async () => {
|
||||
errorMsg.value = '';
|
||||
if (!form.value.auth_key || !form.value.api_key || !form.value.default_resource_id || !form.value.default_speaker || !form.value.token) {
|
||||
errorMsg.value = '请填写所有必填项(OpenAI Key / 火山 API Key / 资源 ID / 默认音色 / Token)';
|
||||
return;
|
||||
}
|
||||
if (!form.value.voices.length || form.value.voices.some(v => !v.name || !v.speaker || !v.resource_id)) {
|
||||
errorMsg.value = '音色列表不完整(每条都需要 name + speaker + resource_id)';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const r = await axios.post('/api/setup', {
|
||||
@@ -229,16 +412,15 @@
|
||||
},
|
||||
voices: form.value.voices,
|
||||
});
|
||||
// 成功:跳 /admin(M2 才会有,先跳 /dashboard)
|
||||
window.location.href = r.data.redirect || '/dashboard';
|
||||
window.location.href = r.data.redirect || '/admin';
|
||||
} catch (e) {
|
||||
const msg = e.response?.data?.error?.message || e.message;
|
||||
errorMsg.value = `安装失败: ${msg}`;
|
||||
errorMsg.value = '安装失败: ' + msg;
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return { form, errorMsg, submitting, addVoice, removeVoice, submit };
|
||||
return { step, form, errorMsg, submitting, addVoice, removeVoice, next, prev, mask, submit };
|
||||
},
|
||||
}).mount('#app');
|
||||
</script>
|
||||
|
||||
+14
-10
@@ -10,17 +10,21 @@ import (
|
||||
)
|
||||
|
||||
// Voice 是一行音色记录;时间字段保持 ISO8601 字符串(SQLite TEXT 默认)。
|
||||
//
|
||||
// JSON tag 是为了前端(admin.html)能直接读取 — 之前没加 tag 时 Go 的
|
||||
// "Name" / "Speaker" 等大写字段会原样输出,前端用 v.name 拿到 undefined,
|
||||
// 整张表看起来"空"但其实有数据。补 tag 后前端能正常显示。
|
||||
type Voice struct {
|
||||
ID int64
|
||||
Name string
|
||||
Speaker string
|
||||
ResourceID string
|
||||
Model string
|
||||
Language string
|
||||
Description string
|
||||
Enabled bool
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Speaker string `json:"speaker"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
Model string `json:"model"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ErrDuplicate 表示 name 唯一冲突;controller 翻译为 409。
|
||||
|
||||
Reference in New Issue
Block a user