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

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