问题:GET /whiteboard/{board_id} 同时被 REST(返回 JSON)与 main.py 的 HTML 页面
注册,FastAPI 按注册顺序匹配到 REST,导致浏览器访问拿到 JSON 而非前端页面。
改为按职责分命名空间,避免冲突:
- HTML 页面:/wb/{id}(main.py)、/wb-admin(main.py,Basic Auth)
- 公开 REST:GET /api/wb/{id}(前端 init 拉取初始笔画)
- WS:/ws/wb/{id}(实时同步 + 心跳)
- 管理 REST:GET /api/admin/wb、DELETE /api/admin/wb/{id}(Basic Auth)
前端 whiteboard.js / whiteboard_admin.js、测试脚本、README 路径同步更新。
旧 /whiteboard/* 路径不再注册(404)。
272 lines
8.8 KiB
JavaScript
272 lines
8.8 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 ----------
|
||
// 路径形如 /wb/{id};id 为 [a-zA-Z0-9_-]{1,64}
|
||
const m = location.pathname.match(/^\/wb\/([^/]+)\/?$/);
|
||
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}/wb/${boardId}`;
|
||
const ok = await copyText(url);
|
||
toast(ok ? "链接已复制" : "复制失败");
|
||
});
|
||
|
||
// ---------- WebSocket ----------
|
||
function wsUrl() {
|
||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||
return `${proto}//${location.host}/ws/wb/${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 /api/wb/{id} 确保白板存在(不存在则服务端新建),再连 WS
|
||
fetch(`/api/wb/${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 {}
|
||
});
|
||
})();
|