Files
audio2text/app/views/_shared.py
audio2text dev 2f68c7e1f8 fix: 并发上传5文件刷新后丢失 + 分片写入阻塞事件循环
根因:
1. pump() 仅在 addFiles() 调用一次,上传完成/失败后不重新触发,
   导致 CONCURRENCY=3 之后的文件(4、5)永远不启动 → 无 DB 会话 → 刷新后消失
2. write_chunk 的 uploaded_chunks 是 read-modify-write,并发分片写入
   后者覆盖前者 → 分片记录丢失 → complete 报 409
3. upload_chunk 是 async 但同步调 write_chunk(fsync+DB commit),
   阻塞 uvicorn 事件循环 → 所有 web 请求被串行化

修复:
- _shared.py: pump() 加 finally 块,上传完成/失败后都触发下一文件;
  文件并发(FILE_CONCURRENCY=5)与分片并发(CHUNK_CONCURRENCY=3)分离
- upload_service.py: 按 upload_id 的进程级锁串行化 uploaded_chunks 更新,
  持锁后 db.refresh 重读最新值再 append,杜绝丢失更新;complete 后清理锁
- upload_router.py: upload_chunk 的 write_chunk 调用改用 run_in_threadpool,
  阻塞 I/O 移出事件循环,web 请求不再被分片写入阻塞

验证:5 文件并发上传后刷新全部可见;4 任务并发处理 4/4 成功(19.2s)
2026-07-06 22:04:06 +08:00

387 lines
15 KiB
Python
Raw 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"),
]
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;
}}
"""