Files
zTools2/app/views/upload_html.py
zikai fffba79022 feat: 文件浏览页 + 共享白板 + 白板管理页(含补登记分片上传/隧道历史改动)
本次提交包含两批改动(7月2日遗留未入库 + 本次新功能),分述如下:

【补登记:7月2日已上线但未提交的功能】
- 分片上传:chunk_upload_controller/service/dao + UploadSession model/schema,
  支持 4MiB 分片、乱序、断点续传、去重、幂等 complete;后台 reaper 清理过期会话。
- 反向隧道:tunnel_controller/service/dao + TunnelSession model/schema,
  SSH remote forward 经 /api/userPort/{userName} 反代到 user 本地服务。
- 上传页:views/upload_html.py(拖拽/多文件/分片/断点续传 UI)。
- config.py:StorageConfig.chunk_session_dir/ttl、TunnelConfig;
  requirements.txt 加 httpx;start.sh 清理 .work/ 残留;
  schema.sql 加 upload_session/tunnel_session 表;sftp_server 承载隧道转发。

【本次新功能】
- 文件浏览页:GET /files(Basic Auth 同 docs)+ /api/admin/files(list/get/download/DELETE)。
  硬删除(DB 行 + 磁盘文件),删除后列表不再显示。前端 static/file_browser.*。
- 共享白板:GET /whiteboard/{id}(公开,不存在则新建)+ WS /ws/whiteboard/{id}。
  MySQL 持久化(whiteboard 表),Canvas 实时同步,心跳 3s/5 次失活移除,
  清空/复制按钮,移动端兼容。WhiteboardHub 管理 {board_id: set[Connection]},
  disconnect 幂等 + 空 set 清理防泄漏,broadcast 失败连接自动移除。
- 白板管理页:GET /whiteboard-admin(Basic Auth)+ /api/admin/whiteboards(list/DELETE)。
  删除时 hub.close_board 踢出在线连接。
- 清理:合并 UploadService.get_out_with_disk_path(下载/删除复用,消除重复 DB 读),
  移除无用 resolve_disk_path。
- config.py:WhiteboardConfig(heartbeat/threshold/board_id 长度/list_limit);
  schema.sql 加 whiteboard 表;README 补新接口与心跳/内存说明。
- 验证:tests/manual_whiteboard_hub.py / _ws.py / _kick.py 全部通过。
2026-07-21 14:28:13 +00:00

289 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
from html import escape
# 默认分片大小 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/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); }}
}}
"""