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

271
static/whiteboard.js Normal file
View File

@@ -0,0 +1,271 @@
/* 白板Canvas 绘画 + WebSocket 实时同步 + 心跳。
- 笔画以 {points:[[x,y],...], color, width} 表示,增量广播。
- 心跳 3s 一次 ping服务端 15s 无心跳判失活会主动断连,前端据此重连。
- 收到他人 stroke 增量重绘该笔;收到 cleared 清空本地画布。
- 兼容鼠标 + 触摸:统一用 pointer eventstouch-action:none 防滚动缩放。 */
(function () {
"use strict";
const { el, toast, copyText } = window.ZK;
// ---------- 从 URL 解析 board_id ----------
// 路径形如 /whiteboard/{id}id 为 [a-zA-Z0-9_-]{1,64}
const m = location.pathname.match(/^\/whiteboard\/([^/]+)\/?$/);
let boardId = m ? decodeURIComponent(m[1]) : "default";
// 合法性兜底:前端非法字符直接回退到 default真正校验在服务端
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default";
document.getElementById("boardId").textContent = boardId;
// ---------- DOM ----------
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const colorInput = document.getElementById("color");
const widthInput = document.getElementById("width");
const widthVal = document.getElementById("widthVal");
const clearBtn = document.getElementById("clearBtn");
const copyBtn = document.getElementById("copyBtn");
const statusEl = document.getElementById("status");
const onlineEl = document.getElementById("online");
// ---------- 状态 ----------
let strokes = []; // 已确认的笔画
let current = null; // 正在画的笔画(本地未提交)
let drawing = false;
let ws = null;
let clientId = localStorage.getItem("wb_cid") || "";
if (!clientId) {
clientId = "c_" + Math.random().toString(36).slice(2, 10);
localStorage.setItem("wb_cid", clientId);
}
let heartbeatTimer = null;
let reconnectTimer = null;
let connected = false;
// ---------- 画布尺寸 ----------
function resize() {
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = Math.max(1, Math.floor(w * dpr));
canvas.height = Math.max(1, Math.floor(h * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
redraw();
}
window.addEventListener("resize", resize);
// ---------- 绘制 ----------
function drawStroke(s) {
if (!s || !s.points || s.points.length < 1) return;
ctx.strokeStyle = s.color || "#1565c0";
ctx.lineWidth = Number(s.width) || 3;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const pts = s.points;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
if (pts.length === 1) {
// 单点:画一个小圆点
ctx.arc(pts[0][0], pts[0][1], (ctx.lineWidth || 3) / 2, 0, Math.PI * 2);
ctx.fillStyle = ctx.strokeStyle;
ctx.fill();
return;
}
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.stroke();
}
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const s of strokes) drawStroke(s);
if (current) drawStroke(current);
}
// ---------- 指针事件 ----------
function pos(e) {
const r = canvas.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
canvas.addEventListener("pointerdown", (e) => {
if (!connected) { flashStatus("未连接,正在重连…"); return; }
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
drawing = true;
current = { points: [pos(e)], color: colorInput.value, width: Number(widthInput.value) };
drawStroke(current);
});
canvas.addEventListener("pointermove", (e) => {
if (!drawing) return;
e.preventDefault();
const p = pos(e);
const last = current.points[current.points.length - 1];
// 跳过过近的点,减少数据量
if (Math.hypot(p[0] - last[0], p[1] - last[1]) < 1.5) return;
current.points.push(p);
// 增量画最后一段
ctx.strokeStyle = current.color;
ctx.lineWidth = current.width;
ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(last[0], last[1]);
ctx.lineTo(p[0], p[1]);
ctx.stroke();
});
function endStroke(e) {
if (!drawing) return;
drawing = false;
if (e && e.pointerId !== undefined) {
try { canvas.releasePointerCapture(e.pointerId); } catch {}
}
if (current && current.points.length) {
strokes.push(current);
send({ type: "stroke", stroke: current });
}
current = null;
}
canvas.addEventListener("pointerup", endStroke);
canvas.addEventListener("pointercancel", endStroke);
canvas.addEventListener("pointerleave", (e) => {
// 仅在抬起时结束离开但按住不放不结束pointer capture 已处理)
if (!drawing) return;
if (e.buttons === 0) endStroke(e);
});
widthInput.addEventListener("input", () => (widthVal.textContent = widthInput.value));
clearBtn.addEventListener("click", () => {
if (!connected) { flashStatus("未连接"); return; }
if (!confirm("确定清空白板?所有人的内容都会被清除。")) return;
send({ type: "clear" });
});
copyBtn.addEventListener("click", async () => {
const url = `${location.origin}/whiteboard/${boardId}`;
const ok = await copyText(url);
toast(ok ? "链接已复制" : "复制失败");
});
// ---------- WebSocket ----------
function wsUrl() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}/ws/whiteboard/${encodeURIComponent(boardId)}`;
}
function connect() {
setStatus("连接中…");
try {
ws = new WebSocket(wsUrl());
} catch (e) {
scheduleReconnect();
return;
}
ws.onopen = () => {
connected = true;
setStatus("已连接", true);
onlineEl.classList.remove("off");
send({ type: "hello", client_id: clientId });
startHeartbeat();
};
ws.onmessage = (ev) => onMessage(ev.data);
ws.onclose = () => onLost("连接已关闭");
ws.onerror = () => { /* close 会跟进 */ };
}
function onMessage(raw) {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
switch (msg.type) {
case "init":
strokes = Array.isArray(msg.strokes) ? msg.strokes : [];
redraw();
setStatus(`已同步 ${strokes.length}`, true);
break;
case "pong":
// 心跳回声,保持连接
break;
case "stroke":
if (msg.client_id === clientId) break; // 自己的,已本地画
strokes.push(msg.stroke);
drawStroke(msg.stroke);
break;
case "cleared":
strokes = [];
current = null;
redraw();
flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了白板");
break;
case "error":
flashStatus(msg.msg || "错误", true);
break;
}
}
function send(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
try { ws.send(JSON.stringify(obj)); } catch {}
}
}
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(() => send({ type: "ping" }), 3000);
}
function stopHeartbeat() {
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
}
function onLost(reason) {
connected = false;
stopHeartbeat();
onlineEl.classList.add("off");
setStatus(reason + ",重连中…", true);
scheduleReconnect();
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 2000);
}
function setStatus(text, isErr) {
statusEl.textContent = text;
statusEl.classList.add("show");
statusEl.classList.toggle("err", !!isErr);
}
let statusTimer = null;
function flashStatus(text, isErr) {
setStatus(text, isErr);
clearTimeout(statusTimer);
statusTimer = setTimeout(() => statusEl.classList.remove("show"), 1600);
}
// ---------- 启动 ----------
// 先 GET /whiteboard/{id} 确保白板存在(不存在则服务端新建),再连 WS
fetch(`/whiteboard/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } })
.then((r) => r.ok ? r.json() : null)
.then((body) => {
if (body && Array.isArray(body.strokes)) {
strokes = body.strokes;
redraw();
}
resize();
connect();
})
.catch(() => { resize(); connect(); });
// 页面隐藏时不发心跳可被服务端判失活;重新可见时主动重连
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && (!ws || ws.readyState !== WebSocket.OPEN)) {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
connect();
}
});
window.addEventListener("beforeunload", () => {
stopHeartbeat();
if (reconnectTimer) clearTimeout(reconnectTimer);
try { ws && ws.close(); } catch {}
});
})();