Files
zTools2/app/views/upload_html.py
zikai 7c193ca46e 前端整理: 合并文件管理页 + 新增导航页
1. 合并 files-page 与 pdf-admin 为统一文件管理页 /api/files-page:
   - 新增 GET /api/admin/files/with-pdf 接口,以 uploaded_files 为基础,
     用 pdf_jobs.source_file_id / output_file_id 内存匹配,为关联文件标注
     转换状态、用户软删标记与角色(源epub/产物PDF)
   - PdfJobOut 补 source_file_id / output_file_id 字段
   - file_browser 新增 PDF 任务列(状态徽标/软删标记/硬删任务按钮)
   - 删除 /api/pdf-admin 路由及 static/pdf_admin.* 三个文件

2. 新增导航页 /api/index,卡片式收集所有页面入口;
   各子页(upload/whiteboard/system_status/files-page)脚注加返回导航链接

3. 更新 docs/routes.md、docs/configuration.md 同步说明

测试: pytest tests/test_pdf_service.py 7 passed; 手动校验各页面路由与合并接口响应
2026-07-28 14:04:12 +08:00

287 lines
10 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.

"""上传页面 HTML 渲染(拖拽 + 多文件 + 分片 + 断点续传)。
单文件 server-rendered内联 CSS+JS风格对齐 system_status_html.py
深色模式自适应、卡片、进度条。无构建步骤、无外部依赖。
"""
from __future__ import annotations
# 默认分片大小 4 MiB大于 Apache 300s 限制下单片可数秒传完,小到内存恒定。
DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
# 同一文件分片并发数
DEFAULT_CONCURRENCY = 3
# 单分片失败重试次数
MAX_RETRY = 2
def render() -> str:
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>上传文件 — zikai</title>
<style>{_CSS}</style>
</head>
<body>
<h1>上传文件</h1>
<p class="sub">拖拽文件到下方,或点击选择。支持多文件、大文件分片上传与断点续传。</p>
<div id="drop" class="drop">
<p class="drop-hint">把文件拖到这里,或</p>
<label class="btn">选择文件<input id="file-input" type="file" multiple hidden></label>
</div>
<div id="tasks" class="tasks"></div>
<div id="summary" class="foot"></div>
<p class="foot"><a class="json" href="/api/index">导航</a> · <a class="json" href="/api/system/status">系统状态</a> · zikai file service</p>
<script>
const CHUNK_SIZE = {DEFAULT_CHUNK_SIZE};
const CONCURRENCY = {DEFAULT_CONCURRENCY};
const MAX_RETRY = {MAX_RETRY};
const API = "/api/files/chunk-uploads";
const tasksEl = document.getElementById("tasks");
const summaryEl = document.getElementById("summary");
const fileInput = document.getElementById("file-input");
const drop = document.getElementById("drop");
let pending = []; // 等待开始的文件
let done = 0, failed = 0;
drop.addEventListener("dragover", e => {{ e.preventDefault(); drop.classList.add("drag"); }});
drop.addEventListener("dragleave", () => drop.classList.remove("drag"));
drop.addEventListener("drop", e => {{
e.preventDefault();
drop.classList.remove("drag");
addFiles(e.dataTransfer.files);
}});
fileInput.addEventListener("change", () => addFiles(fileInput.files));
function addFiles(fileList) {{
for (const f of fileList) {{
const task = makeTask(f);
pending.push(task);
tasksEl.appendChild(task.el);
}}
fileInput.value = "";
pump();
}}
function makeTask(file) {{
const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE));
const el = document.createElement("div");
el.className = "card 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>
<div class="task-meta"></div>
`;
el.querySelector(".fname").textContent = file.name;
el.querySelector(".fsize").textContent = fmtBytes(file.size);
return {{
file, totalChunks, el,
uploadId: null,
uploaded: new Set(),
state: "pending",
cancel: false,
}};
}}
// 限制并发文件数(同时最多 CONCURRENCY 个文件在传)
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";
startTask(t).finally(() => {{
pending = pending.filter(x => x !== t);
pump();
renderSummary();
}});
active++;
}}
}}
}}
function setState(t, s) {{
t.state = s;
const map = {{pending:"等待中", running:"上传中", hashing:"拼接中", done:"完成", dedup:"秒传", fail:"失败"}};
t.el.querySelector(".fstate").textContent = map[s] || s;
t.el.querySelector(".fstate").className = "fstate state-" + s;
}}
function setProgress(t, pct) {{
t.el.querySelector(".fill").style.width = pct.toFixed(1) + "%";
t.el.querySelector(".pct").textContent = pct.toFixed(0) + "%";
}}
async function startTask(t) {{
try {{
// 1. 创建会话
const cre = await fetch(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;
// 2. 查状态(断点续传:补传缺失分片)
const st = await fetch(API + "/" + t.uploadId + "/status");
const status = await st.json();
if (status.completed && status.file_id != null) {{
// 之前已完成
setState(t, "dedup");
setProgress(t, 100);
t.el.querySelector(".task-meta").textContent = "file_id=" + status.file_id + "(已完成)";
done++;
return;
}}
(status.uploaded_chunks || []).forEach(i => t.uploaded.add(i));
// 3. 传缺失分片
const need = [];
for (let i = 0; i < t.totalChunks; i++) if (!t.uploaded.has(i)) need.push(i);
await runPool(need, i => uploadChunk(t, i));
if (t.cancel) return;
if (t.uploaded.size < t.totalChunks) throw new Error("部分分片未能上传");
// 4. complete
setState(t, "hashing");
setProgress(t, 100);
const cmp = await fetch(API + "/" + t.uploadId + "/complete", {{ method: "POST" }});
if (!cmp.ok) throw new Error("complete 失败: " + await cmp.text());
const res = await cmp.json();
setState(t, res.deduplicated ? "dedup" : "done");
t.el.querySelector(".task-meta").innerHTML =
"file_id=" + res.id + " · sha256=<code>" + escapeHtml(res.sha256.slice(0,16)) + "…</code>" +
(res.deduplicated ? " · <b>秒传</b>(服务端已有相同内容)" : "");
done++;
}} catch (e) {{
setState(t, "fail");
t.el.querySelector(".task-meta").textContent = String(e.message || e);
failed++;
}}
}}
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++) {{
if (t.cancel) return;
try {{
const r = await fetch(API + "/" + t.uploadId + "/chunks/" + index, {{
method: "POST",
body: blob,
}});
if (!r.ok) throw new Error("HTTP " + r.status);
t.uploaded.add(index);
const pct = (t.uploaded.size / t.totalChunks) * 100;
setProgress(t, pct);
return;
}} catch (e) {{
lastErr = e;
}}
}}
throw lastErr;
}}
// 简易并发池:对 indices 逐个跑,最多 CONCURRENCY 并发
async function runPool(indices, 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);
}}
function renderSummary() {{
if (done + failed === 0) {{ summaryEl.textContent = ""; return; }}
summaryEl.textContent = "完成 " + done + ",失败 " + failed;
}}
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];
}}
function escapeHtml(s) {{
return s.replace(/[&<>"]/g, c => ({{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}}[c]));
}}
</script>
</body>
</html>
"""
_CSS = """
:root { color-scheme: light dark; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 880px; margin: 2em auto; padding: 0 1em; line-height: 1.5; }
h1 { margin-bottom: 0.1em; }
.sub { color: #777; margin-top: 0; font-size: 0.95em; }
.drop { border: 2px dashed #bbb; border-radius: 12px; padding: 2.5em 1em;
text-align: center; margin: 1.5em 0; transition: background 0.15s, border-color 0.15s; }
.drop.drag { background: rgba(21,101,192,0.08); border-color: #1565c0; }
.drop-hint { margin: 0 0 1em; color: #888; }
.btn { display: inline-block; padding: 0.5em 1.2em; border-radius: 6px;
background: #1565c0; color: #fff; cursor: pointer; font-size: 0.95em; }
.btn:hover { background: #0d47a1; }
.btn input { display: none; }
.tasks { margin: 1em 0; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 0.9em 1.1em;
margin: 0.7em 0; background: rgba(0,0,0,0.02); }
.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: #888; font-size: 0.85em; white-space: nowrap; }
.fstate { font-size: 0.85em; padding: 0.1em 0.6em; border-radius: 10px;
background: #eee; white-space: nowrap; }
.state-running { background: #e3f2fd; color: #1565c0; }
.state-hashing { background: #fff3e0; color: #e65100; }
.state-done { background: #e8f5e9; color: #2e7d32; }
.state-dedup { background: #f3e5f5; color: #7b1fa2; }
.state-fail { background: #ffebee; color: #c62828; }
.bar { position: relative; background: #e6e6e6; border-radius: 4px;
height: 18px; width: 100%; overflow: hidden; }
.bar .fill { height: 100%; width: 0; background: #43a047; transition: width 0.2s; }
.bar .pct { position: absolute; top: 0; left: 0; width: 100%; height: 100%;
text-align: center; font-size: 12px; line-height: 18px;
color: #fff; mix-blend-mode: difference; }
.task-meta { margin-top: 0.5em; font-size: 0.82em; color: #666; word-break: break-all; }
.task-meta code { background: rgba(0,0,0,0.06); padding: 0 0.3em; border-radius: 3px; }
.foot { color: #888; font-size: 0.85em; margin-top: 1.5em; text-align: center; }
a.json { color: #1565c0; text-decoration: none; }
@media (prefers-color-scheme: dark) {{
body {{ background: #1a1a1a; color: #e0e0e0; }}
.card {{ background: rgba(255,255,255,0.04); border-color: #333; }}
.drop {{ border-color: #555; }}
.drop.drag {{ background: rgba(21,101,192,0.18); }}
.fstate {{ background: #333; }}
.bar {{ background: #333; }}
.task-meta code {{ background: rgba(255,255,255,0.08); }}
}}
"""