From b9ea2c322aeae5b9d06ebd6fbb5109460cda1580 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Jul 2026 11:22:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=B8=85=E7=90=86=E6=AD=BB?= =?UTF-8?q?=E4=BB=A3=E7=A0=81/=E6=8F=90=E5=89=8D=E5=A4=B1=E8=B4=A5/?= =?UTF-8?q?=E6=97=A5=E5=BF=97/=E9=AB=98=E5=86=85=E8=81=9A=E4=BD=8E?= =?UTF-8?q?=E8=80=A6=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 死代码移除: - useWhiteboard.js: 移除未使用的 suppressInput 变量及 6 处赋值; 移除 isInputSuppressed / _getSuppressInput / _setEditor 导出; scheduleSend 从公开 API 降为内部函数; setStatus 移除未用 isErr 形参 - useLocale.js: 移除未用的 isZh/pick/locale 返回值及 computed 导入 - App.vue: 移除未用的 ref 导入 - i18n locales: 移除 5 个死 key (switch/copied/copiedLink/copyFailed/emptyText) 提前失败/日志: - 7 处 silent catch (send/connect/onerror/onMessage JSON.parse/ applyRemoteUpdate setSelectionRange/connectBoard 预取/teardownSocket) 全部加 console.error/warn, 统一 [zWB] 前缀 - App.vue onCopyText/onCopyLink 失败 console.warn 文档: - 新增 docs/error-handling.md, docs/integration.md - README.md 重写为简洁版 (简介/结构/外部依赖/Ubuntu 安装使用/docs 链接) --- README.md | 82 ++++++++++++++++++++++++++------ docs/error-handling.md | 22 +++++++++ docs/integration.md | 20 ++++++++ src/App.vue | 10 ++-- src/composables/useLocale.js | 7 +-- src/composables/useWhiteboard.js | 46 ++++++++---------- src/i18n/locales/en.js | 7 +-- src/i18n/locales/zh-CN.js | 7 +-- 8 files changed, 140 insertions(+), 61 deletions(-) create mode 100644 docs/error-handling.md create mode 100644 docs/integration.md diff --git a/README.md b/README.md index e3d687e..00c5124 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,78 @@ -# zWhiteBoard +# zWhiteBoard - 共享记事本前端 -共享记事本前端 -- textarea + WebSocket 实时同步、心跳、断线重连。复用 zTools2 后端 API。 +textarea + WebSocket 实时同步的共享记事本:多人同写一块白板,400ms debounce、3s 心跳、断线 2s 重连。复用 [zTools2](https://git.zikai.wang/zikai/zTools2) 后端 API(`/api/wb/*` 与 `/api/ws/wb/*`)。便携式组件,可独立运行,亦可作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的子模块经构建期组件 import 集成展示(像 [timeTableFix](https://git.zikai.wang/zikai/timeTableFix))。 -## 集成方式 +## 功能 -便携式组件,两种运行模式: +- 多人实时同步同一块白板文本(全量同步,last-writer-wins,非 OT/CRDT) +- 应用远端更新时按最长公共前后缀做区间替换,保留本地光标位置 +- 3s 心跳保活 + 断线 2s 固定重连 +- 页面切到后台时 flush 未发送编辑,切回前台时若掉线自动重连 +- 清空白板(广播 cleared 帧)、复制文本/链接 -- **独立运行**:`npm run dev` / `npm run build`,自带 i18n 与全局样式。 -- **嵌入 mainPage**:作为 git submodule 挂在 zMainPage `third_party/zWhiteBoard`,经构建期组件 import 集成(非 iframe)。mainPage 侧包装器 `Whiteboard.vue` 套 `PageShell` 并透传 `:locale` / `:boardId` props。 +## 项目结构 -## 后端接口(zTools2 提供,不改动) +``` +zWhiteBoard/ +├── index.html Vite 入口 +├── package.json +├── vite.config.js base:'./' 便携式构建;dev 代理 /api(含 ws:true) +├── vitest.config.js +├── .npmrc npmmirror 镜像加速 +├── src/ +│ ├── main.js 应用挂载(i18n + 全局样式) +│ ├── App.vue 便携式根组件(接受 :locale/:boardId prop,独立/嵌入两用) +│ ├── i18n/ 独立 vue-i18n 实例 + 中英 locale +│ ├── composables/ +│ │ ├── useWhiteboard.js WS 客户端 + 同步状态机(与 DOM 解耦,可单测) +│ │ └── useLocale.js 语言辅助(直读自身 i18n 实例) +│ ├── components/ +│ │ └── WbEditor.vue textarea + 工具栏 + 状态条(渲染与事件转发) +│ └── styles/global.css 全局重置 + 设计令牌 +└── test/ Vitest 单元测试(19 项) +``` -- `GET /api/wb/{id}` -- 预取白板内容(不存在则服务端新建) -- `WS /api/ws/wb/{id}` -- 实时同步:hello / edit / ping / clear(C->S);init / pong / update / cleared / error(S->C) +## 外部依赖 -全量文本同步(非 OT/CRDT,last-writer-wins),400ms debounce,3s 心跳,2s 固定重连。 +| 项 | 说明 | +|----|------| +| Node.js | ≥ 18 | +| zTools2 | 后端 `/api/wb/*` 与 `/api/ws/wb/*`(同源调用,cookie 自动管理) | -## 开发 +> 前端纯静态,无运行期后端依赖;API/WS 由 zTools2 提供。生产部署经 [zMainPage](https://git.zikai.wang/zikai/zMainPage) 作为子模块集成,由其 apache2 静态托管 + `/api` 反代到 zTools2(含 WebSocket 升级,无独立 apache2 配置)。 + +## 如何使用 ```bash -npm install -npm test # 单元测试 -npm run dev # 开发服务器(代理 /api -> 本地 zTools2:6867,含 ws:true) -npm run build # 产物在 dist/ +# 克隆并安装 +git clone https://git.zikai.wang/zikai/zWhiteBoard.git +cd zWhiteBoard +npm install # 已配 npmmirror 镜像加速 + +npm run dev # 开发服务器 http://localhost:5176(vite.config.js 已配 /api 代理到 zTools2:6867,含 ws:true) +npm run build # 构建到 dist/ +npm test # 单元测试(19 项) ``` + +> 开发时 `/api/wb/*` 与 `/api/ws/wb/*` 由 vite 代理到本地 zTools2(127.0.0.1:6867,`ws:true` 覆盖 WebSocket);生产环境由 mainPage 同源反代到 zTools2。 + +## 嵌入 mainPage + +作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的 git submodule,经构建期组件 import 集成(非 iframe)。详见 [`docs/integration.md`](./docs/integration.md)。 + +## 技术栈 + +| 项 | 选择 | +|----|------| +| 框架 | Vue 3.5(SFC,` diff --git a/src/composables/useLocale.js b/src/composables/useLocale.js index 58b43c3..0032c7a 100644 --- a/src/composables/useLocale.js +++ b/src/composables/useLocale.js @@ -1,4 +1,3 @@ -import { computed } from 'vue' import i18n from '../i18n/index.js' // ★ 子项目自有的语言辅助 composable。 @@ -7,8 +6,6 @@ import i18n from '../i18n/index.js' // 嵌入 mainPage 时那是父项目的实例(不含本子项目文案)-> 文案回退成 key。 // 改为直接引用本子项目实例的 .global,无论是否 app.use() 都拿到本子项目的 t。 export function useLocale() { - const { t, locale } = i18n.global - const isZh = computed(() => locale.value === 'zh-CN') - const pick = (obj) => (isZh.value ? obj.zh : obj.en) - return { t, locale, isZh, pick } + const { t } = i18n.global + return { t } } diff --git a/src/composables/useWhiteboard.js b/src/composables/useWhiteboard.js index e5d6389..a06362f 100644 --- a/src/composables/useWhiteboard.js +++ b/src/composables/useWhiteboard.js @@ -1,11 +1,11 @@ // ★ 共享记事本 WS 客户端 + 同步状态机(与 DOM 解耦,便于单元测试)。 // // 职责: -// - connect(boardId): GET /api/wb/{id} 预取内容确保白板存在,再连 WS;onopen 发 hello + 启心跳 +// - connectBoard(id, el): GET /api/wb/{id} 预取内容确保白板存在,再连 WS;onopen 发 hello + 启心跳 // - 断线 2s 固定重连(无指数退避、无最大次数) // - sendEdit(text): 400ms debounce + lastSentText 去重 // - sendClear(): 发 clear 帧 -// - applyRemoteUpdate(newText, editorEl): 最长公共前后缀 + setRangeText 区间替换保光标 +// - applyRemoteUpdate(newText): 最长公共前后缀 + setRangeText 区间替换保光标 // - flushSend(): 立即补发未发送编辑 // - disconnect(): 停心跳/重连、关 WS、移除页面可见性钩子 // @@ -85,13 +85,12 @@ export function createWhiteboardClient(opts = {}) { let reconnectTimer = null let debounceTimer = null let lastSentText = '' - let suppressInput = false let statusTimer = null let disposed = false // 当前关联的编辑器元素(applyRemoteUpdate 需要 selectionStart/setRangeText) let editorEl = null - function setStatus(text, isErr) { + function setStatus(text) { statusText.value = text if (statusTimer) clearTimeout(statusTimer) statusTimer = setTimeout(() => { @@ -111,7 +110,9 @@ export function createWhiteboardClient(opts = {}) { if (ws && ws.readyState === Ws.OPEN) { try { ws.send(JSON.stringify(obj)) - } catch {} + } catch (e) { + console.error('[zWB] ws.send 失败', e) + } } } @@ -176,9 +177,7 @@ export function createWhiteboardClient(opts = {}) { const { prefix, suffixOld, suffixNew } = computeDiff(oldText, newText) const hadPending = el.value !== lastSentText - suppressInput = true el.setRangeText(newText.slice(prefix, suffixNew), prefix, suffixOld, 'end') - suppressInput = false lastSentText = el.value content.value = el.value @@ -196,16 +195,14 @@ export function createWhiteboardClient(opts = {}) { } try { el.setSelectionRange(newStart, newEnd) - } catch {} + } catch (e) { + console.warn('[zWB] setSelectionRange 失败', e) + } if (doc && doc.activeElement === el) el.focus() if (hadPending) scheduleSend() } - function isInputSuppressed() { - return suppressInput - } - // ---------- WebSocket ---------- function wsUrl() { const proto = loc && loc.protocol === 'https:' ? 'wss:' : 'ws:' @@ -219,6 +216,7 @@ export function createWhiteboardClient(opts = {}) { try { ws = new Ws(wsUrl()) } catch (e) { + console.error('[zWB] 创建 WebSocket 失败', e) scheduleReconnect() return } @@ -230,14 +228,17 @@ export function createWhiteboardClient(opts = {}) { } ws.onmessage = (ev) => onMessage(ev.data) ws.onclose = () => onLost('status.disconnected') - ws.onerror = () => {} + ws.onerror = (e) => { + console.error('[zWB] WebSocket 错误', e) + } } function onMessage(raw) { let msg try { msg = JSON.parse(raw) - } catch { + } catch (e) { + console.warn('[zWB] 收到非 JSON 消息,已忽略', e) return } switch (msg.type) { @@ -253,13 +254,11 @@ export function createWhiteboardClient(opts = {}) { send({ type: 'edit', content: localText }) } else { const c = msg.content || '' - suppressInput = true if (editorEl) { editorEl.value = c } content.value = c lastSentText = c - suppressInput = false if (editorEl && doc && doc.activeElement === editorEl) editorEl.focus() } setStickyStatus('status.synced') @@ -273,14 +272,12 @@ export function createWhiteboardClient(opts = {}) { setStatus('status.peerUpdate') break case 'cleared': - suppressInput = true if (editorEl) { editorEl.value = '' lastSentText = '' } content.value = '' lastSentText = '' - suppressInput = false setStatus(msg.client_id === clientId ? 'status.cleared' : 'status.peerCleared') break case 'error': @@ -356,8 +353,9 @@ export function createWhiteboardClient(opts = {}) { lastSentText = body.content } } - } catch { + } catch (e) { // 预取失败仍尝试连 WS + console.warn('[zWB] 预取白板内容失败,仍尝试连 WS', e) } } connect() @@ -380,7 +378,9 @@ export function createWhiteboardClient(opts = {}) { ws.onmessage = null ws.onopen = null ws.close() - } catch {} + } catch (e) { + console.warn('[zWB] 关闭旧 WebSocket 时出错', e) + } ws = null } connected.value = false @@ -415,16 +415,10 @@ export function createWhiteboardClient(opts = {}) { sendClear, flushSend, applyRemoteUpdate, - scheduleSend, - isInputSuppressed, bindVisibility, unbindVisibility, // 暴露内部状态便于测试断言 _getLastSentText: () => lastSentText, - _getSuppressInput: () => suppressInput, - _setEditor: (el) => { - editorEl = el - }, } } diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js index caaebae..0772157 100644 --- a/src/i18n/locales/en.js +++ b/src/i18n/locales/en.js @@ -5,16 +5,11 @@ export default { }, board: { idLabel: 'Notepad ID', - switch: 'Switch', placeholder: 'Type here, everyone sees your edits in real time…', clear: 'Clear', clearConfirm: 'Clear all content? Everyone\'s content will be removed.', copyText: 'Copy Text', - copyLink: 'Copy Link', - copied: 'All text copied', - copiedLink: 'Link copied', - copyFailed: 'Copy failed', - emptyText: 'Content is empty' + copyLink: 'Copy Link' }, status: { connecting: 'Connecting…', diff --git a/src/i18n/locales/zh-CN.js b/src/i18n/locales/zh-CN.js index 1f4ed24..a210bab 100644 --- a/src/i18n/locales/zh-CN.js +++ b/src/i18n/locales/zh-CN.js @@ -5,16 +5,11 @@ export default { }, board: { idLabel: '记事本 ID', - switch: '切换', placeholder: '在此输入文本,所有人会实时看到你的编辑…', clear: '清空', clearConfirm: '确定清空全部内容?所有人的内容都会被清除。', copyText: '复制文本', - copyLink: '复制链接', - copied: '已复制全部文本', - copiedLink: '链接已复制', - copyFailed: '复制失败', - emptyText: '内容为空' + copyLink: '复制链接' }, status: { connecting: '连接中…',