Initial commit: audio2text 双语字幕生成服务
- 音频/视频转双语(英/中)SRT 字幕,Docker 容器化,CPU 开发/GPU 生产同一份代码 - faster-whisper ASR(词级时间戳) + 断句时间戳重算 + NLLB 翻译(模型不共驻) - 分片上传(断点续传) + SQLite 持久化 + 主页/历史/日志页面 - 历史页文件名搜索;缓存定时清理(默认保留7天,可配置) - 双 Dockerfile(cpu/gpu) + setup/start/stop 脚本
This commit is contained in:
0
app/views/__init__.py
Normal file
0
app/views/__init__.py
Normal file
370
app/views/_shared.py
Normal file
370
app/views/_shared.py
Normal 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:
|
||||
"""导航栏 HTML,active 页高亮。"""
|
||||
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 key(home/history/logs)
|
||||
body: 页面专属 HTML(h1、内容区等)
|
||||
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
169
app/views/history_html.py
Normal 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
142
app/views/home_html.py
Normal 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
167
app/views/logs_html.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user