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 全部通过。
This commit is contained in:
zikai
2026-07-21 14:28:13 +00:00
parent e5a725fc91
commit fffba79022
48 changed files with 3569 additions and 216 deletions

288
app/views/upload_html.py Normal file
View File

@@ -0,0 +1,288 @@
"""上传页面 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); }}
}}
"""