- 音频/视频转双语(英/中)SRT 字幕,Docker 容器化,CPU 开发/GPU 生产同一份代码 - faster-whisper ASR(词级时间戳) + 断句时间戳重算 + NLLB 翻译(模型不共驻) - 分片上传(断点续传) + SQLite 持久化 + 主页/历史/日志页面 - 历史页文件名搜索;缓存定时清理(默认保留7天,可配置) - 双 Dockerfile(cpu/gpu) + setup/start/stop 脚本
170 lines
5.7 KiB
Python
170 lines
5.7 KiB
Python
"""历史任务页:分页表格展示所有任务,可按文件名搜索、下载完成的字幕。
|
||
|
||
共享 _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,
|
||
)
|