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

0
app/views/__init__.py Normal file
View File

370
app/views/_shared.py Normal file
View File

@@ -0,0 +1,370 @@
"""前端共享资产:统一样式、工具 JS、上传协议 JS、页面骨架。
3 个页面home/history/logs共用 BASE_CSS + SHARED_JS消除 ~400 行重复 CSS
和 ~80 行重复 JS。_CSS / JS 字符串都是**普通字符串**(非 f-string花括号用单层。
页面骨架 render_page() 统一 <head>/<nav>/<body> 结构,各页面只提供专属的 body + JS。
"""
from __future__ import annotations
# 分片上传参数home / upload 共用)
DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
DEFAULT_CONCURRENCY = 3
MAX_RETRY = 2
POLL_INTERVAL = 2000
_NAV_ITEMS = [
("/", "主页", "home"),
("/history", "历史", "history"),
("/logs", "日志", "logs"),
]
def render_nav(active: str) -> str:
"""导航栏 HTMLactive 页高亮。"""
items = []
for href, label, key in _NAV_ITEMS:
cls = "nav-item active" if key == active else "nav-item"
items.append(f'<a href="{href}" class="{cls}">{label}</a>')
return f'<nav class="nav">{"".join(items)}</nav>'
def render_page(
title: str,
nav_active: str,
body: str,
page_js: str = "",
page_css: str = "",
) -> str:
"""页面骨架:统一 head + nav + body 结构。
Args:
title: <title> 文本
nav_active: 当前页 nav keyhome/history/logs
body: 页面专属 HTMLh1、内容区等
page_js: 页面专属 JS<script> 内容,不含标签)
page_css: 页面专属 CSS追加在 BASE_CSS 之后)
"""
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
{BASE_CSS}
{page_css}
</style>
</head>
<body>
{render_nav(nav_active)}
{body}
<script>
{SHARED_JS}
{page_js}
</script>
</body>
</html>
"""
# ==================== 共享 CSS ====================
BASE_CSS = """
:root {
--bg: #fafafa; --fg: #1a1a1a; --card-bg: #fff; --border: #e0e0e0;
--accent: #1565c0; --accent-hover: #0d47a1; --accent-light: #e3f2fd;
--success: #2e7d32; --success-light: #e8f5e9; --success-fill: #43a047;
--warn: #e65100; --warn-light: #fff3e0;
--error: #c62828; --error-light: #ffebee;
--muted: #888; --bar-bg: #e6e6e6;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a1a; --fg: #e0e0e0; --card-bg: rgba(255,255,255,0.04);
--border: #333; --accent: #64b5f6; --accent-hover: #90caf9;
--accent-light: rgba(33,150,243,0.15); --success: #66bb6a;
--success-light: #1b3a20; --success-fill: #43a047; --warn: #ffab91;
--warn-light: #3a2818; --error: #ef9a9a; --error-light: #3a1b1b;
--muted: #888; --bar-bg: #333;
}
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 920px; margin: 0 auto; padding: 0 1em 2em;
line-height: 1.6; background: var(--bg); color: var(--fg);
transition: background 0.2s, color 0.2s;
}
/* 导航栏 */
.nav { display: flex; gap: 0; border-bottom: 2px solid var(--border);
margin-bottom: 1.5em; padding-top: 0.5em; }
.nav-item { padding: 0.5em 1.2em; color: var(--muted); text-decoration: none;
font-size: 0.92em; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: color 0.15s, border-color 0.15s; }
.nav-item:hover { color: var(--accent); }
.nav-item.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
h1 { margin-bottom: 0.1em; font-size: 1.5em; }
h2 { font-size: 1.15em; margin: 1.5em 0 0.5em; color: var(--muted); }
.sub { color: var(--muted); margin-top: 0; font-size: 0.92em; }
/* 上传拖拽区 */
.drop { border: 2px dashed var(--border); border-radius: 12px; padding: 2.5em 1em;
text-align: center; margin: 1.2em 0; transition: background 0.15s, border-color 0.15s; }
.drop.drag { background: var(--accent-light); border-color: var(--accent); }
.drop-hint { margin: 0 0 1em; color: var(--muted); }
/* 按钮 */
.btn { display: inline-block; padding: 0.5em 1.3em; border-radius: 6px;
background: var(--accent); color: #fff; cursor: pointer; font-size: 0.92em;
border: none; transition: background 0.15s, transform 0.1s; }
.btn:hover { background: var(--accent-hover); }
.btn:active { transform: scale(0.98); }
.btn input { display: none; }
.btn-sm { padding: 0.3em 1em; border: 1px solid var(--border); border-radius: 4px;
background: transparent; cursor: pointer; font-size: 0.85em; color: var(--fg);
transition: background 0.15s; }
.btn-sm:hover { background: var(--accent-light); }
/* 卡片 */
.card { border: 1px solid var(--border); border-radius: 10px; padding: 0.9em 1.1em;
margin: 0.7em 0; background: var(--card-bg);
box-shadow: 0 1px 3px rgba(0,0,0,0.04); transition: box-shadow 0.15s; }
.card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
/* 任务卡片头部 */
.task-head { display: flex; align-items: center; gap: 0.6em; margin-bottom: 0.5em; }
.fname { font-weight: 600; word-break: break-all; flex: 1; }
.fsize { color: var(--muted); font-size: 0.85em; white-space: nowrap; }
/* 状态标签 */
.fstate, .status-tag { font-size: 0.82em; padding: 0.15em 0.7em; border-radius: 12px;
white-space: nowrap; font-weight: 500; }
.state-running, .st-running { background: var(--accent-light); color: var(--accent); }
.state-hashing { background: var(--warn-light); color: var(--warn); }
.state-done, .st-done { background: var(--success-light); color: var(--success); }
.state-fail, .st-fail { background: var(--error-light); color: var(--error); }
/* 进度条 */
.bar { position: relative; background: var(--bar-bg); border-radius: 4px;
height: 20px; width: 100%; overflow: hidden; }
.bar .fill { height: 100%; width: 0; background: linear-gradient(90deg, #43a047, #66bb6a);
border-radius: 4px; transition: width 0.3s ease; }
.bar .pct { position: absolute; top: 0; left: 0; width: 100%; height: 100%;
text-align: center; font-size: 12px; line-height: 20px;
color: #fff; mix-blend-mode: difference; }
.mini-bar { display: inline-block; width: 60px; height: 8px; background: var(--bar-bg);
border-radius: 2px; overflow: hidden; margin-right: 4px; vertical-align: middle; }
.mini-fill { height: 100%; background: var(--success-fill); border-radius: 2px; }
/* 任务元信息 */
.task-meta { margin-top: 0.5em; font-size: 0.82em; color: var(--muted); word-break: break-all; }
.task-meta a.dl, a.dl { color: var(--accent); text-decoration: none; font-weight: 600;
margin-right: 0.3em; transition: color 0.15s; }
a.dl:hover { text-decoration: underline; }
.fail-msg { color: var(--error); }
.task-time { margin-top: 0.3em; font-size: 0.78em; color: var(--muted); }
/* 表格 */
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; }
table { width: 100%; border-collapse: collapse; font-size: 0.88em; }
th { background: var(--accent-light); padding: 0.6em 0.8em; text-align: left;
font-weight: 600; border-bottom: 2px solid var(--border); white-space: nowrap; }
td { padding: 0.5em 0.8em; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:nth-child(even) td { background: rgba(0,0,0,0.015); }
tr:hover td { background: var(--accent-light); }
/* 工具栏 + 分页 */
.toolbar { display: flex; align-items: center; gap: 1em; margin: 1em 0; flex-wrap: wrap; }
.pagination { margin: 1em 0; text-align: center; }
.page-btn { padding: 0.3em 1em; border: 1px solid var(--border); border-radius: 4px;
background: transparent; cursor: pointer; font-size: 0.85em; color: var(--fg);
transition: background 0.15s; }
.page-btn:hover { background: var(--accent-light); }
.page-info { color: var(--muted); font-size: 0.85em; margin: 0 0.5em; }
.info { color: var(--muted); font-size: 0.85em; }
/* 通用 */
.empty { text-align: center; color: var(--muted); padding: 3em; font-size: 0.95em; }
.foot { color: var(--muted); font-size: 0.82em; margin-top: 1.5em; text-align: center; }
a { color: var(--accent); text-decoration: none; }
.muted { color: var(--muted); }
.err-tip { color: var(--error); cursor: help; border-bottom: 1px dotted var(--error); }
"""
# ==================== 共享 JS ====================
SHARED_JS = """
// 任务状态中文标签
const STATUS_LABEL = {
queued: "排队中", extracting: "提取音频", transcribing: "语音识别",
segmenting: "断句重算", translating: "翻译中", done: "完成", failed: "失败"
};
const ACTIVE_STATES = ["queued","extracting","transcribing","segmenting","translating"];
// HTML 转义(防 XSS
function escapeHtml(s) {
const d = document.createElement("div");
d.textContent = s == null ? "" : String(s);
return d.innerHTML;
}
// 字节数格式化
function fmtBytes(n) {
let x = n, u = 0;
const units = ["B","KiB","MiB","GiB","TiB"];
while (x >= 1024 && u < units.length-1) { x /= 1024; u++; }
return u === 0 ? x + " B" : x.toFixed(1) + " " + units[u];
}
// 并发池indices 中的每个元素交给 worker最多 concurrency 个并发
async function runPool(indices, concurrency, worker) {
let cursor = 0;
const runners = [];
for (let n = 0; n < concurrency; n++) {
runners.push((async () => {
while (cursor < indices.length) { const i = indices[cursor++]; await worker(i); }
})());
}
await Promise.all(runners);
}
"""
# ==================== 分片上传协议 JS ====================
# 主页上传区共用。onComplete 回调让页面自定义完成后的行为。
def render_upload_js(on_complete: str) -> str:
"""生成分片上传协议 JS。
Args:
on_complete: JS 代码片段,在 complete 成功后执行resp 是 CompleteResponse
例如主页传 "refreshList()" 刷新最近任务列表。
"""
return f"""
const CHUNK_SIZE = {DEFAULT_CHUNK_SIZE};
const CONCURRENCY = {DEFAULT_CONCURRENCY};
const MAX_RETRY = {MAX_RETRY};
const UPLOAD_API = "/api/tasks/chunk-uploads";
let pending = [];
function initUpload(dropEl, fileInputEl, tasksContainerEl) {{
dropEl.addEventListener("dragover", e => {{ e.preventDefault(); dropEl.classList.add("drag"); }});
dropEl.addEventListener("dragleave", () => dropEl.classList.remove("drag"));
dropEl.addEventListener("drop", e => {{
e.preventDefault();
dropEl.classList.remove("drag");
addFiles(e.dataTransfer.files, tasksContainerEl);
}});
fileInputEl.addEventListener("change", () => addFiles(fileInputEl.files, tasksContainerEl));
}}
function addFiles(fileList, container) {{
for (const f of fileList) {{
pending.push(makeUploadTask(f, container));
}}
document.querySelector('#file-input').value = "";
pump();
}}
function makeUploadTask(file, container) {{
const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE));
const el = document.createElement("div");
el.className = "card task upload-task";
el.innerHTML = `
<div class="task-head">
<span class="fname"></span>
<span class="fsize"></span>
<span class="fstate">等待中</span>
</div>
<div class="bar"><div class="fill" style="width:0%"></div><span class="pct">0%</span></div>
`;
el.querySelector(".fname").textContent = file.name;
el.querySelector(".fsize").textContent = fmtBytes(file.size);
container.insertBefore(el, container.firstChild);
return {{ file, totalChunks, el, uploadId: null, uploaded: new Set(), state: "pending" }};
}}
function setUploadState(t, s) {{
t.state = s;
const map = {{pending:"等待中", running:"上传中", hashing:"拼接中", done:"完成", fail:"失败"}};
t.el.querySelector(".fstate").textContent = map[s] || s;
t.el.querySelector(".fstate").className = "fstate state-" + s;
}}
function setUploadProgress(t, pct) {{
t.el.querySelector(".fill").style.width = pct.toFixed(1) + "%";
t.el.querySelector(".pct").textContent = pct.toFixed(0) + "%";
}}
function pump() {{
const active = pending.filter(t => t.state === "running").length;
for (const t of pending) {{
if (active >= CONCURRENCY) break;
if (t.state === "pending") {{
t.state = "running";
startUpload(t);
active++;
}}
}}
}}
async function startUpload(t) {{
try {{
const cre = await fetch(UPLOAD_API, {{
method: "POST", headers: {{"Content-Type":"application/json"}},
body: JSON.stringify({{ filename: t.file.name, size_bytes: t.file.size, chunk_size: CHUNK_SIZE, total_chunks: t.totalChunks }}),
}});
if (!cre.ok) throw new Error("创建会话失败: " + await cre.text());
const sess = await cre.json();
t.uploadId = sess.upload_id;
const st = await fetch(UPLOAD_API + "/" + t.uploadId + "/status");
const status = await st.json();
(status.uploaded_chunks || []).forEach(i => t.uploaded.add(i));
const need = [];
for (let i = 0; i < t.totalChunks; i++) if (!t.uploaded.has(i)) need.push(i);
await runPool(need, CONCURRENCY, i => uploadChunk(t, i));
if (t.uploaded.size < t.totalChunks) throw new Error("部分分片未能上传");
setUploadState(t, "hashing");
setUploadProgress(t, 100);
const cmp = await fetch(UPLOAD_API + "/" + t.uploadId + "/complete", {{ method: "POST" }});
if (!cmp.ok) throw new Error("complete 失败: " + await cmp.text());
const resp = await cmp.json();
t.el.remove();
pending = pending.filter(x => x !== t);
{on_complete}
}} catch (e) {{
setUploadState(t, "fail");
const meta = document.createElement("div");
meta.className = "task-meta fail-msg";
meta.textContent = String(e.message || e);
t.el.appendChild(meta);
}}
}}
async function uploadChunk(t, index) {{
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, t.file.size);
const blob = t.file.slice(start, end);
let lastErr;
for (let attempt = 0; attempt <= MAX_RETRY; attempt++) {{
try {{
const r = await fetch(UPLOAD_API + "/" + t.uploadId + "/chunks/" + index, {{ method: "POST", body: blob }});
if (!r.ok) throw new Error("HTTP " + r.status);
t.uploaded.add(index);
setUploadProgress(t, (t.uploaded.size / t.totalChunks) * 100);
return;
}} catch (e) {{ lastErr = e; }}
}}
throw lastErr;
}}
"""

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,
)

142
app/views/home_html.py Normal file
View File

@@ -0,0 +1,142 @@
"""主页:上传入口 + 最近 10 个任务的实时进度。
共享 _shared.py 的 BASE_CSS / SHARED_JS / 上传协议 JS。
页面专属refreshList拉取最近任务、makeTaskCard、pollTask轮询进行中任务
"""
from __future__ import annotations
from ._shared import POLL_INTERVAL, render_page, render_upload_js
RECENT_LIMIT = 10
LIST_REFRESH_INTERVAL = 5000
_PAGE_JS = f"""
const POLL_INTERVAL = {POLL_INTERVAL};
const LIST_REFRESH_INTERVAL = {LIST_REFRESH_INTERVAL};
const RECENT_LIMIT = {RECENT_LIMIT};
const tasksEl = document.getElementById("tasks");
const emptyEl = document.getElementById("empty");
let pollingIds = new Set();
// 初始化上传区
initUpload(document.getElementById("drop"), document.getElementById("file-input"), tasksEl);
// ==================== 最近任务列表 ====================
async function refreshList() {{
try {{
const r = await fetch("/api/tasks?limit=" + RECENT_LIMIT);
if (!r.ok) return;
const data = await r.json();
renderTasks(data.tasks);
}} catch (e) {{}}
}}
function renderTasks(tasks) {{
tasksEl.querySelectorAll(".server-task").forEach(el => el.remove());
const hasUploadCards = tasksEl.querySelector(".upload-task") !== null;
emptyEl.style.display = (tasks.length === 0 && !hasUploadCards) ? "block" : "none";
for (const task of tasks) {{
tasksEl.appendChild(makeTaskCard(task));
}}
// 对进行中的任务启动轮询
for (const task of tasks) {{
if (ACTIVE_STATES.includes(task.status) && !pollingIds.has(task.id)) {{
pollingIds.add(task.id);
pollTask(task.id);
}}
}}
}}
function downloadLinks(taskId) {{
return `<a href="/api/tasks/${{taskId}}/subtitle?type=bilingual" class="dl">双语 SRT</a>`
+ ` <a href="/api/tasks/${{taskId}}/subtitle?type=en" class="dl">英</a>`
+ ` <a href="/api/tasks/${{taskId}}/subtitle?type=zh" class="dl">中</a>`;
}}
function makeTaskCard(task) {{
const el = document.createElement("div");
el.className = "card task server-task";
el.dataset.taskId = task.id;
el.innerHTML = renderTaskInner(task);
return el;
}}
function renderTaskInner(task) {{
const label = STATUS_LABEL[task.status] || task.status;
const stateClass = task.status === "done" ? "state-done"
: task.status === "failed" ? "state-fail" : "state-running";
const created = new Date(task.created_at).toLocaleString();
let body;
if (task.status === "done") {{
body = `<div class="task-meta"><b>完成</b> · ${{downloadLinks(task.id)}}</div>`;
}} else if (task.status === "failed") {{
body = `<div class="task-meta fail-msg">${{escapeHtml(task.error || "未知错误")}}</div>`;
}} else {{
body = `<div class="bar"><div class="fill" style="width:${{task.progress}}%"></div><span class="pct">${{task.progress.toFixed(0)}}%</span></div>`;
}}
return `
<div class="task-head">
<span class="fname">#${{task.id}} ${{escapeHtml(task.filename)}}</span>
<span class="fstate ${{stateClass}}">${{label}}</span>
</div>
${{body}}
<div class="task-time">${{created}}</div>`;
}}
async function pollTask(taskId) {{
const tick = async () => {{
try {{
const r = await fetch("/api/tasks/" + taskId);
if (!r.ok) {{ pollingIds.delete(taskId); return; }}
const task = await r.json();
const card = tasksEl.querySelector('.server-task[data-task-id="' + taskId + '"]');
if (!card) {{ pollingIds.delete(taskId); return; }}
card.innerHTML = renderTaskInner(task);
if (task.status === "done" || task.status === "failed") {{
pollingIds.delete(taskId);
return;
}}
setTimeout(tick, POLL_INTERVAL);
}} catch (e) {{
setTimeout(tick, POLL_INTERVAL);
}}
}};
tick();
}}
// 启动
refreshList();
setInterval(refreshList, LIST_REFRESH_INTERVAL);
"""
_BODY = """
<h1>音频转字幕</h1>
<p class="sub">上传视频或音频文件,自动生成双语(英/中SRT 字幕。支持大文件分片上传与断点续传。</p>
<div id="drop" class="drop">
<p class="drop-hint">把文件拖到这里,或</p>
<label class="btn">选择文件<input id="file-input" type="file" multiple hidden></label>
</div>
<h2>最近任务</h2>
<div id="tasks" class="tasks"></div>
<div id="empty" class="empty" style="display:none">暂无任务,上传文件后这里会显示进度。</div>
"""
def render() -> str:
# 上传完成后刷新任务列表,让新任务出现在卡片中
upload_js = render_upload_js(on_complete="refreshList();")
return render_page(
title="audio2text — 音频转字幕",
nav_active="home",
body=_BODY,
page_js=upload_js + _PAGE_JS,
)

167
app/views/logs_html.py Normal file
View File

@@ -0,0 +1,167 @@
"""日志页:实时轮询 /api/logs按级别过滤可展开 traceback。
共享 _shared.py 的 BASE_CSS / SHARED_JS。
页面专属级别过滤按钮、自动刷新开关、清空、traceback 折叠。
"""
from __future__ import annotations
from ._shared import render_page
POLL_INTERVAL_LOGS = 2000
DEFAULT_TAIL = 200
_PAGE_JS = f"""
const POLL_INTERVAL = {POLL_INTERVAL_LOGS};
const DEFAULT_TAIL = {DEFAULT_TAIL};
const API = "/api/logs";
let currentLevel = "debug";
let autoRefresh = true;
let timer = null;
const logsEl = document.getElementById("logs");
const emptyEl = document.getElementById("empty");
const statusEl = document.getElementById("status");
const LEVEL_CLASS = {{
DEBUG: "st-running", INFO: "st-done", WARNING: "st-fail", ERROR: "st-fail",
CRITICAL: "st-fail"
}};
document.querySelectorAll(".filter").forEach(btn => {{
btn.addEventListener("click", () => {{
document.querySelectorAll(".filter").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
currentLevel = btn.dataset.level;
logsEl.innerHTML = "";
fetchLogs();
}});
}});
document.getElementById("autorefresh").addEventListener("change", e => {{
autoRefresh = e.target.checked;
if (autoRefresh) fetchLogs(); else if (timer) {{ clearTimeout(timer); timer = null; }}
}});
document.getElementById("clear-btn").addEventListener("click", async () => {{
if (!confirm("确定清空所有日志缓冲?")) return;
try {{
await fetch(API, {{ method: "DELETE" }});
logsEl.innerHTML = "";
statusEl.textContent = "已清空";
}} catch (e) {{ statusEl.textContent = "清空失败"; }}
}});
async function fetchLogs() {{
try {{
const r = await fetch(`${{API}}?level=${{currentLevel}}&tail=${{DEFAULT_TAIL}}`);
if (!r.ok) {{ statusEl.textContent = "HTTP " + r.status; scheduleNext(); return; }}
const data = await r.json();
renderLogs(data.logs);
statusEl.textContent = `${{data.count}} 条 · 更新 ${{new Date().toLocaleTimeString()}}`;
scheduleNext();
}} catch (e) {{
statusEl.textContent = "获取失败";
scheduleNext();
}}
}}
function renderLogs(logs) {{
if (!logs || logs.length === 0) {{
if (logsEl.children.length === 0) emptyEl.style.display = "block";
return;
}}
emptyEl.style.display = "none";
const existing = new Set(Array.from(logsEl.children).map(el => el.dataset.key));
const frag = document.createDocumentFragment();
for (const log of logs) {{
const key = `${{log.ts}}|${{log.level}}|${{log.msg}}`;
if (existing.has(key)) continue;
const row = document.createElement("div");
row.className = "log-row " + (LEVEL_CLASS[log.level] || "st-running");
row.dataset.key = key;
const hasTrace = !!log.traceback;
row.innerHTML = `
<span class="log-ts">${{escapeHtml(log.ts)}}</span>
<span class="status-tag ${{LEVEL_CLASS[log.level] || 'st-running'}}">${{escapeHtml(log.level)}}</span>
<span class="log-logger">${{escapeHtml(log.logger)}}</span>
<span class="log-msg">${{escapeHtml(log.msg)}}${{hasTrace ? ' <span class="trace-toggle">[traceback]</span>' : ''}}</span>`;
if (hasTrace) {{
const pre = document.createElement("pre");
pre.className = "trace";
pre.textContent = log.traceback;
pre.style.display = "none";
row.appendChild(pre);
row.querySelector(".trace-toggle").addEventListener("click", e => {{
e.stopPropagation();
pre.style.display = pre.style.display === "none" ? "block" : "none";
}});
}}
frag.appendChild(row);
}}
logsEl.appendChild(frag);
while (logsEl.children.length > DEFAULT_TAIL) logsEl.removeChild(logsEl.firstChild);
logsEl.scrollTop = logsEl.scrollHeight;
}}
function scheduleNext() {{
if (autoRefresh) timer = setTimeout(fetchLogs, POLL_INTERVAL);
}}
fetchLogs();
"""
_PAGE_CSS = """
.logs { border: 1px solid var(--border); border-radius: 10px; max-height: 70vh;
overflow-y: auto; background: var(--card-bg);
font-family: "SF Mono", "Cascadia Code", Consolas, monospace; font-size: 0.82em; }
.log-row { display: grid; grid-template-columns: 140px 70px 150px 1fr; gap: 0.5em;
padding: 0.25em 0.8em; border-bottom: 1px solid var(--border); align-items: start; }
.log-row:hover { background: var(--accent-light); }
.log-ts { color: var(--muted); white-space: nowrap; }
.log-logger { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.log-msg { word-break: break-all; white-space: pre-wrap; }
.trace-toggle { color: var(--error); cursor: pointer; font-weight: 600; font-size: 0.85em; }
.trace { grid-column: 1 / -1; margin: 0.3em 0 0.5em; padding: 0.6em; background: #1e1e1e;
color: #f44336; border-radius: 4px; font-size: 0.9em; overflow-x: auto; white-space: pre-wrap; }
.filters { display: flex; gap: 0.4em; }
.filter { padding: 0.3em 1em; border: 1px solid var(--border); border-radius: 14px;
background: transparent; cursor: pointer; font-size: 0.85em; color: var(--muted);
transition: background 0.15s, color 0.15s; }
.filter.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.filter:hover:not(.active) { background: var(--accent-light); }
.toggle { display: flex; align-items: center; gap: 0.3em; cursor: pointer; color: var(--muted); }
"""
_BODY = """
<h1>日志</h1>
<p class="sub">实时查看服务日志。debug=详细子步骤info=仅阶段转换error=完整错误。自动刷新每 2 秒。</p>
<div class="toolbar">
<div class="filters">
<button class="filter active" data-level="debug">全部 (DEBUG)</button>
<button class="filter" data-level="info">简略 (INFO)</button>
<button class="filter" data-level="warning">警告+</button>
<button class="filter" data-level="error">仅错误</button>
</div>
<div style="display:flex;align-items:center;gap:0.8em;">
<label class="toggle"><input id="autorefresh" type="checkbox" checked> 自动刷新</label>
<button id="clear-btn" class="btn-sm">清空</button>
<span id="status" class="info"></span>
</div>
</div>
<div id="logs" class="logs"></div>
<div id="empty" class="empty">暂无日志。</div>
"""
def render() -> str:
return render_page(
title="日志 — audio2text",
nav_active="logs",
body=_BODY,
page_js=_PAGE_JS,
page_css=_PAGE_CSS,
)