这是一个可分享、可实时展示的在线团队协作白板,仿照 excalidraw 的界面和功能开发。
- 账号认证(JWT access/refresh)
- 白板权限:owner / editor / viewer
- 多种绘图工具:自由线条、矩形、圆形、直线等
- 实时协作:Socket.IO operation commit/ack/replay
- 有序 operation log(boardId + seq)
- Monorepo:pnpm workspace
当前运行时与框架(不是 Bun / Elysia):
- React + TypeScript + Vite
- Konva.js + react-konva
- Zustand + Immer
- socket.io-client
- Zod、Tailwind CSS、Lucide
- Node.js + Koa + @koa/router
- Socket.IO
- PostgreSQL + Prisma
- 进程内限流(单实例)
- JWT(jsonwebtoken)
- pnpm workspace
- Docker Compose(web / server / postgres)
- pnpm monorepo + shared 类型/schema
- 注册 / 登录 / 刷新 / 登出 / me
- 认证后的白板 CRUD + 权限
- OperationService(原子 seq、replayOps、fromSeq)
- Socket.IO:
board:join/operation:commit/operation:replay/cursor:update - 前端登录页、Bearer API、socket auth.token、断线 replay
- 基础绘图工具与 Konva UI
- BatchWriter / snapshot compaction(后续)
- 多实例部署(后续)
- Node.js >= 20.19(建议 20/22/24 LTS)
- pnpm >= 11
- PostgreSQL >= 14
- 克隆仓库并安装依赖
git clone https://github.com/Phoen1xCode/WhiteBoard.git
cd WhiteBoard
pnpm install- 配置服务端环境变量
cp apps/server/.env.example apps/server/.env
# 编辑 DATABASE_URL / JWT_* 密钥关键变量:
DATABASE_URL=postgresql://whiteboard:whiteboardpassword@localhost:5432/whiteboard
PORT=4000
JWT_ACCESS_SECRET=replace-with-a-long-random-access-secret
JWT_REFRESH_SECRET=replace-with-a-long-random-refresh-secret前端默认请求 http://localhost:4000(可用 VITE_API_BASE / VITE_WS_URL 覆盖)。
- 数据库迁移
pnpm prisma:generate
pnpm prisma:migrate- 启动
# 终端 1: 后端默认 http://localhost:4000
pnpm dev:server
# 终端 2: 前端默认 http://localhost:5173
pnpm dev:web- 使用
打开 http://localhost:5173 ,先注册/登录,再创建白板。未登录访问受保护路由会跳转登录页;未带 token 的 HTTP board API 返回 401,Socket 无 token 无法连接。
pnpm typecheckWhiteBoard/
├── apps/
│ ├── web/ # 前端应用
│ │ ├── src/
│ │ │ ├── components/ # React 组件
│ │ │ │ ├── board/ # 白板相关组件
│ │ │ │ ├── style/ # 样式控制组件
│ │ │ │ └── ui/ # 通用 UI 组件
│ │ │ ├── pages/ # 页面组件
│ │ │ ├── store/ # Zustand 状态管理
│ │ │ ├── hooks/ # 自定义 Hooks
│ │ │ ├── lib/ # 工具函数
│ │ │ └── styles/ # 全局样式
│ │ └── package.json
│ │
│ └── server/ # 后端应用
│ ├── src/
│ │ ├── auth.ts / boards.ts / operations.ts
│ │ ├── board-access.ts / board-state.ts
│ │ ├── resolve-access-token.ts / collaboration.ts
│ │ ├── routes/ # HTTP adapters
│ │ ├── sockets/ # Socket.IO adapter
│ │ ├── middleware/ # auth / rate-limit 等
│ │ └── lib/ # jwt / prisma
│ ├── prisma/
│ │ ├── schema.prisma # 数据库模型
│ │ └── migrations/ # 数据库迁移文件
│ └── package.json
│
├── packages/
│ └── shared/ # 共享类型与 schema
│ ├── src/
│ │ ├── types/ # whiteboard / socket 类型
│ │ └── schemas/ # Zod schema
│ └── package.json
│
├── docker/ # Docker 部署配置
│ ├── Dockerfile
│ ├── docker-compose.yml
│ ├── nginx.conf
│ └── entrypoint.sh
│
├── AGENTS.md # agent 项目记忆(CLAUDE.md 为其软链接)
└── package.json # 根配置
WhiteBoard 采用前后端分离的 Monorepo 架构,通过 pnpm workspace 统一管理依赖。整体架构分为三层:
┌─────────────────────────────────────────────────────────────┐
│ 前端层 (React) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Canvas 渲染 │ │ 状态管理 │ │ WebSocket │ │
│ │ (Konva.js) │ │ (Zustand) │ │ 客户端 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
↕ HTTP/WebSocket
┌─────────────────────────────────────────────────────────────┐
│ 后端层 (Node.js) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ REST API │ │ Socket.IO │ │ 业务逻辑 │ │
│ │ (Koa) │ │ 服务器 │ │ (Service) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
↕ Prisma ORM
┌─────────────────────────────────────────────────────────────┐
│ 数据持久层 (PostgreSQL) │
│ PostgreSQL │
└─────────────────────────────────────────────────────────────┘
本项目采用基于操作的同步机制实现多人实时协作,这是一种高效且可靠的协作方案:
工作流程:
客户端 A 服务器 客户端 B
│ │ │
│ 1. JWT + 快照/lastSeq │ │
│ (HTTP Bearer) │ │
│ ◄───────────────────── │ │
│ │ │
│ 2. board:join │ │
│ (auth.token) │ │
│ ─────────────────────► │ │
│ │ │
│ 3. 本地乐观更新 UI │ │
│ 4. operation:commit │ │
│ ─────────────────────► │ 5. authorize → persist │
│ │ (boardId+seq) │
│ 6. ack 提交者 │ │
│ ◄───────────────────── │ 7. operation:committed │
│ │ ─────────────────────► │
│ │ │ 8. 应用 op 更新 UI
三个阶段:
-
初始同步(Snapshot Loading)
- 带 Bearer token 调用
GET /api/v1/boards/:id,拿快照 +lastSeq - 使用
setInitialElements()初始化本地状态 - Socket 以
auth.token连接后board:join
- 带 Bearer token 调用
-
操作提交(operation:commit)
- 本地先乐观应用,再
operation:commit - 服务端:
authorize → persist(原子 boardId+seq)→ ack 提交者 → broadcast 其他人 - 断线后用
lastSeq走operation:replay
- 本地先乐观应用,再
-
乐观更新与回滚
- UI 立即更新;若 commit 被服务端确定性拒绝,客户端回滚本地 op
所有白板修改通过 WhiteBoardOperation 类型定义,确保类型安全和一致性:
type WhiteBoardOperation =
| { type: "add"; boardId: string; element: WhiteBoardElement }
| {
type: "update";
boardId: string;
elementId: string;
changes: Partial<WhiteBoardElement>;
}
| { type: "delete"; boardId: string; elementId: string }
| { type: "clear"; boardId: string };操作类型说明:
add- 添加新元素到画板(绘制新图形)update- 修改现有元素属性(拖动、缩放、改变样式)delete- 删除指定元素(橡皮擦、删除键)clear- 清空整个画板
使用 Zustand + Immer 实现不可变状态管理:
核心状态结构:
{
elements: Record<string, WhiteBoardElement>, // 元素字典,O(1) 查找
currentTool: ShapeType, // 当前工具
currentStyle: DrawingStyle, // 当前样式
selectedElementId: string | null, // 选中元素
undoStack: HistoryEntry[], // 撤销栈
redoStack: HistoryEntry[] // 重做栈
}操作应用逻辑:
applyOperation(operation, { local, recordHistory });local: true- 本地操作,需要广播给其他客户端local: false- 远程操作,仅应用到本地状态recordHistory: true- 记录到历史栈,支持撤销/重做
实现了完整的 Undo/Redo 功能,基于逆向操作模式:
工作原理:
- 每个操作执行时,自动生成其逆向操作
- 原始操作和逆向操作作为一对存入历史栈
- 撤销时应用逆向操作,重做时重新应用原始操作
- 撤销/重做操作同样通过 WebSocket 同步给其他客户端
逆向操作映射:
| 原始操作 | 逆向操作 |
|---|---|
| add(element) | delete(elementId) |
| delete(elementId) | add(element) |
| update(id, changes) | update(id, originalValues) |
历史栈管理:
- 限制最大容量为 50 条,防止内存溢出
- 执行新操作时自动清空重做栈
- 支持跨客户端的撤销/重做同步
1. Canvas 渲染引擎 (Konva.js)
选择 Konva.js 作为渲染引擎的原因:
- 基于 Canvas API,性能优异
- 提供完整的图形变换能力(拖动、缩放、旋转)
- 支持事件系统,易于实现交互
- React 集成良好(react-konva)
核心渲染流程:
Canvas 组件
├─ Stage (画布容器)
│ ├─ Layer (图层)
│ │ ├─ 已保存元素渲染 (elements.map(renderElement))
│ │ ├─ 正在绘制元素渲染 (currentShape)
│ │ └─ Transformer (选中元素的变换控制器)2. 绘图工具实现
支持多种绘图工具,每种工具有独立的绘制逻辑:
| 工具 | 实现方式 | 数据结构 |
|---|---|---|
| 自由线条 (freehand) | 记录鼠标轨迹点数组 | points: [x1, y1, x2, y2, ...] |
| 矩形 (rectangle) | 起点 + 宽高 | x, y, width, height |
| 圆形 (circle) | 圆心 + 半径 | x, y, radius |
| 直线 (line) | 起点 + 终点 | points: [x1, y1, x2, y2] |
| 选择 (select) | Transformer 控制 | 无数据,仅交互 |
| 橡皮擦 (eraser) | 碰撞检测 + 删除 | 无数据,仅交互 |
3. 实时光标显示
实现了多用户光标位置的实时显示:
// 发送光标位置
sendCursor(boardId, x, y);
// 接收其他用户光标
onCursor((data: { clientId; x; y }) => {
// 渲染其他用户的光标
});4. 连接状态管理
实现了完善的 WebSocket 连接状态管理:
type ConnectionStatus = "connecting" | "connected" | "disconnected" | "reconnecting";- 自动重连机制(最多 10 次,延迟 1-5 秒)
- 重连后自动重新加入白板房间
- 连接状态实时显示给用户
1. REST API 设计
除注册/登录/刷新外,HTTP API 需 Authorization: Bearer <accessToken>。
认证:
| 方法 | 路径 | 功能 |
|---|---|---|
| POST | /api/v1/auth/register |
注册 |
| POST | /api/v1/auth/login |
登录(access + refresh) |
| POST | /api/v1/auth/refresh |
刷新 access |
| POST | /api/v1/auth/logout |
登出(踢掉 Socket) |
| GET | /api/v1/auth/me |
当前用户 |
白板(需登录;按 owner/editor/viewer 鉴权):
| 方法 | 路径 | 功能 | 说明 |
|---|---|---|---|
| GET | /api/v1/boards |
获取白板列表 | 当前用户可访问 |
| POST | /api/v1/boards |
创建新白板 | 创建者为 owner |
| GET | /api/v1/boards/:id |
获取白板详情 | 快照 + lastSeq |
| PATCH | /api/v1/boards/:id |
更新标题 | owner/editor |
| DELETE | /api/v1/boards/:id |
删除白板 | 仅 owner |
2. WebSocket 事件系统
基于 Socket.IO;握手通过 auth.token(JWT access)。房间名 board:{boardId}。
客户端 → 服务器:
| 事件 | 数据 | 功能 |
|---|---|---|
board:join |
{ boardId } |
加入白板房间 |
board:leave |
{ boardId } |
离开白板房间 |
operation:commit |
{ boardId, operation, clientOpId? } |
提交操作(ack) |
operation:replay |
{ boardId, fromSeq } |
拉取 seq > fromSeq |
cursor:update |
{ boardId, x, y } |
发送光标 |
服务器 → 客户端:
| 事件 | 数据 | 功能 |
|---|---|---|
board:joined / ack |
members 等 | join 成功 |
board:user-joined / board:user-left |
user / socketId | 成员变更 |
operation:committed |
含 seq 的已提交 op |
广播给房间其他人 |
operation:replayed / ack |
ops 列表 | 断线补齐 |
cursor:updated |
userId, x, y… | 他人光标 |
3. operation:commit 路径
authorize → persist(boardId+seq 原子写入 + snapshot)→ ack 提交者 → broadcast 其他人。
幂等 clientOpId 命中时只 ack,不重复广播。
4. 数据与重连
Board.snapshot与 op log 在同一事务更新;getBoard在行锁下原子读 snapshot +lastSeq- 客户端用
lastSeq做operation:replay,只补seq > lastSeq
model Board {
id String @id @default(cuid())
title String
snapshot Json // { elements: [...] }
ownerId String?
// permissions / operations / snapshots 关系略
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Operation {
id String @id @default(cuid())
boardId String
seq Int
opType String
clientOpId String?
payload Json
@@unique([boardId, seq])
@@unique([boardId, clientOpId])
}# 安装依赖
pnpm install
# 前端开发
pnpm dev:web
# 后端开发
pnpm dev:server
# Prisma 相关
pnpm prisma:generate # 生成 Prisma Client
pnpm prisma:migrate # 运行数据库迁移- Docker >= 20.10
- Docker Compose >= 2.0
# 进入 docker 目录并启动所有服务
cd docker && docker compose up -d --build- 配置环境变量(可选)
# 复制环境变量示例文件
cp docker/.env.docker.example docker/.env.docker
# 编辑配置(可选,默认配置可直接使用)
vim docker/.env.docker- 构建并启动服务
# 进入 docker 目录
cd docker
# 构建并启动所有服务(使用自定义环境变量)
docker compose --env-file .env.docker up -d --build
# 或直接使用默认配置启动
docker compose up -d --build
# 查看服务状态
docker compose ps
# 查看日志
docker compose logs -f
# 查看特定服务日志
docker compose logs -f server# 停止所有服务
cd docker && docker-compose down
# 停止并删除数据卷
cd docker && docker-compose down -v
# 重新构建并启动
cd docker && docker-compose up -d --build
# 仅重启某个服务
cd docker && docker-compose restart server| 服务 | 端口 | 说明 |
|---|---|---|
| web | 8080 | 前端 Nginx 服务 |
| server | 3000 | 后端 API + WebSocket(容器内 PORT=3000) |
| postgres | 5432 | PostgreSQL 数据库 |
docker/
├── Dockerfile # 多阶段构建文件
├── docker-compose.yml # 服务编排配置
├── nginx.conf # Nginx 配置
├── entrypoint.sh # 服务器启动脚本(含数据库迁移)
└── .env.docker.example # 环境变量示例
| 快捷键 | 功能 |
|---|---|
P |
切换到画笔工具 |
R |
切换到矩形工具 |
O |
切换到圆形工具 |
L |
切换到直线工具 |
E |
切换到橡皮擦工具 |
Delete / Backspace |
删除选中元素 |
Ctrl+Z / Cmd+Z |
撤销 |
Ctrl+Y / Cmd+Shift+Z |
重做 |
Escape |
取消选中 |