后端修复: - 白板删除踢人失效:delete_whiteboard 改 async def,删除后直接 await hub.close_board()。原实现用 asyncio.get_running_loop() 在同步 REST handler (threadpool)里调用必抛 RuntimeError 被 except 吞掉,close_board 从不执行。 同时移除 service 的 hub 依赖(close_board 改由 controller 调用,service 只管 DB)。 - delete_file 去重复查询:原先 get_out_with_disk_path + get_by_id 查两次, 合并为一次;磁盘 unlink 失败加 logger.warning(原静默吞掉致磁盘泄漏无记录)。 - get_hub 单例加 threading.Lock 双重检查(防 REST threadpool 与 WS 事件循环 并发首访各建一个 hub)。 - file_controller 公开 /api/files list 加 Query(ge=1, le=10000) 约束(原无上限可 DoS)。 前端修复: - applyRemoteUpdate 有未发送编辑时重发:合并远端更新后若本地有 pending 编辑 (editor.value !== lastSentText)重新 scheduleSend,避免被 lastSentText 短路丢弃。 - init 不覆盖未发送编辑:断线重连后若本地有未发送内容,作为新版本发上去而非被 init 覆盖。 - applyRemoteUpdate 仅在编辑器已有焦点时恢复焦点,避免抢按钮焦点。 - api() 401 时 location.reload() 触发浏览器 Basic Auth 弹窗(原只 toast 卡死)。 README: - 精简重写,补全 Ubuntu 从 0 安装、Apache 反代(含 WS)、配置项表格、防火墙说明。
284 lines
9.8 KiB
JavaScript
284 lines
9.8 KiB
JavaScript
/* 记事本:textarea + WebSocket 实时同步 + 心跳。
|
||
- 文本以整文本 debounce 400ms 后发服务端,服务端存为新版本并广播给其他端。
|
||
- 收到他人 update 时用 diff 应用变更,保留本地光标位置(按相对偏移调整)。
|
||
- 收到 cleared 清空本地 textarea。
|
||
- 心跳 3s 一次 ping;服务端 15s 无心跳判失活会主动断连,前端据此重连。 */
|
||
(function () {
|
||
"use strict";
|
||
const { toast, copyText } = window.ZK;
|
||
|
||
// ---------- 从 URL 解析 board_id ----------
|
||
const m = location.pathname.match(/^\/wb\/([^/]+)\/?$/);
|
||
let boardId = m ? decodeURIComponent(m[1]) : "default";
|
||
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default";
|
||
document.getElementById("boardId").textContent = boardId;
|
||
|
||
// ---------- DOM ----------
|
||
const editor = document.getElementById("editor");
|
||
const clearBtn = document.getElementById("clearBtn");
|
||
const copyBtn = document.getElementById("copyBtn");
|
||
const copyLinkBtn = document.getElementById("copyLinkBtn");
|
||
const statusEl = document.getElementById("status");
|
||
const onlineEl = document.getElementById("online");
|
||
|
||
// ---------- 状态 ----------
|
||
let ws = null;
|
||
// 每个 tab 独立的 client_id(用 sessionStorage 而非 localStorage,避免同浏览器
|
||
// 多 tab 共享 id 导致收到的 update 被误判为「自己的」而跳过不同步)。
|
||
// 服务端已用 exclude=conn 排除发送者,前端不再用 client_id 跳过 update。
|
||
let clientId = sessionStorage.getItem("wb_cid") || "";
|
||
if (!clientId) {
|
||
clientId = "c_" + Math.random().toString(36).slice(2, 10);
|
||
sessionStorage.setItem("wb_cid", clientId);
|
||
}
|
||
let heartbeatTimer = null;
|
||
let reconnectTimer = null;
|
||
let connected = false;
|
||
let lastSentText = ""; // 上次发到服务端的文本(避免无变更时重复发)
|
||
let suppressInput = false; // 应用远端更新时抑制 input 事件,防回环
|
||
let debounceTimer = null;
|
||
|
||
// ---------- 本地编辑 -> debounce -> 发送 ----------
|
||
editor.addEventListener("input", () => {
|
||
if (suppressInput) return;
|
||
scheduleSend();
|
||
});
|
||
|
||
function scheduleSend() {
|
||
if (debounceTimer) clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(() => {
|
||
debounceTimer = null;
|
||
const text = editor.value;
|
||
if (text === lastSentText) return;
|
||
lastSentText = text;
|
||
send({ type: "edit", content: text });
|
||
}, 400);
|
||
}
|
||
|
||
function flushSend() {
|
||
if (debounceTimer) {
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = null;
|
||
const text = editor.value;
|
||
if (text !== lastSentText) {
|
||
lastSentText = text;
|
||
send({ type: "edit", content: text });
|
||
}
|
||
}
|
||
}
|
||
|
||
clearBtn.addEventListener("click", () => {
|
||
if (!connected) { flashStatus("未连接"); return; }
|
||
if (!confirm("确定清空全部内容?所有人的内容都会被清除。")) return;
|
||
send({ type: "clear" });
|
||
});
|
||
|
||
copyBtn.addEventListener("click", async () => {
|
||
const text = editor.value;
|
||
if (!text) { toast("内容为空"); return; }
|
||
const ok = await copyText(text);
|
||
toast(ok ? "已复制全部文本" : "复制失败");
|
||
});
|
||
|
||
copyLinkBtn.addEventListener("click", async () => {
|
||
const url = `${location.origin}/wb/${boardId}`;
|
||
const ok = await copyText(url);
|
||
toast(ok ? "链接已复制" : "复制失败");
|
||
});
|
||
|
||
// ---------- 应用远端更新(保留光标) ----------
|
||
// 策略:用最长公共前后缀算出变更区间,仅替换该区间,光标按相对位置调整。
|
||
// 若本地有未发送的编辑(editor.value !== lastSentText),合并后重新 scheduleSend,
|
||
// 避免本地编辑被远端覆盖后因 lastSentText 短路而丢弃。
|
||
function applyRemoteUpdate(newText) {
|
||
const oldText = editor.value;
|
||
if (newText === oldText) return;
|
||
|
||
const selStart = editor.selectionStart;
|
||
const selEnd = editor.selectionEnd;
|
||
|
||
// 算公共前缀
|
||
let prefix = 0;
|
||
const minLen = Math.min(oldText.length, newText.length);
|
||
while (prefix < minLen && oldText[prefix] === newText[prefix]) prefix++;
|
||
|
||
// 算公共后缀(不能与前缀重叠)
|
||
let suffixOld = oldText.length;
|
||
let suffixNew = newText.length;
|
||
while (suffixOld > prefix && suffixNew > prefix && oldText[suffixOld - 1] === newText[suffixNew - 1]) {
|
||
suffixOld--;
|
||
suffixNew--;
|
||
}
|
||
|
||
const hadPending = editor.value !== lastSentText;
|
||
suppressInput = true;
|
||
// 用 setRangeText 替换 [prefix, suffixOld) 为 newText[prefix, suffixNew)
|
||
editor.setRangeText(newText.slice(prefix, suffixNew), prefix, suffixOld, "end");
|
||
suppressInput = false;
|
||
lastSentText = editor.value;
|
||
|
||
// 调整光标:若光标在变更区间之前,不动;在之后,平移差值;在区间内,移到区间末尾
|
||
const delta = (suffixNew - prefix) - (suffixOld - prefix);
|
||
let newStart = selStart, newEnd = selEnd;
|
||
if (selStart <= prefix) {
|
||
// 光标在变更前,不变
|
||
} else if (selStart >= suffixOld) {
|
||
// 光标在变更后,平移
|
||
newStart = selStart + delta;
|
||
newEnd = selEnd + delta;
|
||
} else {
|
||
// 光标在变更区间内,移到区间末尾
|
||
newStart = newEnd = suffixNew;
|
||
}
|
||
try {
|
||
editor.setSelectionRange(newStart, newEnd);
|
||
} catch {}
|
||
// 仅当编辑器当前有焦点时恢复焦点,避免抢其它控件焦点
|
||
if (document.activeElement === editor) editor.focus();
|
||
|
||
// 本地有未发送编辑被合并了,重新安排发送,避免被丢弃
|
||
if (hadPending) scheduleSend();
|
||
}
|
||
|
||
// ---------- 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("已连接", false);
|
||
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":
|
||
// 连接建立时服务端下发当前文本。若本地有未发送编辑(断线期间输入的),
|
||
// 不直接覆盖,而是把本地编辑作为最新版本发上去(last-writer-wins),
|
||
// 避免断线期间的编辑被静默丢弃。
|
||
if (editor.value && editor.value !== lastSentText) {
|
||
lastSentText = editor.value;
|
||
send({ type: "edit", content: editor.value });
|
||
} else {
|
||
suppressInput = true;
|
||
editor.value = msg.content || "";
|
||
lastSentText = editor.value;
|
||
suppressInput = false;
|
||
editor.focus();
|
||
}
|
||
setStatus("已同步", false);
|
||
break;
|
||
case "pong":
|
||
break;
|
||
case "update":
|
||
// 服务端 broadcast 已用 exclude=conn 排除发送者,收到即他人编辑,直接应用。
|
||
applyRemoteUpdate(msg.content || "");
|
||
flashStatus("对方有更新");
|
||
break;
|
||
case "cleared":
|
||
suppressInput = true;
|
||
editor.value = "";
|
||
lastSentText = "";
|
||
suppressInput = false;
|
||
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 && typeof body.content === "string") {
|
||
editor.value = body.content;
|
||
lastSentText = body.content;
|
||
}
|
||
connect();
|
||
})
|
||
.catch(() => { connect(); });
|
||
|
||
// 页面可见性:重新可见时若已断连则主动重连;隐藏时 flush 未发送的编辑
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.visibilityState === "visible") {
|
||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||
connect();
|
||
}
|
||
} else {
|
||
flushSend();
|
||
}
|
||
});
|
||
|
||
// 离开前 flush 未发送编辑
|
||
window.addEventListener("beforeunload", () => {
|
||
flushSend();
|
||
stopHeartbeat();
|
||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||
try { ws && ws.close(); } catch {}
|
||
});
|
||
})();
|