refactor: 精简架构,去掉书籍管理,核心 TTS 代理

- 去掉 books/chapters CRUD、SQLAlchemy、SQLite 依赖
- 核心只剩 /api/tts + 智能分段 + 自动重试
- 新增 API_TOKEN 环境变量,管理接口 Bearer Token 鉴权
- 管理接口精简为 preview + config
- 前端重写:TTS 试听 + 配置查看 + 接口文档
- Dockerfile/docker-compose 清理,去掉数据库卷
This commit is contained in:
sunruiling
2026-03-27 15:10:58 +08:00
parent 30544f7f42
commit 2a87020b48
11 changed files with 381 additions and 1503 deletions

View File

@@ -3,485 +3,217 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TTS Book Service</title>
<title>TTS Proxy Service</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0f1117;--card:#1a1d27;--border:#2a2d3a;--primary:#6c5ce7;--primary-hover:#7c6df7;--success:#00b894;--error:#ff6b6b;--warn:#fdcb6e;--text:#e8e8e8;--text-dim:#8b8fa3;--text-bright:#fff}
:root{--bg:#0f1117;--card:#1a1d27;--border:#2a2d3a;--primary:#6c5ce7;--primary-hover:#7c6df7;--success:#00b894;--error:#ff6b6b;--warn:#fdcb6e;--text:#e8e8e8;--dim:#8b8fa3;--bright:#fff}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
.container{max-width:1200px;margin:0 auto;padding:20px}
h1{font-size:1.6rem;font-weight:700;margin-bottom:8px;background:linear-gradient(135deg,var(--primary),#a29bfe);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.subtitle{color:var(--text-dim);font-size:.85rem;margin-bottom:24px}
/* Tabs */
.tabs{display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:0}
.tab{padding:10px 20px;cursor:pointer;color:var(--text-dim);font-size:.9rem;border:none;background:none;transition:all .2s;position:relative;border-bottom:2px solid transparent}
.tab:hover{color:var(--text)}
.tab.active{color:var(--primary);border-bottom-color:var(--primary)}
.tab-panel{display:none}
.tab-panel.active{display:block}
/* Cards */
.c{max-width:800px;margin:0 auto;padding:24px}
h1{font-size:1.5rem;font-weight:700;margin-bottom:6px;background:linear-gradient(135deg,var(--primary),#a29bfe);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.sub{color:var(--dim);font-size:.82rem;margin-bottom:28px}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px;margin-bottom:16px}
.card-title{font-size:1.1rem;font-weight:600;margin-bottom:16px;display:flex;align-items:center;gap:8px}
/* Forms */
.form-group{margin-bottom:14px}
.form-group label{display:block;font-size:.8rem;color:var(--text-dim);margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
input,textarea,select{width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:.9rem;outline:none;transition:border .2s}
input:focus,textarea:focus,select:focus{border-color:var(--primary)}
textarea{resize:vertical;min-height:100px;font-family:inherit}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
/* Buttons */
.btn{padding:8px 18px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem;font-weight:500;transition:all .2s;display:inline-flex;align-items:center;gap:6px}
.btn-primary{background:var(--primary);color:#fff}
.btn-primary:hover{background:var(--primary-hover);transform:translateY(-1px)}
.btn-success{background:var(--success);color:#fff}
.btn-success:hover{opacity:.9}
.btn-danger{background:var(--error);color:#fff}
.btn-danger:hover{opacity:.9}
.btn-sm{padding:5px 12px;font-size:.78rem}
.card-t{font-size:1rem;font-weight:600;margin-bottom:14px;display:flex;align-items:center;gap:8px}
.fg{margin-bottom:12px}
.fg label{display:block;font-size:.78rem;color:var(--dim);margin-bottom:5px;text-transform:uppercase;letter-spacing:.5px}
input,textarea{width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:.88rem;outline:none;transition:border .2s}
input:focus,textarea:focus{border-color:var(--primary)}
textarea{resize:vertical;min-height:120px;font-family:inherit}
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.btn{padding:9px 20px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem;font-weight:500;transition:all .2s;display:inline-flex;align-items:center;gap:6px}
.btn-p{background:var(--primary);color:#fff}.btn-p:hover{background:var(--primary-hover);transform:translateY(-1px)}
.btn:disabled{opacity:.5;cursor:not-allowed}
/* Table */
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse}
th{text-align:left;padding:10px 12px;font-size:.75rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border)}
td{padding:10px 12px;border-bottom:1px solid var(--border);font-size:.85rem;vertical-align:middle}
tr:hover{background:rgba(108,92,231,.05)}
/* Status badges */
.badge{display:inline-block;padding:3px 10px;border-radius:20px;font-size:.72rem;font-weight:600;text-transform:uppercase}
.badge-ready{background:rgba(0,184,148,.15);color:var(--success)}
.badge-pending{background:rgba(253,203,110,.15);color:var(--warn)}
.badge-generating{background:rgba(108,92,231,.15);color:var(--primary);animation:pulse 1.5s infinite}
.badge-error{background:rgba(255,107,107,.15);color:var(--error)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
/* Modal */
.modal-overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.6);z-index:100;justify-content:center;align-items:center}
.modal-overlay.show{display:flex}
.modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;width:90%;max-width:700px;max-height:80vh;overflow-y:auto}
.modal-title{font-size:1.1rem;font-weight:600;margin-bottom:16px}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
/* Toast */
.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:10px;font-size:.85rem;z-index:200;animation:slideIn .3s ease;max-width:400px}
.toast-success{background:var(--success);color:#fff}
.toast-error{background:var(--error);color:#fff}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
/* Preview */
.preview-box{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:16px;margin-top:12px}
audio{width:100%;margin-top:8px}
.text-dim{color:var(--text-dim)}
.flex{display:flex;gap:8px;align-items:center}
.flex-between{display:flex;justify-content:space-between;align-items:center}
.preview{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px;margin-top:12px}
audio{width:100%;margin-top:6px}
.dim{color:var(--dim)}
.mt-2{margin-top:8px}
.mt-4{margin-top:16px}
.mb-2{margin-bottom:8px}
/* Scrollable text preview */
.text-preview{max-height:120px;overflow-y:auto;font-size:.82rem;color:var(--text-dim);line-height:1.5;white-space:pre-wrap;word-break:break-all}
.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:10px;font-size:.85rem;z-index:200;animation:slideIn .3s ease;max-width:380px}
.toast-ok{background:var(--success);color:#fff}.toast-err{background:var(--error);color:#fff}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
table{width:100%;border-collapse:collapse}
th{text-align:left;padding:8px 10px;font-size:.73rem;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border)}
td{padding:8px 10px;border-bottom:1px solid var(--border);font-size:.84rem}
.code{font-family:'SF Mono',Menlo,monospace;font-size:.8rem;color:var(--primary)}
.tabs{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid var(--border)}
.tab{padding:10px 18px;cursor:pointer;color:var(--dim);font-size:.88rem;border:none;background:none;transition:all .2s;border-bottom:2px solid transparent}
.tab:hover{color:var(--text)}.tab.on{color:var(--primary);border-bottom-color:var(--primary)}
.panel{display:none}.panel.on{display:block}
.hint{font-size:.78rem;color:var(--dim);margin-top:6px;line-height:1.5}
</style>
</head>
<body>
<div class="container">
<h1>📚 TTS Book Service</h1>
<p class="subtitle">小米 MiMo TTS 听书音频转换服务</p>
<div class="c">
<h1>🎙️ TTS Proxy Service</h1>
<p class="sub">小米 MiMo TTS 代理服务 · 智能分段 · 自动重试</p>
<div class="tabs">
<button class="tab active" onclick="switchTab('books')">📖 书籍管理</button>
<button class="tab" onclick="switchTab('preview')">🎙 TTS 试听</button>
<button class="tab" onclick="switchTab('settings')">⚙️ 配置</button>
<button class="tab on" onclick="sw('tts',this)">🎙️ TTS 试听</button>
<button class="tab" onclick="sw('cfg',this)"> 配置</button>
<button class="tab" onclick="sw('api',this)">📖 接口说明</button>
</div>
<!-- Tab: Books -->
<div id="tab-books" class="tab-panel active">
<!-- TTS 试听 -->
<div id="p-tts" class="panel on">
<div class="card">
<div class="flex-between">
<div class="card-title" style="margin-bottom:0">📖 书籍列表</div>
<button class="btn btn-primary" onclick="showAddBook()">+ 添加书籍</button>
</div>
</div>
<div id="book-list" class="card">
<p class="text-dim">加载中...</p>
</div>
</div>
<!-- Tab: Preview -->
<div id="tab-preview" class="tab-panel">
<div class="card">
<div class="card-title">🎙️ TTS 试听</div>
<div class="form-row">
<div class="form-group">
<label>说话风格(可选)</label>
<input id="preview-style" placeholder="如:开心、语速慢、东北话、像个大将军...">
<div class="card-t">🎙️ TTS 试听</div>
<div class="row">
<div class="fg">
<label>音色(可选,留空用默认)</label>
<input id="pv-voice" placeholder="mimo_default">
</div>
<div class="form-group">
<label>音色(可选)</label>
<input id="preview-voice" placeholder="留空使用默认音色 mimo_default">
<div class="fg">
<label>风格(可选)</label>
<input id="pv-style" placeholder="开心、语速慢、东北话...">
</div>
</div>
<div class="form-group">
<div class="fg">
<label>文本内容</label>
<textarea id="preview-text" rows="4" placeholder="输入要合成的文本..."></textarea>
<textarea id="pv-text" rows="5" placeholder="输入要合成的文本...长文本自动分段生成"></textarea>
</div>
<button class="btn btn-primary" onclick="doPreview()" id="preview-btn">🔊 生成试听</button>
<div id="preview-result" class="preview-box" style="display:none">
<audio id="preview-audio" controls></audio>
<button class="btn btn-p" onclick="preview()" id="pv-btn">🔊 生成试听</button>
<div id="pv-result" class="preview" style="display:none">
<audio id="pv-audio" controls></audio>
<p class="dim mt-2" id="pv-info"></p>
</div>
</div>
</div>
<!-- Tab: Settings -->
<div id="tab-settings" class="tab-panel">
<!-- 配置 -->
<div id="p-cfg" class="panel">
<div class="card">
<div class="card-title">⚙️ 当前配置</div>
<div class="card-t">⚙️ 当前配置</div>
<table>
<tr><th style="width:180px">配置</th><th></th></tr>
<tr><td>TTS API</td><td id="cfg-endpoint">-</td></tr>
<tr><td>模型</td><td id="cfg-model">-</td></tr>
<tr><td>默认音色</td><td id="cfg-voice">-</td></tr>
<tr><td>API Key</td><td id="cfg-apikey">-</td></tr>
<tr><th style="width:140px"></th><th></th></tr>
<tr><td>TTS Endpoint</td><td id="c-ep">-</td></tr>
<tr><td>模型</td><td id="c-md">-</td></tr>
<tr><td>默认音色</td><td id="c-vc">-</td></tr>
<tr><td>API Key</td><td id="c-ak">-</td></tr>
<tr><td>分段上限</td><td id="c-ch">-</td></tr>
<tr><td>访问令牌</td><td id="c-tk">-</td></tr>
</table>
<p class="text-dim mt-4" style="font-size:.8rem">通过环境变量配置MIMO_API_KEY、MIMO_API_ENDPOINT、MIMO_TTS_MODEL、MIMO_VOICE</p>
<p class="hint mt-2">通过环境变量配置MIMO_API_KEY、MIMO_VOICE、MIMO_TTS_MODEL、API_TOKEN 等</p>
</div>
</div>
</div>
<!-- Add Book Modal -->
<div id="modal-add-book" class="modal-overlay">
<div class="modal">
<div class="modal-title">📖 添加书籍</div>
<div class="form-group">
<label>书籍 ID听书 App 中的 book_id</label>
<input id="new-book-id" placeholder="如book_9">
<!-- 接口说明 -->
<div id="p-api" class="panel">
<div class="card">
<div class="card-t">📖 核心接口</div>
<table>
<tr><th>接口</th><th>方法</th><th>说明</th></tr>
<tr><td><code class="code">/api/tts</code></td><td>POST</td><td>实时 TTS返回 MP3 音频流</td></tr>
<tr><td><code class="code">/health</code></td><td>GET</td><td>健康检查</td></tr>
</table>
</div>
<div class="form-group">
<label>书名</label>
<input id="new-book-title" placeholder="书籍名称">
<div class="card">
<div class="card-t">🔧 管理接口 <span class="dim" style="font-weight:400;font-size:.78rem">(需 Bearer Token</span></div>
<table>
<tr><th>接口</th><th>方法</th><th>说明</th></tr>
<tr><td><code class="code">/admin/api/preview</code></td><td>POST</td><td>TTS 试听,返回音频 URL</td></tr>
<tr><td><code class="code">/admin/api/config</code></td><td>GET</td><td>查看配置</td></tr>
</table>
</div>
<div class="form-group">
<label>作者</label>
<input id="new-book-author" placeholder="作者(可选)">
</div>
<div class="modal-actions">
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-add-book')">取消</button>
<button class="btn btn-primary" onclick="addBook()">确认添加</button>
</div>
</div>
</div>
<div class="card">
<div class="card-t">📤 /api/tts 请求格式</div>
<p class="hint">JSON 格式(推荐):</p>
<pre style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;margin:8px 0;font-size:.82rem;overflow-x:auto"><code>POST /api/tts
Content-Type: application/json
<!-- Chapter Modal -->
<div id="modal-chapters" class="modal-overlay">
<div class="modal">
<div class="flex-between">
<div class="modal-title" id="chapter-modal-title" style="margin-bottom:0">章节管理</div>
<button class="btn btn-sm btn-primary" onclick="showAddChapter()">+ 添加章节</button>
</div>
<div id="chapter-list" class="mt-4">
<p class="text-dim">加载中...</p>
</div>
<div class="mt-4" id="bulk-actions" style="display:none">
<button class="btn btn-success" onclick="generateAll()">⚡ 批量生成所有未就绪章节</button>
</div>
<div class="modal-actions">
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-chapters')">关闭</button>
</div>
</div>
</div>
{
"text": "要合成的文本",
"style": "开心", // 可选
"voice": "mimo_default" // 可选
}</code></pre>
<p class="hint">Form 格式(兼容百度风格):</p>
<pre style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;margin:8px 0;font-size:.82rem"><code>POST /api/tts
Content-Type: application/x-www-form-urlencoded
<!-- Add Chapter Modal -->
<div id="modal-add-chapter" class="modal-overlay">
<div class="modal">
<div class="modal-title" id="add-chapter-title">添加章节</div>
<div class="form-row">
<div class="form-group">
<label>章节 ID</label>
<input id="new-chapter-id" placeholder="如chapter_1">
</div>
<div class="form-group">
<label>App 章节 ID</label>
<input id="new-chapter-app-id" placeholder="如chapter1">
</div>
tex=要合成的文本</code></pre>
<p class="hint mt-2">
<b>特性:</b>长文本自动分段≤2000字/段)+ TTS 失败自动重试(最多 3 次)<br>
<b>听书 App 接入:</b>在 App 中配置 TTS 源 URL 为 <code class="code">http://服务器:端口/api/tts</code>
</p>
</div>
<div class="form-group">
<label>章节标题</label>
<input id="new-chapter-title" placeholder="章节名称">
<div class="card">
<div class="card-t">🎭 MiMo TTS 风格参考</div>
<table>
<tr><th>类别</th><th>示例</th></tr>
<tr><td>情感</td><td>开心 / 悲伤 / 生气 / 平静 / 惊讶</td></tr>
<tr><td>语速</td><td>语速慢 / 语速快 / 悄悄话</td></tr>
<tr><td>角色</td><td>像个大将军 / 像个小孩 / 孙悟空</td></tr>
<tr><td>方言</td><td>东北话 / 四川话 / 台湾腔 / 粤语</td></tr>
</table>
<p class="hint mt-2">可组合使用:<code class="code">"style": "开心 语速快"</code></p>
</div>
<div class="form-group">
<label>文本内容TTS 输入)</label>
<textarea id="new-chapter-text" rows="8" placeholder="粘贴本章节的文本内容..."></textarea>
</div>
<div class="modal-actions">
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-add-chapter')">取消</button>
<button class="btn btn-primary" onclick="addChapter()">添加</button>
</div>
</div>
</div>
<!-- Edit Chapter Modal -->
<div id="modal-edit-chapter" class="modal-overlay">
<div class="modal">
<div class="modal-title">编辑章节文本</div>
<input type="hidden" id="edit-chapter-id">
<div class="form-group">
<label>文本内容</label>
<textarea id="edit-chapter-text" rows="12" placeholder="修改章节文本..."></textarea>
</div>
<div class="modal-actions">
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-edit-chapter')">取消</button>
<button class="btn btn-primary" onclick="saveChapterText()">保存</button>
<div class="card">
<div class="card-t">📱 听书 App 模板变量</div>
<table>
<tr><th>变量</th><th>说明</th></tr>
<tr><td><code class="code">{{speakText}}</code></td><td>朗读文本</td></tr>
<tr><td><code class="code">{{speakSpeed}}</code></td><td>语速,范围 5-50</td></tr>
</table>
<p class="hint mt-2">
App 只能动态传文本和语速。voice/style 需在 JSON 配置中写死,或通过其他客户端调用 /api/tts 时传入。
</p>
</div>
</div>
</div>
<script>
let currentBookId = null;
// ── Tab ──
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
if (name === 'settings') loadSettings();
function sw(name, el) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('on'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('on'));
el.classList.add('on');
document.getElementById('p-' + name).classList.add('on');
if (name === 'cfg') loadCfg();
}
// ── Toast ──
function toast(msg, type = 'success') {
function toast(msg, ok = true) {
const t = document.createElement('div');
t.className = 'toast toast-' + type;
t.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
// ── Modal ──
function showModal(id) { document.getElementById(id).classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
// ── Books ──
async function loadBooks() {
async function preview() {
const text = document.getElementById('pv-text').value.trim();
const style = document.getElementById('pv-style').value.trim();
const voice = document.getElementById('pv-voice').value.trim();
if (!text) { toast('请输入文本', false); return; }
const btn = document.getElementById('pv-btn');
btn.disabled = true; btn.textContent = '⏳ 生成中...';
try {
const res = await fetch('/admin/api/books');
const books = await res.json();
const el = document.getElementById('book-list');
if (!books.length) {
el.innerHTML = '<p class="text-dim">暂无书籍,点击右上角「添加书籍」开始</p>';
return;
}
el.innerHTML = `<table>
<tr><th>书籍ID</th><th>书名</th><th>作者</th><th>操作</th></tr>
${books.map(b => `<tr>
<td><code>${b.book_id}</code></td>
<td>${b.title}</td>
<td>${b.author || '-'}</td>
<td class="flex">
<button class="btn btn-sm btn-primary" onclick="openChapters('${b.book_id}','${b.title}')">管理章节</button>
<button class="btn btn-sm btn-danger" onclick="deleteBook('${b.book_id}')">删除</button>
</td>
</tr>`).join('')}
</table>`;
} catch(e) { toast('加载失败: ' + e.message, 'error'); }
}
function showAddBook() { showModal('modal-add-book'); }
async function addBook() {
const book_id = document.getElementById('new-book-id').value.trim();
const title = document.getElementById('new-book-title').value.trim();
const author = document.getElementById('new-book-author').value.trim();
if (!book_id || !title) { toast('书籍ID和书名不能为空', 'error'); return; }
try {
await fetch('/admin/api/books', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({book_id, title, author})
});
closeModal('modal-add-book');
document.getElementById('new-book-id').value = '';
document.getElementById('new-book-title').value = '';
document.getElementById('new-book-author').value = '';
toast('书籍添加成功');
loadBooks();
} catch(e) { toast(e.message, 'error'); }
}
async function deleteBook(book_id) {
if (!confirm(`确定删除书籍 ${book_id} 及其所有章节?`)) return;
await fetch(`/admin/api/books/${book_id}`, {method: 'DELETE'});
toast('已删除');
loadBooks();
}
// ── Chapters ──
async function openChapters(book_id, title) {
currentBookId = book_id;
document.getElementById('chapter-modal-title').textContent = `📖 ${title} (${book_id})`;
showModal('modal-chapters');
await loadChapters(book_id);
}
async function loadChapters(book_id) {
try {
const res = await fetch(`/admin/api/books/${book_id}/chapters`);
const chapters = await res.json();
const el = document.getElementById('chapter-list');
const bulkEl = document.getElementById('bulk-actions');
if (!chapters.length) {
el.innerHTML = '<p class="text-dim">暂无章节</p>';
bulkEl.style.display = 'none';
return;
}
bulkEl.style.display = 'block';
el.innerHTML = `<table>
<tr><th>章节ID</th><th>App ID</th><th>标题</th><th>字数</th><th>状态</th><th>操作</th></tr>
${chapters.map(ch => `<tr>
<td><code>${ch.chapter_id}</code></td>
<td>${ch.app_chapter_id}</td>
<td>${ch.title || '-'}</td>
<td>${ch.text_length}</td>
<td><span class="badge badge-${ch.status}">${ch.status}</span></td>
<td class="flex" style="flex-wrap:wrap">
<button class="btn btn-sm btn-primary" onclick="editChapter('${book_id}','${ch.chapter_id}')">编辑</button>
<button class="btn btn-sm btn-success" onclick="generateOne('${book_id}','${ch.chapter_id}')">生成</button>
<button class="btn btn-sm btn-danger" onclick="deleteChapter('${book_id}','${ch.chapter_id}')">删除</button>
</td>
</tr>`).join('')}
</table>`;
} catch(e) { toast('加载失败: ' + e.message, 'error'); }
}
function showAddChapter() {
document.getElementById('add-chapter-title').textContent = '添加章节';
showModal('modal-add-chapter');
}
async function addChapter() {
const chapter_id = document.getElementById('new-chapter-id').value.trim();
const app_chapter_id = document.getElementById('new-chapter-app-id').value.trim();
const title = document.getElementById('new-chapter-title').value.trim();
const text_content = document.getElementById('new-chapter-text').value.trim();
if (!chapter_id) { toast('章节ID不能为空', 'error'); return; }
try {
await fetch(`/admin/api/books/${currentBookId}/chapters`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({chapter_id, app_chapter_id, title, text_content})
});
closeModal('modal-add-chapter');
['new-chapter-id','new-chapter-app-id','new-chapter-title','new-chapter-text'].forEach(id => document.getElementById(id).value = '');
toast('章节添加成功');
loadChapters(currentBookId);
} catch(e) { toast(e.message, 'error'); }
}
async function editChapter(book_id, chapter_id) {
document.getElementById('edit-chapter-id').value = chapter_id;
// Fetch full chapter text
const res = await fetch(`/admin/api/books/${book_id}/chapters`);
const chapters = await res.json();
const ch = chapters.find(c => c.chapter_id === chapter_id);
document.getElementById('edit-chapter-text').value = ch ? ch.text_content : '';
showModal('modal-edit-chapter');
}
async function saveChapterText() {
const chapter_id = document.getElementById('edit-chapter-id').value;
const text_content = document.getElementById('edit-chapter-text').value;
try {
await fetch(`/admin/api/books/${currentBookId}/chapters/${chapter_id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text_content})
});
closeModal('modal-edit-chapter');
toast('文本已保存');
loadChapters(currentBookId);
} catch(e) { toast(e.message, 'error'); }
}
async function deleteChapter(book_id, chapter_id) {
if (!confirm(`确定删除章节 ${chapter_id}`)) return;
await fetch(`/admin/api/books/${book_id}/chapters/${chapter_id}`, {method: 'DELETE'});
toast('已删除');
loadChapters(book_id);
}
// ── TTS Generation ──
async function generateOne(book_id, chapter_id) {
const btn = event.target;
btn.disabled = true;
btn.textContent = '生成中...';
try {
const res = await fetch(`/admin/api/books/${book_id}/chapters/${chapter_id}/generate`, {method: 'POST'});
const data = await res.json();
if (data.status === 'ready') toast('音频生成成功!');
else toast('生成失败: ' + (data.error_msg || '未知错误'), 'error');
loadChapters(book_id);
} catch(e) { toast('生成失败: ' + e.message, 'error'); }
btn.disabled = false;
btn.textContent = '生成';
}
async function generateAll() {
if (!confirm('确定批量生成所有未就绪章节的音频?这可能需要较长时间。')) return;
try {
toast('开始批量生成...');
const res = await fetch(`/admin/api/books/${currentBookId}/generate-all`, {method: 'POST'});
const data = await res.json();
toast(`批量生成完成,共 ${data.total}`);
loadChapters(currentBookId);
} catch(e) { toast('批量生成失败: ' + e.message, 'error'); }
}
// ── Preview ──
async function doPreview() {
const text = document.getElementById('preview-text').value.trim();
const style = document.getElementById('preview-style').value.trim();
const voice = document.getElementById('preview-voice').value.trim();
if (!text) { toast('请输入文本', 'error'); return; }
const btn = document.getElementById('preview-btn');
btn.disabled = true;
btn.textContent = '⏳ 生成中...';
try {
const res = await fetch('/admin/api/tts/preview', {
const res = await fetch('/admin/api/preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text, style, voice})
});
const data = await res.json();
if (data.ok) {
document.getElementById('preview-result').style.display = 'block';
document.getElementById('preview-audio').src = data.url;
document.getElementById('preview-audio').play();
toast('试听生成成功');
} else {
toast('生成失败', 'error');
}
} catch(e) { toast('生成失败: ' + e.message, 'error'); }
btn.disabled = false;
btn.textContent = '🔊 生成试听';
document.getElementById('pv-result').style.display = 'block';
document.getElementById('pv-audio').src = data.url;
document.getElementById('pv-audio').play();
document.getElementById('pv-info').textContent = data.chunks > 1 ? `已自动分 ${data.chunks} 段生成并拼接` : '';
toast('生成成功');
} else { toast('生成失败', false); }
} catch(e) { toast('生成失败: ' + e.message, false); }
btn.disabled = false; btn.textContent = '🔊 生成试听';
}
// ── Settings ──
async function loadSettings() {
async function loadCfg() {
try {
const res = await fetch('/admin/api/config');
const cfg = await res.json();
document.getElementById('cfg-endpoint').textContent = cfg.endpoint || '-';
document.getElementById('cfg-model').textContent = cfg.model || '-';
document.getElementById('cfg-voice').textContent = cfg.voice || '-';
document.getElementById('cfg-apikey').textContent = cfg.api_key_masked || '未配置';
const c = await res.json();
document.getElementById('c-ep').textContent = c.endpoint || '-';
document.getElementById('c-md').textContent = c.model || '-';
document.getElementById('c-vc').textContent = c.voice || '-';
document.getElementById('c-ak').textContent = c.api_key || '-';
document.getElementById('c-ch').textContent = c.max_chunk + ' 字符';
document.getElementById('c-tk').textContent = c.token_set ? '✅ 已配置' : '❌ 未配置(接口公开访问)';
} catch(e) {
document.getElementById('cfg-apikey').textContent = '加载失败';
toast('加载配置失败: ' + e.message, false);
}
}
// ── Init ──
loadBooks();
</script>
</body>
</html>