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

112
static/common.css Normal file
View File

@@ -0,0 +1,112 @@
/* zikai 共享前端样式:与 upload_html / system_status_html 风格统一。
深色模式自适应、卡片、主色 #1565c0。各页面在此基础上叠加专属样式。 */
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: rgba(0, 0, 0, 0.02);
--border: #e0e3e7;
--text: #1a1a1a;
--text-dim: #6b7280;
--primary: #1565c0;
--primary-strong: #0d47a1;
--primary-soft: rgba(21, 101, 192, 0.1);
--success: #2e7d32;
--success-soft: #e8f5e9;
--danger: #c62828;
--danger-soft: #ffebee;
--warn: #e65100;
--warn-soft: #fff3e0;
--radius: 10px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #15161a;
--surface: #1c1d22;
--surface-2: rgba(255, 255, 255, 0.04);
--border: #2c2d33;
--text: #e6e6e6;
--text-dim: #9aa0a6;
--primary: #5b9bf0;
--primary-strong: #82b4f5;
--primary-soft: rgba(91, 155, 240, 0.16);
--success: #66bb6a;
--success-soft: rgba(102, 187, 106, 0.16);
--danger: #ef5350;
--danger-soft: rgba(239, 83, 80, 0.16);
--warn: #fb8c00;
--warn-soft: rgba(251, 140, 0, 0.16);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
"PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 960px; margin: 0 auto; padding: 2em 1.2em 4em; }
h1 { margin: 0 0 0.2em; font-size: 1.6em; letter-spacing: -0.01em; }
.sub { color: var(--text-dim); margin: 0 0 1.6em; font-size: 0.95em; }
.card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1em 1.2em;
margin: 0.8em 0;
background: var(--surface);
box-shadow: var(--shadow);
}
.toolbar { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; margin: 0 0 1.2em; }
.btn {
display: inline-flex; align-items: center; gap: 0.4em;
padding: 0.5em 1.1em; border-radius: 8px;
border: 1px solid var(--border); background: var(--surface);
color: var(--text); cursor: pointer; font-size: 0.92em;
transition: background 0.15s, border-color 0.15s, transform 0.05s;
user-select: none;
}
.btn:hover { background: var(--surface-2); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { background: var(--primary-strong); }
.btn.danger { color: var(--danger); border-color: var(--danger-soft); }
.btn.danger:hover { background: var(--danger-soft); }
.btn.ghost { background: transparent; }
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
th, td { text-align: left; padding: 0.6em 0.8em; border-bottom: 1px solid var(--border); }
th { color: var(--text-dim); font-weight: 600; font-size: 0.82em;
text-transform: uppercase; letter-spacing: 0.04em; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--surface-2); }
.mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.85em; }
.dim { color: var(--text-dim); }
.muted { color: var(--text-dim); font-size: 0.82em; }
.tag {
display: inline-block; padding: 0.1em 0.6em; border-radius: 10px;
font-size: 0.78em; background: var(--surface-2); color: var(--text-dim);
}
.tag.ok { background: var(--success-soft); color: var(--success); }
.tag.warn { background: var(--warn-soft); color: var(--warn); }
.tag.err { background: var(--danger-soft); color: var(--danger); }
a.link { color: var(--primary); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.foot { color: var(--text-dim); font-size: 0.82em; margin-top: 2em; text-align: center; }
.toast {
position: fixed; bottom: 1.5em; left: 50%; transform: translateX(-50%);
background: var(--text); color: var(--bg);
padding: 0.6em 1.2em; border-radius: 8px; font-size: 0.88em;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
opacity: 0; transition: opacity 0.2s, transform 0.2s; pointer-events: none;
z-index: 50;
}
.toast.show { opacity: 0.95; transform: translateX(-50%) translateY(-4px); }
.copyable { cursor: pointer; }
.copyable:hover { color: var(--primary); }
.empty { text-align: center; color: var(--text-dim); padding: 3em 1em; font-size: 0.95em; }
.skel { color: var(--text-dim); padding: 2em; text-align: center; }

87
static/common.js Normal file
View File

@@ -0,0 +1,87 @@
/* zikai 共享前端工具toast、复制、字节/时间格式化、fetch 封装。
Basic Auth 同源时浏览器自动带缓存的凭据无需额外处理fetch 默认 same-origin。 */
(function (global) {
"use strict";
function el(tag, attrs, ...children) {
const node = document.createElement(tag);
if (attrs) {
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") node.className = v;
else if (k === "dataset") Object.assign(node.dataset, v);
else if (k.startsWith("on") && typeof v === "function")
node.addEventListener(k.slice(2).toLowerCase(), v);
else if (v !== null && v !== undefined) node.setAttribute(k, v);
}
}
for (const c of children) {
if (c == null || c === false) continue;
node.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
}
return node;
}
let toastTimer = null;
function toast(msg, kind) {
let node = document.querySelector(".toast");
if (!node) {
node = el("div", { class: "toast" });
document.body.appendChild(node);
}
node.textContent = msg;
node.className = "toast show" + (kind ? " " + kind : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (node.className = "toast"), 2200);
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// 降级:临时 textarea
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand("copy"); } catch {}
document.body.removeChild(ta);
return ok;
}
}
function fmtBytes(n) {
if (n == null) return "-";
const x = Number(n);
if (!isFinite(x)) return "-";
for (const unit of ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]) {
if (Math.abs(x) < 1024 || unit === "PiB")
return unit === "B" ? `${x} B` : `${x.toFixed(1)} ${unit}`;
n = x / 1024;
}
return `${n.toFixed(1)} PiB`;
}
function fmtTime(s) {
if (!s) return "-";
const d = new Date(s);
if (isNaN(d.getTime())) return s;
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
async function api(path, opts) {
const res = await fetch(path, opts);
if (res.status === 401) {
// 触发浏览器 Basic Auth 弹窗(同源 reload 即可带上凭据)
toast("需要登录");
throw new Error("UNAUTHORIZED");
}
return res;
}
global.ZK = { el, toast, copyText, fmtBytes, fmtTime, api };
})(window);

18
static/file_browser.css Normal file
View File

@@ -0,0 +1,18 @@
/* 文件浏览页专属样式。表格、行内操作、sha 截断。 */
.files-table th.col-name { min-width: 30%; }
.files-table th.col-size { width: 9em; }
.files-table th.col-src { width: 6em; }
.files-table th.col-time { width: 11em; }
.files-table th.col-act { width: 9em; text-align: right; }
.files-table td.col-act { text-align: right; white-space: nowrap; }
.files-table td.col-name .fname { font-weight: 600; word-break: break-all; }
.files-table td.col-name .sha { display: block; margin-top: 0.15em; color: var(--text-dim); }
.files-table td.col-act .btn { padding: 0.3em 0.8em; font-size: 0.85em; margin-left: 0.3em; }
.sha-short { cursor: pointer; }
.sha-short:hover { color: var(--primary); }
.row-removed { opacity: 0; transition: opacity 0.25s; }
@media (max-width: 640px) {
.files-table th, .files-table td { padding: 0.5em 0.4em; }
.files-table th.col-src, .files-table td.col-src { display: none; }
.files-table th.col-time, .files-table td.col-time { font-size: 0.78em; }
}

29
static/file_browser.html Normal file
View File

@@ -0,0 +1,29 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>文件浏览 - zikai</title>
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/file_browser.css">
</head>
<body>
<div class="wrap">
<h1>文件浏览</h1>
<p class="sub">查看已上传的文件、下载或删除。删除后不再显示。</p>
<div class="toolbar">
<button class="btn primary" id="refresh">刷新</button>
<span class="muted" id="count"></span>
</div>
<div id="list">
<div class="skel">加载中…</div>
</div>
<p class="foot"><a class="link" href="/upload">上传文件</a> · zikai file service</p>
</div>
<script src="/static/common.js"></script>
<script src="/static/file_browser.js"></script>
</body>
</html>

94
static/file_browser.js Normal file
View File

@@ -0,0 +1,94 @@
/* 文件浏览页:拉取 /api/admin/files、渲染表格、下载/删除/复制 sha。
每次进入或删除后重新拉取,前端不缓存列表,保证已删除文件不显示。 */
(function () {
"use strict";
const { el, toast, copyText, fmtBytes, fmtTime, api } = window.ZK;
const listEl = document.getElementById("list");
const countEl = document.getElementById("count");
const refreshBtn = document.getElementById("refresh");
refreshBtn.addEventListener("click", load);
async function load() {
listEl.innerHTML = '<div class="skel">加载中…</div>';
countEl.textContent = "";
try {
const res = await api("/api/admin/files?limit=500&offset=0");
if (!res.ok) throw new Error("HTTP " + res.status);
const body = await res.json();
render(body.items || []);
countEl.textContent = `${body.total} 个文件`;
} catch (e) {
listEl.innerHTML = '<div class="empty">加载失败:' + (e.message || e) + "</div>";
}
}
function render(items) {
if (!items.length) {
listEl.innerHTML = '<div class="empty">还没有文件。去 <a class="link" href="/upload">上传</a> 一个吧。</div>';
return;
}
const table = el("table", { class: "files-table" });
const thead = el("thead", null,
el("tr", null,
el("th", { class: "col-name" }, "文件名 / SHA-256"),
el("th", { class: "col-size" }, "大小"),
el("th", { class: "col-src" }, "来源"),
el("th", { class: "col-time" }, "上传时间"),
el("th", { class: "col-act" }, "操作")
)
);
const tbody = el("tbody", null);
for (const f of items) {
const row = el("tr", null,
el("td", { class: "col-name" },
el("div", { class: "fname" }, f.original_filename),
el("span", { class: "sha mono sha-short", title: "点击复制完整 SHA-256" },
shortSha(f.sha256))
),
el("td", { class: "col-size mono" }, fmtBytes(f.size_bytes)),
el("td", { class: "col-src" }, el("span", { class: "tag" }, f.source || "-")),
el("td", { class: "col-time muted" }, fmtTime(f.uploaded_at)),
el("td", { class: "col-act" },
el("a", { class: "btn primary", href: `/api/admin/files/${f.id}/download`, download: "" }, "下载"),
el("button", { class: "btn danger", onclick: () => remove(f, row) }, "删除")
)
);
const shaNode = row.querySelector(".sha-short");
shaNode.addEventListener("click", async () => {
const ok = await copyText(f.sha256);
toast(ok ? "已复制 SHA-256" : "复制失败");
});
tbody.appendChild(row);
}
table.appendChild(thead);
table.appendChild(tbody);
listEl.innerHTML = "";
listEl.appendChild(table);
}
function shortSha(sha) {
if (!sha) return "-";
return sha.length > 16 ? sha.slice(0, 12) + "…" + sha.slice(-4) : sha;
}
async function remove(f, row) {
if (!confirm(`确定删除「${f.original_filename}」?\n此操作不可恢复,将同时删除磁盘文件。`)) return;
try {
const res = await api(`/api/admin/files/${f.id}`, { method: "DELETE" });
if (!res.ok) throw new Error("HTTP " + res.status);
const body = await res.json();
if (!body.deleted) { toast("文件已不存在"); }
row.classList.add("row-removed");
setTimeout(() => row.remove(), 250);
toast("已删除");
// 更新计数
const m = (countEl.textContent || "").match(/(\d+)/);
if (m) countEl.textContent = `${Math.max(0, Number(m[1]) - 1)} 个文件`;
} catch (e) {
toast("删除失败:" + (e.message || e), "err");
}
}
load();
})();

50
static/whiteboard.css Normal file
View File

@@ -0,0 +1,50 @@
/* 白板页专属样式:全屏画布、悬浮工具栏、移动端适配。 */
:root { --bar-h: 52px; }
body { overflow: hidden; background: var(--bg); }
.wb-app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
.wb-bar {
display: flex; align-items: center; justify-content: space-between;
gap: 0.6em; padding: 0.5em 0.9em;
background: var(--surface); border-bottom: 1px solid var(--border);
box-shadow: var(--shadow); min-height: var(--bar-h);
flex-wrap: wrap;
}
.wb-bar-left, .wb-bar-right { display: flex; align-items: center; gap: 0.6em; }
.wb-title { font-weight: 700; font-size: 1.05em; }
.wb-id { background: var(--surface-2); padding: 0.15em 0.5em; border-radius: 6px; font-size: 0.82em; color: var(--text-dim); }
.wb-online { color: var(--success); font-size: 0.7em; }
.wb-online.off { color: var(--text-dim); }
.wb-tool { display: inline-flex; align-items: center; gap: 0.3em; font-size: 0.85em; color: var(--text-dim); }
.wb-tool input[type="color"] { width: 28px; height: 28px; padding: 0; border: 1px solid var(--border); border-radius: 6px; background: transparent; cursor: pointer; }
.wb-tool input[type="range"] { width: 80px; accent-color: var(--primary); }
.wb-width-val { width: 1.4em; text-align: center; }
.wb-bar .btn { padding: 0.4em 0.9em; font-size: 0.86em; }
.wb-stage { position: relative; flex: 1; overflow: hidden; }
#canvas {
position: absolute; inset: 0; width: 100%; height: 100%;
display: block; touch-action: none; cursor: crosshair;
background:
linear-gradient(var(--border) 1px, transparent 1px) 0 0 / 24px 24px,
linear-gradient(90deg, var(--border) 1px, transparent 1px) 0 0 / 24px 24px,
var(--surface);
background-blend-mode: normal;
}
.wb-status {
position: absolute; bottom: 0.8em; left: 50%; transform: translateX(-50%);
background: var(--surface); border: 1px solid var(--border);
padding: 0.3em 0.9em; border-radius: 16px; font-size: 0.8em;
color: var(--text-dim); box-shadow: var(--shadow);
opacity: 0; transition: opacity 0.3s; pointer-events: none;
}
.wb-status.show { opacity: 1; }
.wb-status.err { color: var(--danger); border-color: var(--danger-soft); }
/* 移动端:工具栏紧凑、按钮变大易触 */
@media (max-width: 640px) {
.wb-bar { padding: 0.4em 0.5em; gap: 0.4em; }
.wb-id { max-width: 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wb-tool input[type="range"] { width: 56px; }
.wb-bar .btn { padding: 0.45em 0.7em; }
.wb-title { display: none; }
}

39
static/whiteboard.html Normal file
View File

@@ -0,0 +1,39 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="theme-color" content="#1565c0">
<title>白板 - zikai</title>
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/whiteboard.css">
</head>
<body>
<div class="wb-app">
<header class="wb-bar">
<div class="wb-bar-left">
<span class="wb-title">白板</span>
<code class="wb-id mono" id="boardId" title="白板 ID"></code>
<span class="wb-online" id="online" title="在线人数"></span>
</div>
<div class="wb-bar-right">
<label class="wb-tool" title="笔画颜色">
<input type="color" id="color" value="#1565c0">
</label>
<label class="wb-tool" title="笔画粗细">
<input type="range" id="width" min="1" max="24" value="3">
<span class="wb-width-val mono" id="widthVal">3</span>
</label>
<button class="btn" id="copyBtn" title="复制分享链接">复制链接</button>
<button class="btn danger" id="clearBtn" title="清空白板(所有人)">清空</button>
</div>
</header>
<main class="wb-stage">
<canvas id="canvas"></canvas>
<div class="wb-status" id="status">连接中…</div>
</main>
</div>
<script src="/static/common.js"></script>
<script src="/static/whiteboard.js"></script>
</body>
</html>

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 {}
});
})();

View File

@@ -0,0 +1,15 @@
/* 白板管理页专属样式。 */
.wb-admin-table th.col-id { min-width: 28%; }
.wb-admin-table th.col-mods { width: 7em; }
.wb-admin-table th.col-created { width: 11em; }
.wb-admin-table th.col-updated { width: 11em; }
.wb-admin-table th.col-act { width: 7em; text-align: right; }
.wb-admin-table td.col-act { text-align: right; }
.wb-admin-table td.col-act .btn { padding: 0.3em 0.8em; font-size: 0.85em; }
.wb-admin-table td.col-id .bid { font-weight: 600; word-break: break-all; }
.wb-admin-table td.col-mods { text-align: right; }
.row-removed { opacity: 0; transition: opacity 0.25s; }
@media (max-width: 640px) {
.wb-admin-table th, .wb-admin-table td { padding: 0.5em 0.4em; }
.wb-admin-table th.col-created, .wb-admin-table td.col-created { display: none; }
}

View File

@@ -0,0 +1,30 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>白板管理 - zikai</title>
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/whiteboard_admin.css">
</head>
<body>
<div class="wrap">
<h1>白板管理</h1>
<p class="sub">查看所有白板的创建时间、修改次数、上次修改时间;可删除。</p>
<div class="toolbar">
<button class="btn primary" id="refresh">刷新</button>
<button class="btn" id="newBtn">新建并打开</button>
<span class="muted" id="count"></span>
</div>
<div id="list">
<div class="skel">加载中…</div>
</div>
<p class="foot"><a class="link" href="/upload">上传文件</a> · <a class="link" href="/files">文件浏览</a> · zikai</p>
</div>
<script src="/static/common.js"></script>
<script src="/static/whiteboard_admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,84 @@
/* 白板管理页:拉取 /api/admin/whiteboards、渲染表格、删除、新建并跳转。 */
(function () {
"use strict";
const { el, toast, fmtTime, api } = window.ZK;
const listEl = document.getElementById("list");
const countEl = document.getElementById("count");
const refreshBtn = document.getElementById("refresh");
const newBtn = document.getElementById("newBtn");
refreshBtn.addEventListener("click", load);
newBtn.addEventListener("click", () => {
// 生成一个随机 board_id 并打开(访问即创建)
const id = "b_" + Math.random().toString(36).slice(2, 10);
window.open(`/whiteboard/${id}`, "_blank");
});
async function load() {
listEl.innerHTML = '<div class="skel">加载中…</div>';
countEl.textContent = "";
try {
const res = await api("/api/admin/whiteboards?limit=500&offset=0");
if (!res.ok) throw new Error("HTTP " + res.status);
const body = await res.json();
render(body.items || []);
countEl.textContent = `${body.total} 个白板`;
} catch (e) {
listEl.innerHTML = '<div class="empty">加载失败:' + (e.message || e) + "</div>";
}
}
function render(items) {
if (!items.length) {
listEl.innerHTML = '<div class="empty">还没有白板。点「新建并打开」创建一个。</div>';
return;
}
const table = el("table", { class: "wb-admin-table" });
const thead = el("thead", null,
el("tr", null,
el("th", { class: "col-id" }, "白板 ID"),
el("th", { class: "col-mods" }, "修改次数"),
el("th", { class: "col-created" }, "创建时间"),
el("th", { class: "col-updated" }, "上次修改"),
el("th", { class: "col-act" }, "操作")
)
);
const tbody = el("tbody", null);
for (const b of items) {
const row = el("tr", null,
el("td", { class: "col-id" },
el("a", { class: "bid link", href: `/whiteboard/${b.board_id}`, target: "_blank" }, b.board_id)
),
el("td", { class: "col-mods mono" }, String(b.stroke_count ?? 0)),
el("td", { class: "col-created muted" }, fmtTime(b.created_at)),
el("td", { class: "col-updated muted" }, fmtTime(b.updated_at)),
el("td", { class: "col-act" },
el("button", { class: "btn danger", onclick: () => remove(b, row) }, "删除")
)
);
tbody.appendChild(row);
}
table.appendChild(thead);
table.appendChild(tbody);
listEl.innerHTML = "";
listEl.appendChild(table);
}
async function remove(b, row) {
if (!confirm(`确定删除白板「${b.board_id}」?\n所有在线协作者会被断开,内容不可恢复。`)) return;
try {
const res = await api(`/api/admin/whiteboards/${encodeURIComponent(b.board_id)}`, { method: "DELETE" });
if (res.status === 404) { toast("白板已不存在"); }
else if (!res.ok) throw new Error("HTTP " + res.status);
row.classList.add("row-removed");
setTimeout(() => row.remove(), 250);
toast("已删除");
const m = (countEl.textContent || "").match(/(\d+)/);
if (m) countEl.textContent = `${Math.max(0, Number(m[1]) - 1)} 个白板`;
} catch (e) {
toast("删除失败:" + (e.message || e), "err");
}
}
load();
})();