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:
112
static/common.css
Normal file
112
static/common.css
Normal 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
87
static/common.js
Normal 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
18
static/file_browser.css
Normal 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
29
static/file_browser.html
Normal 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
94
static/file_browser.js
Normal 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
50
static/whiteboard.css
Normal 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
39
static/whiteboard.html
Normal 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
271
static/whiteboard.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/* 白板: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 {}
|
||||
});
|
||||
})();
|
||||
15
static/whiteboard_admin.css
Normal file
15
static/whiteboard_admin.css
Normal 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; }
|
||||
}
|
||||
30
static/whiteboard_admin.html
Normal file
30
static/whiteboard_admin.html
Normal 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>
|
||||
84
static/whiteboard_admin.js
Normal file
84
static/whiteboard_admin.js
Normal 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user