设置页(/settings):
- 新增 Setting DB 模型(键值存储,持久化运行时覆盖)
- config.py 加 _apply_overrides:get_settings() 合并 DB 覆盖值
(_applying_overrides 标志防递归:DB 初始化回调 get_settings 时不重入)
- save_setting() 写 DB + 清 lru_cache,后续任务读到新值
- settings_router.py: GET/PUT /api/settings
- settings_html.py: 设置页表单(batch_size/beam_size/sort_by_length)
+ 设备只读信息 + 保存后 reload 确认
- 验证:改 asr_batch_size=16 beam_size=1 -> 任务 ASR 日志确认生效
- 验证:重启容器后设置从 DB 恢复(持久化)
删除任务:
- task_router.py: DELETE /api/tasks/{id},仅 done/failed 可删
删字幕/音频/视频产物 + UploadSession + Task DB 记录
- home_html.py + history_html.py: done/failed 任务显示删除按钮
+ confirm 确认 + 调用 DELETE API + 刷新列表
- 验证:删除 task 15 成功,删除进行中 task 返回 409
/docs 去认证:
- 移除 require_docs_auth 依赖,/docs /redoc /openapi.json 直接公开
离线模式:
- Dockerfile dev/final 加 HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1
(模型已缓存在 ./models volume,无需联网验证)
- 验证:离线模式完整跑通 ASR+翻译(task 15 done)
193 lines
6.5 KiB
Python
193 lines
6.5 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");
|
||
let created;
|
||
try {{ created = fmtDateTime24(new Date(task.created_at + "Z")); }}
|
||
catch (e) {{ created = task.created_at; }}
|
||
|
||
let idDisplay = task.is_upload ? "—" : "#" + task.id;
|
||
|
||
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>`;
|
||
}}
|
||
// done/failed 且非上传中:加删除按钮
|
||
const canDelete = (task.status === "done" || task.status === "failed") && !task.is_upload;
|
||
if (canDelete) {{
|
||
action += ` <button class="btn-sm" onclick="deleteTask(${{task.id}})">删除</button>`;
|
||
}}
|
||
|
||
let progress;
|
||
if (task.status === "done") progress = "100%";
|
||
else if (task.status === "failed") progress = "—";
|
||
else {{
|
||
const pct = (task.progress == null) ? 0 : task.progress;
|
||
progress = `<div class="mini-bar"><div class="mini-fill" style="width:${{pct}}%"></div></div>${{pct.toFixed(0)}}%`;
|
||
}}
|
||
|
||
tr.innerHTML = `
|
||
<td class="muted">${{idDisplay}}</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;
|
||
}}
|
||
|
||
async function deleteTask(taskId) {{
|
||
if (!confirm("确认删除任务 #" + taskId + "?字幕和中间文件将被清除。")) return;
|
||
try {{
|
||
const r = await fetch("/api/tasks/" + taskId, {{ method: "DELETE" }});
|
||
if (!r.ok) {{ alert("删除失败:" + await r.text()); return; }}
|
||
load(currentOffset);
|
||
}} catch (e) {{
|
||
alert("删除失败:" + e);
|
||
}}
|
||
}}
|
||
|
||
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,
|
||
)
|