Initial commit: audio2text 双语字幕生成服务
- 音频/视频转双语(英/中)SRT 字幕,Docker 容器化,CPU 开发/GPU 生产同一份代码 - faster-whisper ASR(词级时间戳) + 断句时间戳重算 + NLLB 翻译(模型不共驻) - 分片上传(断点续传) + SQLite 持久化 + 主页/历史/日志页面 - 历史页文件名搜索;缓存定时清理(默认保留7天,可配置) - 双 Dockerfile(cpu/gpu) + setup/start/stop 脚本
This commit is contained in:
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;
|
||||
}}
|
||||
"""
|
||||
Reference in New Issue
Block a user