239 lines
12 KiB
HTML
239 lines
12 KiB
HTML
<!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: #0b0f1a;
|
|||
|
|
--bg-card: #131826;
|
|||
|
|
--bg-elev: #1a2138;
|
|||
|
|
--border: #232b42;
|
|||
|
|
--text: #e4e7ee;
|
|||
|
|
--text-dim: #8a92a6;
|
|||
|
|
--text-mute: #5a6275;
|
|||
|
|
--accent: #00d4ff;
|
|||
|
|
--accent-2: #7b2ff7;
|
|||
|
|
--success: #22c55e;
|
|||
|
|
--warning: #f59e0b;
|
|||
|
|
--danger: #ef4444;
|
|||
|
|
}
|
|||
|
|
* { 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;
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
#app { max-width: 760px; margin: 0 auto; }
|
|||
|
|
|
|||
|
|
.header { display: flex; align-items: center; gap: 16px; margin-bottom: 28px; }
|
|||
|
|
.logo {
|
|||
|
|
width: 48px; height: 48px; border-radius: 12px;
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
.header h1 { font-size: 22px; font-weight: 600; }
|
|||
|
|
.header .sub { font-size: 12px; 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; }
|
|||
|
|
|
|||
|
|
.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; }
|
|||
|
|
.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;
|
|||
|
|
}
|
|||
|
|
.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; }
|
|||
|
|
|
|||
|
|
.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); }
|
|||
|
|
|
|||
|
|
.btn {
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
.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-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); }
|
|||
|
|
|
|||
|
|
.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; }
|
|||
|
|
|
|||
|
|
.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; }
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div id="app">
|
|||
|
|
<div class="header">
|
|||
|
|
<div class="logo">TTS</div>
|
|||
|
|
<div>
|
|||
|
|
<h1>火山 TTS 聚合平台 · 首次安装</h1>
|
|||
|
|
<div class="sub">填写以下信息完成初始化。token 来自启动日志或环境变量 <code>TTS_ADMIN_KEY</code>。</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="errorMsg" class="banner">⚠ {{ errorMsg }}</div>
|
|||
|
|
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-title">火山引擎 TTS 凭证</div>
|
|||
|
|
<div class="card-desc">从火山引擎控制台获取,API Key 仅在此页面输入,不会回显。</div>
|
|||
|
|
<div class="field">
|
|||
|
|
<label>API Key <span class="req">*</span></label>
|
|||
|
|
<input type="password" v-model="form.api_key" placeholder="例如: 5b4d7c2a-..." autocomplete="off">
|
|||
|
|
</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>
|
|||
|
|
|
|||
|
|
<div class="card">
|
|||
|
|
<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 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>
|
|||
|
|
|
|||
|
|
<div class="actions">
|
|||
|
|
<div style="color: var(--text-mute); font-size: 12px;">
|
|||
|
|
提交后立即进入正常模式;后续管理请访问 <code>/admin</code>。
|
|||
|
|
</div>
|
|||
|
|
<button class="btn btn-primary" @click="submit" :disabled="submitting">
|
|||
|
|
{{ submitting ? '安装中...' : '完成安装' }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const { createApp, ref, onMounted } = Vue;
|
|||
|
|
createApp({
|
|||
|
|
setup() {
|
|||
|
|
const form = ref({
|
|||
|
|
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);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
// 预填旧 env 变量(只填非空字段;api_key 永不预填,需手动输入)
|
|||
|
|
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 = '';
|
|||
|
|
if (!form.value.api_key || !form.value.default_resource_id || !form.value.default_speaker || !form.value.token) {
|
|||
|
|
errorMsg.value = '请填写所有必填项(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', {
|
|||
|
|
token: form.value.token,
|
|||
|
|
settings: {
|
|||
|
|
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,
|
|||
|
|
});
|
|||
|
|
// 成功:跳 /admin(M2 才会有,先跳 /dashboard)
|
|||
|
|
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 { form, errorMsg, submitting, addVoice, removeVoice, submit };
|
|||
|
|
},
|
|||
|
|
}).mount('#app');
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|