本次提交包含两批改动(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 全部通过。
272 lines
8.9 KiB
JavaScript
272 lines
8.9 KiB
JavaScript
/* 白板:Canvas 绘画 + WebSocket 实时同步 + 心跳。
|
||
- 笔画以 {points:[[x,y],...], color, width} 表示,增量广播。
|
||
- 心跳 3s 一次 ping;服务端 15s 无心跳判失活会主动断连,前端据此重连。
|
||
- 收到他人 stroke 增量重绘该笔;收到 cleared 清空本地画布。
|
||
- 兼容鼠标 + 触摸:统一用 pointer events,touch-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 {}
|
||
});
|
||
})();
|