refactor: 清理死代码/提前失败/日志/高内聚低耦合

死代码移除:
- 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 链接)
This commit is contained in:
root
2026-07-28 11:22:01 +08:00
parent d7d7450296
commit b9ea2c322a
8 changed files with 140 additions and 61 deletions

View File

@@ -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 / clearC->Sinit / pong / update / cleared / errorS->C
全量文本同步(非 OT/CRDTlast-writer-wins400ms debounce3s 心跳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 ```bash
npm install # 克隆并安装
npm test # 单元测试 git clone https://git.zikai.wang/zikai/zWhiteBoard.git
npm run dev # 开发服务器(代理 /api -> 本地 zTools2:6867含 ws:true cd zWhiteBoard
npm run build # 产物在 dist/ npm install # 已配 npmmirror 镜像加速
npm run dev # 开发服务器 http://localhost:5176vite.config.js 已配 /api 代理到 zTools2:6867含 ws:true
npm run build # 构建到 dist/
npm test # 单元测试19 项)
``` ```
> 开发时 `/api/wb/*` 与 `/api/ws/wb/*` 由 vite 代理到本地 zTools2127.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.5SFC`<script setup>` |
| 构建 | Vite 6 |
| 国际化 | vue-i18n 9中文同步英文懒加载 |
| 状态管理 | composable 单例 |
| 测试 | Vitest |
| 包源 | npmmirror.com中国镜像加速 |
## 了解更多
- [嵌入 mainPage构建期组件 import](./docs/integration.md)
- [错误处理与日志约定](./docs/error-handling.md)
- 后端 API 与 WS 协议见 [zTools2](https://git.zikai.wang/zikai/zTools2) README

22
docs/error-handling.md Normal file
View File

@@ -0,0 +1,22 @@
# 错误处理与日志约定
zWhiteBoard 是实时同步客户端错误处理分两层WS 客户端 fail-fast关键路径抛错/记日志UI 层捕获后写控制台。
## WS 客户端src/composables/useWhiteboard.js
- `send``ws.send` 序列化失败 `console.error`WS 通常是 RUNNING 状态,此分支极少触发,但记日志便于定位)。
- `connect``new WebSocket()` 抛错 `console.error` + 安排重连URL 非法或浏览器拦截时)。
- `ws.onerror`:透传 `console.error`(浏览器仅给泛化错误,无 detail但能标记故障时间点
- `onMessage`JSON.parse 失败 `console.warn` 后忽略(不抛错,避免单条坏消息中断后续同步)。
- `applyRemoteUpdate``setSelectionRange` 失败 `console.warn`(光标修复非致命,文本已正确替换)。
- `connectBoard`GET `/api/wb/{id}` 预取失败 `console.warn`,仍尝试连 WS预取仅为优化WS init 帧会兜底下发内容)。
- `teardownSocket`:关闭旧 WS 出错 `console.warn`(清理路径,不应阻塞切换)。
## UI 层src/App.vue
- `onCopyText` / `onCopyLink`:剪贴板 API 拒绝时 `console.warn`HTTPS/权限策略限制,非致命,不影响同步)。
- `onClear`:未连接直接 return不发 clear用户取消确认框直接 return。
## 控制台前缀
所有 `[zWB]` 前缀的日志来自本子项目,便于嵌入宿主时与宿主日志区分。

20
docs/integration.md Normal file
View File

@@ -0,0 +1,20 @@
# 嵌入 mainPage
zWhiteBoard 作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的 git submodule`third_party/zWhiteBoard`),经构建期组件 import 集成(非 iframe与 timeTableFix / zPDF_package 同范式)。
## 集成方式
- mainPage 侧 `src/modules/whiteboard/Whiteboard.vue``PageShell` 并透传 `:locale` / `:boardId` props。
- 构建期直接 `import` 本项目 `src/App.vue`,共享 mainPage 的构建与 KeepAlive 缓存。
- 组件内 `useWhiteboard.js` 同源 fetch `/api/wb/*` 与 WS `/api/ws/wb/*`,反代第一方自动携带 cookie无需 CORS。
## 便携式设计(无 host 依赖)
组件自身保持「无 host 依赖」:
- 不持 `defineOptions({ name })`(避免与宿主组件名冲突)。
- 不引 mainPage 的 i18n仅靠 `:locale` prop 驱动自有 i18n 实例。
- `useLocale` 直接读本子项目模块级 `i18n.global`,而非 `useI18n()`(后者嵌入时会解析到宿主实例,文案回退成 key
- `App.vue` 仅经 `:boardId` prop 决定连哪个白板,不耦合宿主路由。
详见 mainPage 的 `docs/submodule-zWhiteBoard.md`

View File

@@ -2,7 +2,7 @@
// ★ zWhiteBoard 根组件(便携式)。 // ★ zWhiteBoard 根组件(便携式)。
// 嵌入 mainPage 时经 :locale/:boardId props 传入;独立运行时从 URL 读取 boardId。 // 嵌入 mainPage 时经 :locale/:boardId props 传入;独立运行时从 URL 读取 boardId。
// WS 同步委托给 useWhiteboard 客户端WbEditor 仅负责渲染与事件转发。 // WS 同步委托给 useWhiteboard 客户端WbEditor 仅负责渲染与事件转发。
import { ref, watch, onMounted, onUnmounted, useTemplateRef } from 'vue' import { watch, onMounted, onUnmounted, useTemplateRef } from 'vue'
import { useWhiteboard } from './composables/useWhiteboard.js' import { useWhiteboard } from './composables/useWhiteboard.js'
import { useLocale } from './composables/useLocale.js' import { useLocale } from './composables/useLocale.js'
import { loadLocaleAsync } from './i18n/index.js' import { loadLocaleAsync } from './i18n/index.js'
@@ -60,14 +60,18 @@ async function onCopyText() {
if (!text) return if (!text) return
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
} catch {} } catch (e) {
console.warn('[zWB] 复制文本失败', e)
}
} }
async function onCopyLink() { async function onCopyLink() {
const url = `${location.origin}/whiteboard/${encodeURIComponent(props.boardId)}` const url = `${location.origin}/whiteboard/${encodeURIComponent(props.boardId)}`
try { try {
await navigator.clipboard.writeText(url) await navigator.clipboard.writeText(url)
} catch {} } catch (e) {
console.warn('[zWB] 复制链接失败', e)
}
} }
</script> </script>

View File

@@ -1,4 +1,3 @@
import { computed } from 'vue'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
// ★ 子项目自有的语言辅助 composable。 // ★ 子项目自有的语言辅助 composable。
@@ -7,8 +6,6 @@ import i18n from '../i18n/index.js'
// 嵌入 mainPage 时那是父项目的实例(不含本子项目文案)-> 文案回退成 key。 // 嵌入 mainPage 时那是父项目的实例(不含本子项目文案)-> 文案回退成 key。
// 改为直接引用本子项目实例的 .global无论是否 app.use() 都拿到本子项目的 t。 // 改为直接引用本子项目实例的 .global无论是否 app.use() 都拿到本子项目的 t。
export function useLocale() { export function useLocale() {
const { t, locale } = i18n.global const { t } = i18n.global
const isZh = computed(() => locale.value === 'zh-CN') return { t }
const pick = (obj) => (isZh.value ? obj.zh : obj.en)
return { t, locale, isZh, pick }
} }

View File

@@ -1,11 +1,11 @@
// ★ 共享记事本 WS 客户端 + 同步状态机(与 DOM 解耦,便于单元测试)。 // ★ 共享记事本 WS 客户端 + 同步状态机(与 DOM 解耦,便于单元测试)。
// //
// 职责: // 职责:
// - connect(boardId): GET /api/wb/{id} 预取内容确保白板存在,再连 WSonopen 发 hello + 启心跳 // - connectBoard(id, el): GET /api/wb/{id} 预取内容确保白板存在,再连 WSonopen 发 hello + 启心跳
// - 断线 2s 固定重连(无指数退避、无最大次数) // - 断线 2s 固定重连(无指数退避、无最大次数)
// - sendEdit(text): 400ms debounce + lastSentText 去重 // - sendEdit(text): 400ms debounce + lastSentText 去重
// - sendClear(): 发 clear 帧 // - sendClear(): 发 clear 帧
// - applyRemoteUpdate(newText, editorEl): 最长公共前后缀 + setRangeText 区间替换保光标 // - applyRemoteUpdate(newText): 最长公共前后缀 + setRangeText 区间替换保光标
// - flushSend(): 立即补发未发送编辑 // - flushSend(): 立即补发未发送编辑
// - disconnect(): 停心跳/重连、关 WS、移除页面可见性钩子 // - disconnect(): 停心跳/重连、关 WS、移除页面可见性钩子
// //
@@ -85,13 +85,12 @@ export function createWhiteboardClient(opts = {}) {
let reconnectTimer = null let reconnectTimer = null
let debounceTimer = null let debounceTimer = null
let lastSentText = '' let lastSentText = ''
let suppressInput = false
let statusTimer = null let statusTimer = null
let disposed = false let disposed = false
// 当前关联的编辑器元素applyRemoteUpdate 需要 selectionStart/setRangeText // 当前关联的编辑器元素applyRemoteUpdate 需要 selectionStart/setRangeText
let editorEl = null let editorEl = null
function setStatus(text, isErr) { function setStatus(text) {
statusText.value = text statusText.value = text
if (statusTimer) clearTimeout(statusTimer) if (statusTimer) clearTimeout(statusTimer)
statusTimer = setTimeout(() => { statusTimer = setTimeout(() => {
@@ -111,7 +110,9 @@ export function createWhiteboardClient(opts = {}) {
if (ws && ws.readyState === Ws.OPEN) { if (ws && ws.readyState === Ws.OPEN) {
try { try {
ws.send(JSON.stringify(obj)) 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 { prefix, suffixOld, suffixNew } = computeDiff(oldText, newText)
const hadPending = el.value !== lastSentText const hadPending = el.value !== lastSentText
suppressInput = true
el.setRangeText(newText.slice(prefix, suffixNew), prefix, suffixOld, 'end') el.setRangeText(newText.slice(prefix, suffixNew), prefix, suffixOld, 'end')
suppressInput = false
lastSentText = el.value lastSentText = el.value
content.value = el.value content.value = el.value
@@ -196,16 +195,14 @@ export function createWhiteboardClient(opts = {}) {
} }
try { try {
el.setSelectionRange(newStart, newEnd) el.setSelectionRange(newStart, newEnd)
} catch {} } catch (e) {
console.warn('[zWB] setSelectionRange 失败', e)
}
if (doc && doc.activeElement === el) el.focus() if (doc && doc.activeElement === el) el.focus()
if (hadPending) scheduleSend() if (hadPending) scheduleSend()
} }
function isInputSuppressed() {
return suppressInput
}
// ---------- WebSocket ---------- // ---------- WebSocket ----------
function wsUrl() { function wsUrl() {
const proto = loc && loc.protocol === 'https:' ? 'wss:' : 'ws:' const proto = loc && loc.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -219,6 +216,7 @@ export function createWhiteboardClient(opts = {}) {
try { try {
ws = new Ws(wsUrl()) ws = new Ws(wsUrl())
} catch (e) { } catch (e) {
console.error('[zWB] 创建 WebSocket 失败', e)
scheduleReconnect() scheduleReconnect()
return return
} }
@@ -230,14 +228,17 @@ export function createWhiteboardClient(opts = {}) {
} }
ws.onmessage = (ev) => onMessage(ev.data) ws.onmessage = (ev) => onMessage(ev.data)
ws.onclose = () => onLost('status.disconnected') ws.onclose = () => onLost('status.disconnected')
ws.onerror = () => {} ws.onerror = (e) => {
console.error('[zWB] WebSocket 错误', e)
}
} }
function onMessage(raw) { function onMessage(raw) {
let msg let msg
try { try {
msg = JSON.parse(raw) msg = JSON.parse(raw)
} catch { } catch (e) {
console.warn('[zWB] 收到非 JSON 消息,已忽略', e)
return return
} }
switch (msg.type) { switch (msg.type) {
@@ -253,13 +254,11 @@ export function createWhiteboardClient(opts = {}) {
send({ type: 'edit', content: localText }) send({ type: 'edit', content: localText })
} else { } else {
const c = msg.content || '' const c = msg.content || ''
suppressInput = true
if (editorEl) { if (editorEl) {
editorEl.value = c editorEl.value = c
} }
content.value = c content.value = c
lastSentText = c lastSentText = c
suppressInput = false
if (editorEl && doc && doc.activeElement === editorEl) editorEl.focus() if (editorEl && doc && doc.activeElement === editorEl) editorEl.focus()
} }
setStickyStatus('status.synced') setStickyStatus('status.synced')
@@ -273,14 +272,12 @@ export function createWhiteboardClient(opts = {}) {
setStatus('status.peerUpdate') setStatus('status.peerUpdate')
break break
case 'cleared': case 'cleared':
suppressInput = true
if (editorEl) { if (editorEl) {
editorEl.value = '' editorEl.value = ''
lastSentText = '' lastSentText = ''
} }
content.value = '' content.value = ''
lastSentText = '' lastSentText = ''
suppressInput = false
setStatus(msg.client_id === clientId ? 'status.cleared' : 'status.peerCleared') setStatus(msg.client_id === clientId ? 'status.cleared' : 'status.peerCleared')
break break
case 'error': case 'error':
@@ -356,8 +353,9 @@ export function createWhiteboardClient(opts = {}) {
lastSentText = body.content lastSentText = body.content
} }
} }
} catch { } catch (e) {
// 预取失败仍尝试连 WS // 预取失败仍尝试连 WS
console.warn('[zWB] 预取白板内容失败,仍尝试连 WS', e)
} }
} }
connect() connect()
@@ -380,7 +378,9 @@ export function createWhiteboardClient(opts = {}) {
ws.onmessage = null ws.onmessage = null
ws.onopen = null ws.onopen = null
ws.close() ws.close()
} catch {} } catch (e) {
console.warn('[zWB] 关闭旧 WebSocket 时出错', e)
}
ws = null ws = null
} }
connected.value = false connected.value = false
@@ -415,16 +415,10 @@ export function createWhiteboardClient(opts = {}) {
sendClear, sendClear,
flushSend, flushSend,
applyRemoteUpdate, applyRemoteUpdate,
scheduleSend,
isInputSuppressed,
bindVisibility, bindVisibility,
unbindVisibility, unbindVisibility,
// 暴露内部状态便于测试断言 // 暴露内部状态便于测试断言
_getLastSentText: () => lastSentText, _getLastSentText: () => lastSentText,
_getSuppressInput: () => suppressInput,
_setEditor: (el) => {
editorEl = el
},
} }
} }

View File

@@ -5,16 +5,11 @@ export default {
}, },
board: { board: {
idLabel: 'Notepad ID', idLabel: 'Notepad ID',
switch: 'Switch',
placeholder: 'Type here, everyone sees your edits in real time…', placeholder: 'Type here, everyone sees your edits in real time…',
clear: 'Clear', clear: 'Clear',
clearConfirm: 'Clear all content? Everyone\'s content will be removed.', clearConfirm: 'Clear all content? Everyone\'s content will be removed.',
copyText: 'Copy Text', copyText: 'Copy Text',
copyLink: 'Copy Link', copyLink: 'Copy Link'
copied: 'All text copied',
copiedLink: 'Link copied',
copyFailed: 'Copy failed',
emptyText: 'Content is empty'
}, },
status: { status: {
connecting: 'Connecting…', connecting: 'Connecting…',

View File

@@ -5,16 +5,11 @@ export default {
}, },
board: { board: {
idLabel: '记事本 ID', idLabel: '记事本 ID',
switch: '切换',
placeholder: '在此输入文本,所有人会实时看到你的编辑…', placeholder: '在此输入文本,所有人会实时看到你的编辑…',
clear: '清空', clear: '清空',
clearConfirm: '确定清空全部内容?所有人的内容都会被清除。', clearConfirm: '确定清空全部内容?所有人的内容都会被清除。',
copyText: '复制文本', copyText: '复制文本',
copyLink: '复制链接', copyLink: '复制链接'
copied: '已复制全部文本',
copiedLink: '链接已复制',
copyFailed: '复制失败',
emptyText: '内容为空'
}, },
status: { status: {
connecting: '连接中…', connecting: '连接中…',