Initial commit: audio2text 双语字幕生成服务

- 音频/视频转双语(英/中)SRT 字幕,Docker 容器化,CPU 开发/GPU 生产同一份代码
- faster-whisper ASR(词级时间戳) + 断句时间戳重算 + NLLB 翻译(模型不共驻)
- 分片上传(断点续传) + SQLite 持久化 + 主页/历史/日志页面
- 历史页文件名搜索;缓存定时清理(默认保留7天,可配置)
- 双 Dockerfile(cpu/gpu) + setup/start/stop 脚本
This commit is contained in:
2026-07-06 06:54:19 +00:00
commit 00e2a95fb7
44 changed files with 4110 additions and 0 deletions

169
app/views/history_html.py Normal file
View File

@@ -0,0 +1,169 @@
"""历史任务页:分页表格展示所有任务,可按文件名搜索、下载完成的字幕。
共享 _shared.py 的 BASE_CSS / SHARED_JS。
页面专属:搜索框(防抖)、分页表格、手动刷新(不自动轮询)。
"""
from __future__ import annotations
from ._shared import render_page
PAGE_SIZE = 50
SEARCH_DEBOUNCE_MS = 350
_PAGE_JS = f"""
const PAGE_SIZE = {PAGE_SIZE};
const SEARCH_DEBOUNCE_MS = {SEARCH_DEBOUNCE_MS};
let currentOffset = 0;
let total = 0;
let searchQuery = "";
let debounceTimer = null;
const tbody = document.getElementById("task-body");
const infoEl = document.getElementById("info");
const emptyEl = document.getElementById("empty");
const paginationEl = document.getElementById("pagination");
const searchInput = document.getElementById("search");
document.getElementById("refresh-btn").addEventListener("click", () => load(currentOffset));
// 搜索:输入防抖,改动后回到第一页
searchInput.addEventListener("input", () => {{
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {{
searchQuery = searchInput.value.trim();
load(0);
}}, SEARCH_DEBOUNCE_MS);
}});
async function load(offset) {{
currentOffset = offset;
try {{
const params = new URLSearchParams({{
limit: PAGE_SIZE, offset: offset
}});
if (searchQuery) params.set("q", searchQuery);
const r = await fetch("/api/tasks?" + params.toString());
if (!r.ok) {{ infoEl.textContent = "HTTP " + r.status; return; }}
const data = await r.json();
total = data.total;
renderTable(data.tasks);
renderPagination();
const end = Math.min(offset + data.tasks.length, total);
const prefix = searchQuery ? `搜索“${{escapeHtml(searchQuery)}}”匹配 ` : "";
infoEl.textContent = total === 0
? (searchQuery ? "无匹配任务" : "暂无任务")
: `${{prefix}}${{total}} 条,显示 ${{offset + 1}}${{end}}`;
}} catch (e) {{
infoEl.textContent = "加载失败";
}}
}}
function renderTable(tasks) {{
tbody.innerHTML = "";
if (tasks.length === 0) {{ emptyEl.style.display = "block"; return; }}
emptyEl.style.display = "none";
for (const task of tasks) {{
const tr = document.createElement("tr");
const label = STATUS_LABEL[task.status] || task.status;
const stateClass = "st-" + (task.status === "done" ? "done" : task.status === "failed" ? "fail" : "running");
const created = new Date(task.created_at).toLocaleString();
let action;
if (task.status === "done") {{
action = `<a href="/api/tasks/${{task.id}}/subtitle?type=bilingual" class="dl">双语</a>`
+ `<a href="/api/tasks/${{task.id}}/subtitle?type=en" class="dl">英</a>`
+ `<a href="/api/tasks/${{task.id}}/subtitle?type=zh" class="dl">中</a>`;
}} else if (task.status === "failed") {{
action = `<span class="err-tip" title="${{escapeHtml(task.error || "")}}">查看错误</span>`;
}} else {{
action = `<span class="muted">—</span>`;
}}
let progress;
if (task.status === "done") progress = "100%";
else if (task.status === "failed") progress = "";
else progress = `<div class="mini-bar"><div class="mini-fill" style="width:${{task.progress}}%"></div></div>${{task.progress.toFixed(0)}}%`;
tr.innerHTML = `
<td class="muted">#${{task.id}}</td>
<td>${{escapeHtml(task.filename)}}</td>
<td><span class="status-tag ${{stateClass}}">${{label}}</span></td>
<td>${{progress}}</td>
<td class="task-time">${{created}}</td>
<td>${{action}}</td>`;
tbody.appendChild(tr);
}}
}}
function renderPagination() {{
const pages = Math.ceil(total / PAGE_SIZE);
const currentPage = Math.floor(currentOffset / PAGE_SIZE) + 1;
if (pages <= 1) {{ paginationEl.innerHTML = ""; return; }}
let html = "";
if (currentPage > 1)
html += `<button class="page-btn" onclick="load(${{(currentPage - 2) * PAGE_SIZE}})">上一页</button> `;
html += `<span class="page-info">第 ${{currentPage}} / ${{pages}} 页</span>`;
if (currentPage < pages)
html += ` <button class="page-btn" onclick="load(${{currentPage * PAGE_SIZE}})">下一页</button>`;
paginationEl.innerHTML = html;
}}
load(0);
"""
_PAGE_CSS = """
.search-input {
flex: 1; min-width: 180px; max-width: 360px;
padding: 0.42em 0.7em; border: 1px solid var(--border); border-radius: 6px;
background: var(--card-bg); color: var(--fg); font-size: 0.9em;
transition: border-color 0.15s, box-shadow 0.15s;
}
.search-input:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
}
/* 搜索框主导工具栏,刷新/信息靠右不收缩 */
.toolbar { justify-content: flex-start; }
.toolbar .btn-sm, .toolbar .info { flex-shrink: 0; }
"""
_BODY = """
<h1>历史任务</h1>
<p class="sub">所有转写任务记录。可按文件名搜索,完成任务可下载字幕,进行中显示进度。</p>
<div class="toolbar">
<input id="search" class="search-input" type="search" placeholder="按文件名搜索…" autocomplete="off">
<button id="refresh-btn" class="btn-sm">刷新</button>
<span id="info" class="info"></span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>文件名</th>
<th>状态</th>
<th>进度</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="task-body"></tbody>
</table>
</div>
<div id="pagination" class="pagination"></div>
<div id="empty" class="empty" style="display:none">暂无任务。</div>
"""
def render() -> str:
return render_page(
title="历史任务 — audio2text",
nav_active="history",
body=_BODY,
page_js=_PAGE_JS,
page_css=_PAGE_CSS,
)