Files
audio2text/app/views/_shared.py
audio2text dev 7635e5e766 feat: 设置页调 batch_size + 删除任务 + /docs 去认证 + 离线模式
设置页(/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)
2026-07-11 11:33:34 +08:00

388 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""前端共享资产:统一样式、工具 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 # 单文件内分片并发数
DEFAULT_FILE_CONCURRENCY = 5 # 同时上传的文件数(不阻塞 web 请求/ffmpeg/gpu
MAX_RETRY = 2
POLL_INTERVAL = 2000
_NAV_ITEMS = [
("/", "主页", "home"),
("/history", "历史", "history"),
("/logs", "日志", "logs"),
("/settings", "设置", "settings"),
]
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 = {
uploading: "上传中", queued: "排队中", extracting: "提取音频",
transcribing: "语音识别", segmenting: "断句重算", translating: "翻译中",
done: "完成", failed: "失败"
};
const ACTIVE_STATES = ["uploading","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];
}
// 24 小时制时间格式化(不受浏览器 locale 影响,避免 am/pm 混淆)
function _pad2(n) { return n < 10 ? "0" + n : "" + n; }
function fmtTime24(d) {
return _pad2(d.getHours()) + ":" + _pad2(d.getMinutes()) + ":" + _pad2(d.getSeconds());
}
function fmtDateTime24(d) {
return d.getFullYear() + "-" + _pad2(d.getMonth()+1) + "-" + _pad2(d.getDate())
+ " " + fmtTime24(d);
}
// 并发池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 CHUNK_CONCURRENCY = {DEFAULT_CONCURRENCY}; // 单文件内分片并发
const FILE_CONCURRENCY = {DEFAULT_FILE_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() {{
// 统计正在上传的文件数,启动等待中的文件直到达到 FILE_CONCURRENCY
const active = pending.filter(t => t.state === "running").length;
for (const t of pending) {{
if (active >= FILE_CONCURRENCY) break;
if (t.state === "pending") {{
setUploadState(t, "running");
startUpload(t);
}}
}}
}}
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, CHUNK_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);
}} finally {{
// 无论成功还是失败,都触发 pump 让队列中下一个文件开始上传
pump();
}}
}}
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;
}}
"""