refactor: 白板从画笔画板改为文本记事本
原实现是 Canvas 画笔画板,与「文字白板/记事本」需求不符。重做为纯文本实时协作: - model:strokes JSON -> content TEXT + version INT(乐观锁)+ edit_count; schema/dao/service 同步重构,append_stroke/replace_strokes -> update_content。 - WS 协议:stroke -> edit(发完整文本,debounce 400ms);init 下发 content/version。 update 帧广播给他人,cleared 广播给所有人。 - 前端:canvas -> textarea;收到远端 update 用最长公共前后缀算变更区间, 仅替换该区间并保留光标(区间前不动/后平移/内移末尾);清空/复制文本按钮。 - schema.sql 更新 whiteboard 表 DDL;DB 旧表 DROP 重建(开发环境)。 - 测试脚本与 README 同步更新。
This commit is contained in:
20
README.md
20
README.md
@@ -15,9 +15,9 @@
|
|||||||
- **文件浏览页** `GET /files`(Basic Auth,同 docs):列出/下载/**硬删除**已上传文件;删除后不再显示。
|
- **文件浏览页** `GET /files`(Basic Auth,同 docs):列出/下载/**硬删除**已上传文件;删除后不再显示。
|
||||||
管理 API:`GET /api/admin/files`、`GET /api/admin/files/{id}`、`GET /api/admin/files/{id}/download`、
|
管理 API:`GET /api/admin/files`、`GET /api/admin/files/{id}`、`GET /api/admin/files/{id}/download`、
|
||||||
`DELETE /api/admin/files/{id}`(均 Basic Auth)。
|
`DELETE /api/admin/files/{id}`(均 Basic Auth)。
|
||||||
- **共享白板** `GET /wb/{id}`(公开,不存在则新建):Canvas 实时协作 + **清空 / 复制链接**,兼容移动端。
|
- **共享记事本(白板)** `GET /wb/{id}`(公开,不存在则新建):纯文本实时协作 + **清空 / 复制文本**,兼容移动端。
|
||||||
实时同步走 `WS /ws/wb/{id}`(**心跳 3s,连续 5 次丢失判失活并移除**)。
|
实时同步走 `WS /ws/wb/{id}`(**心跳 3s,连续 5 次丢失判失活并移除**)。
|
||||||
- **白板管理页** `GET /wb-admin`(Basic Auth,同 docs):查看创建时间/修改次数/上次修改时间/删除。
|
- **白板管理页** `GET /wb-admin`(Basic Auth,同 docs):查看创建时间/编辑次数/上次修改时间/删除。
|
||||||
管理 API:`GET /api/admin/wb`、`DELETE /api/admin/wb/{id}`(均 Basic Auth)。
|
管理 API:`GET /api/admin/wb`、`DELETE /api/admin/wb/{id}`(均 Basic Auth)。
|
||||||
- **反向隧道反代**:`ALL /api/userPort/{userName}` -- 把请求经 SSH 反向隧道转发到该 user 的本机服务。
|
- **反向隧道反代**:`ALL /api/userPort/{userName}` -- 把请求经 SSH 反向隧道转发到该 user 的本机服务。
|
||||||
- **内置 SFTP/SSH 服务器**(asyncssh),支持 **密码 + 公钥** 鉴权,同时承载 SFTP 文件暂存与反向隧道。
|
- **内置 SFTP/SSH 服务器**(asyncssh),支持 **密码 + 公钥** 鉴权,同时承载 SFTP 文件暂存与反向隧道。
|
||||||
@@ -168,20 +168,24 @@ SSH 服务器(2022)同时承载 SFTP 文件暂存与反向隧道。隧道 us
|
|||||||
- **删除后不再显示**:列表每次进入或删除后重新 fetch,前端不缓存;DB 行已删,列表自然不含。
|
- **删除后不再显示**:列表每次进入或删除后重新 fetch,前端不缓存;DB 行已删,列表自然不含。
|
||||||
- 公开 `/api/files` 系列(user.py 依赖的查重/查询/下载)保留不变。
|
- 公开 `/api/files` 系列(user.py 依赖的查重/查询/下载)保留不变。
|
||||||
|
|
||||||
## 共享白板
|
## 共享记事本(白板)
|
||||||
|
|
||||||
白板无鉴权,任何人凭 `/wb/{id}` 即可访问并实时协作;`{id}` 须匹配
|
白板无鉴权,任何人凭 `/wb/{id}` 即可访问并实时协作;`{id}` 须匹配
|
||||||
`[a-zA-Z0-9_-]{1,64}`,非法返回 400。访问不存在的 id 自动新建空板。白板长期留存
|
`[a-zA-Z0-9_-]{1,64}`,非法返回 400。访问不存在的 id 自动新建空板。白板长期留存
|
||||||
(存 MySQL `whiteboard` 表),进程重启后内容仍在。
|
(存 MySQL `whiteboard` 表,`content` TEXT 列),进程重启后内容仍在。
|
||||||
|
|
||||||
### 实时同步与心跳
|
### 实时同步与心跳
|
||||||
|
|
||||||
- 连接:`WS /ws/wb/{id}`(公开)。JSON 文本帧协议:
|
- 连接:`WS /ws/wb/{id}`(公开)。JSON 文本帧协议:
|
||||||
- client -> server:`{"type":"hello","client_id":"..."}`(首帧,可选)、
|
- client -> server:`{"type":"hello","client_id":"..."}`(首帧,可选)、
|
||||||
`{"type":"ping"}`(心跳)、`{"type":"stroke","stroke":{points,color,width}}`、`{"type":"clear"}`
|
`{"type":"ping"}`(心跳)、`{"type":"edit","content":"..."}`(debounce 后发完整文本)、
|
||||||
- server -> client:`{"type":"init","strokes":[...],"stroke_count":n}`、`{"type":"pong"}`、
|
`{"type":"clear"}`
|
||||||
`{"type":"stroke","stroke":{...},"client_id":"..."}`(广播给他人,不含发送者)、
|
- server -> client:`{"type":"init","content":"...","version":n,"edit_count":m}`、
|
||||||
|
`{"type":"pong"}`、`{"type":"update","content":"...","version":n,"client_id":"..."}`(广播给他人,不含发送者)、
|
||||||
`{"type":"cleared","client_id":"..."}`(广播给所有人)、`{"type":"error","msg":"..."}`
|
`{"type":"cleared","client_id":"..."}`(广播给所有人)、`{"type":"error","msg":"..."}`
|
||||||
|
- **同步策略**:客户端本地编辑后 debounce 400ms 发完整文本,服务端存为新版本(version+1)
|
||||||
|
并广播给同 board 的其他在线连接。其他端用最长公共前后缀算出变更区间,仅替换该区间并
|
||||||
|
保留本地光标位置(在变更区间前不动,在后平移,在区间内移到末尾)。
|
||||||
- **心跳**:客户端每 `whiteboard.heartbeat_interval_seconds`(默认 3s)发一次 `ping`,服务端回 `pong`
|
- **心跳**:客户端每 `whiteboard.heartbeat_interval_seconds`(默认 3s)发一次 `ping`,服务端回 `pong`
|
||||||
并刷新计时。后台 reaper 每秒扫描,连续 `heartbeat_miss_threshold`(默认 5)次未收到心跳
|
并刷新计时。后台 reaper 每秒扫描,连续 `heartbeat_miss_threshold`(默认 5)次未收到心跳
|
||||||
(即 15s)判失活,**关闭该连接并从 hub 移除**。
|
(即 15s)判失活,**关闭该连接并从 hub 移除**。
|
||||||
@@ -197,7 +201,7 @@ SSH 服务器(2022)同时承载 SFTP 文件暂存与反向隧道。隧道 us
|
|||||||
|
|
||||||
- `GET /wb-admin`(Basic Auth,同 docs)渲染 `static/whiteboard_admin.html`。
|
- `GET /wb-admin`(Basic Auth,同 docs)渲染 `static/whiteboard_admin.html`。
|
||||||
- 管理 API(均 Basic Auth):
|
- 管理 API(均 Basic Auth):
|
||||||
- `GET /api/admin/wb?limit=&offset=` -> `{total, items:[{board_id, stroke_count, created_at, updated_at}]}`
|
- `GET /api/admin/wb?limit=&offset=` -> `{total, items:[{board_id, edit_count, created_at, updated_at}]}`
|
||||||
- `DELETE /api/admin/wb/{id}` -> 删 DB 行 + 关闭该 board 所有在线 WS 连接。
|
- `DELETE /api/admin/wb/{id}` -> 删 DB 行 + 关闭该 board 所有在线 WS 连接。
|
||||||
|
|
||||||
## 临时文件清理
|
## 临时文件清理
|
||||||
|
|||||||
@@ -86,18 +86,18 @@ def delete_whiteboard(
|
|||||||
|
|
||||||
@router.websocket("/ws/wb/{board_id}")
|
@router.websocket("/ws/wb/{board_id}")
|
||||||
async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
|
async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
|
||||||
"""白板实时协作端点。
|
"""白板实时协作端点(文本记事本)。
|
||||||
|
|
||||||
协议(JSON 文本帧):
|
协议(JSON 文本帧):
|
||||||
client -> server:
|
client -> server:
|
||||||
{"type":"hello","client_id":"..."} 首帧(可选,未带则服务端生成)
|
{"type":"hello","client_id":"..."} 首帧(可选,未带则服务端生成)
|
||||||
{"type":"ping"} 心跳,服务端回 pong 并刷新计时
|
{"type":"ping"} 心跳,服务端回 pong 并刷新计时
|
||||||
{"type":"stroke","stroke":{...}} 新增笔画,持久化并广播给他人
|
{"type":"edit","content":"..."} debounce 后发完整文本,持久化并广播给他人
|
||||||
{"type":"clear"} 清空,持久化并广播给所有人
|
{"type":"clear"} 清空,持久化并广播给所有人
|
||||||
server -> client:
|
server -> client:
|
||||||
{"type":"init","strokes":[...],"stroke_count":n}
|
{"type":"init","content":"...","version":n,"edit_count":m}
|
||||||
{"type":"pong"}
|
{"type":"pong"}
|
||||||
{"type":"stroke","stroke":{...},"client_id":"..."}
|
{"type":"update","content":"...","version":n,"client_id":"..."} 文本变更
|
||||||
{"type":"cleared","client_id":"..."}
|
{"type":"cleared","client_id":"..."}
|
||||||
{"type":"error","msg":"..."}
|
{"type":"error","msg":"..."}
|
||||||
"""
|
"""
|
||||||
@@ -115,7 +115,6 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
|
|||||||
client_id = _extract_client_id(first) or uuid.uuid4().hex[:12]
|
client_id = _extract_client_id(first) or uuid.uuid4().hex[:12]
|
||||||
|
|
||||||
# 校验 board_id 并加载白板(不存在则新建)
|
# 校验 board_id 并加载白板(不存在则新建)
|
||||||
from ..database import get_session_local
|
|
||||||
try:
|
try:
|
||||||
board = _with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).get_or_create(board_id))
|
board = _with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).get_or_create(board_id))
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
@@ -128,8 +127,9 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
|
|||||||
await hub.register(conn)
|
await hub.register(conn)
|
||||||
await _safe_send(websocket, {
|
await _safe_send(websocket, {
|
||||||
"type": "init",
|
"type": "init",
|
||||||
"strokes": board.strokes,
|
"content": board.content,
|
||||||
"stroke_count": board.stroke_count,
|
"version": board.version,
|
||||||
|
"edit_count": board.edit_count,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 主循环:收消息 -> 处理 -> 广播
|
# 主循环:收消息 -> 处理 -> 广播
|
||||||
@@ -146,17 +146,22 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
|
|||||||
continue
|
continue
|
||||||
# 任何有效业务帧都视为活性证据
|
# 任何有效业务帧都视为活性证据
|
||||||
conn.touch()
|
conn.touch()
|
||||||
if mtype == "stroke":
|
if mtype == "edit":
|
||||||
stroke = msg.get("stroke") or {}
|
content = msg.get("content")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
await _safe_send(websocket, {"type": "error", "msg": "content 必须是字符串"})
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
_with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).append_stroke(board_id, stroke))
|
out = _with_db(
|
||||||
|
lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).update_content(board_id, content)
|
||||||
|
)
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
await _safe_send(websocket, {"type": "error", "msg": exc.detail})
|
await _safe_send(websocket, {"type": "error", "msg": exc.detail})
|
||||||
continue
|
continue
|
||||||
# 广播给他人(发送者本地已画,不回推)
|
# 广播给他人(发送者本地已更新,不回推)
|
||||||
await hub.broadcast(
|
await hub.broadcast(
|
||||||
board_id,
|
board_id,
|
||||||
{"type": "stroke", "stroke": stroke, "client_id": client_id},
|
{"type": "update", "content": out.content, "version": out.version, "client_id": client_id},
|
||||||
exclude=conn,
|
exclude=conn,
|
||||||
)
|
)
|
||||||
elif mtype == "clear":
|
elif mtype == "clear":
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""Whiteboard 的 DAO。
|
"""Whiteboard 的 DAO(文本记事本)。
|
||||||
|
|
||||||
所有写操作均在该层 commit,service 不直接操作 session。
|
所有写操作均在该层 commit,service 不直接操作 session。
|
||||||
get_or_create 用于「访问即新建」语义(路由 GET /whiteboard/{id} 不存在则建)。
|
get_or_create 用于「访问即新建」语义(路由 GET /api/wb/{id} 不存在则建)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -33,7 +31,7 @@ class WhiteboardDAO:
|
|||||||
board = self.get(board_id)
|
board = self.get(board_id)
|
||||||
if board is not None:
|
if board is not None:
|
||||||
return board
|
return board
|
||||||
board = Whiteboard(board_id=board_id, strokes=[], stroke_count=0)
|
board = Whiteboard(board_id=board_id, content="", version=0, edit_count=0)
|
||||||
try:
|
try:
|
||||||
return self.create(board)
|
return self.create(board)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -41,24 +39,14 @@ class WhiteboardDAO:
|
|||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
return self.get(board_id) # type: ignore[return-value]
|
return self.get(board_id) # type: ignore[return-value]
|
||||||
|
|
||||||
def append_strokes(self, board_id: str, new_strokes: list[Any]) -> Whiteboard | None:
|
def update_content(self, board_id: str, content: str) -> Whiteboard | None:
|
||||||
"""把新笔画追加到 strokes 数组尾部,stroke_count 自增。"""
|
"""整体替换文本内容,version +1、edit_count +1。"""
|
||||||
board = self.get(board_id)
|
board = self.get(board_id)
|
||||||
if board is None:
|
if board is None:
|
||||||
return None
|
return None
|
||||||
board.strokes = [*board.strokes, *new_strokes]
|
board.content = content
|
||||||
board.stroke_count = (board.stroke_count or 0) + len(new_strokes)
|
board.version = (board.version or 0) + 1
|
||||||
self.db.commit()
|
board.edit_count = (board.edit_count or 0) + 1
|
||||||
self.db.refresh(board)
|
|
||||||
return board
|
|
||||||
|
|
||||||
def replace_strokes(self, board_id: str, strokes: list[Any]) -> Whiteboard | None:
|
|
||||||
"""整体替换 strokes(清空时传 []),stroke_count 自增 1。"""
|
|
||||||
board = self.get(board_id)
|
|
||||||
if board is None:
|
|
||||||
return None
|
|
||||||
board.strokes = list(strokes)
|
|
||||||
board.stroke_count = (board.stroke_count or 0) + 1
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(board)
|
self.db.refresh(board)
|
||||||
return board
|
return board
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"""共享白板实体。
|
"""共享白板实体(文本记事本)。
|
||||||
|
|
||||||
一个白板由 board_id 唯一标识(用户可读的 url id),strokes 以 JSON 列保存全部笔画。
|
一个白板由 board_id 唯一标识(用户可读的 url id),content 存完整文本,
|
||||||
白板长期留存,进程重启后仍可恢复;实时协作由 WebSocket hub 在内存中维护在线连接,
|
version 是乐观锁版本号(每次编辑 +1)。白板长期留存,进程重启后仍可恢复;
|
||||||
笔画变更经 service 落库后由 hub 广播给同 board 的其它在线连接。
|
实时协作由 WebSocket hub 在内存中维护在线连接,文本变更经 service 落库后
|
||||||
|
由 hub 广播给同 board 的其它在线连接。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, DateTime, Integer, JSON, String, func
|
from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from ..database import Base
|
from ..database import Base
|
||||||
@@ -21,10 +22,12 @@ class Whiteboard(Base):
|
|||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
# 用户可读的 url id([a-zA-Z0-9_-]{1,64}),全局唯一
|
# 用户可读的 url id([a-zA-Z0-9_-]{1,64}),全局唯一
|
||||||
board_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
board_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||||
# 笔画数组 [{points:[[x,y],...], color, width}, ...]
|
# 白板文本内容(记事本)
|
||||||
strokes: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
# 修改次数:每次新增笔画或清空 +1,供管理页统计
|
# 乐观锁版本号:每次编辑 +1
|
||||||
stroke_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# 编辑次数(累计修改次数,含清空),供管理页统计
|
||||||
|
edit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, server_default=func.now(), nullable=False
|
DateTime, server_default=func.now(), nullable=False
|
||||||
)
|
)
|
||||||
@@ -35,5 +38,5 @@ class Whiteboard(Base):
|
|||||||
def __repr__(self) -> str: # pragma: no cover
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
return (
|
return (
|
||||||
f"<Whiteboard board_id={self.board_id!r} "
|
f"<Whiteboard board_id={self.board_id!r} "
|
||||||
f"strokes={len(self.strokes)} mods={self.stroke_count}>"
|
f"len={len(self.content)} v={self.version} mods={self.edit_count}>"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ from .file import FileListResponse, FileUploadResponse, UploadedFileOut
|
|||||||
from .system import DiskUsage, MemoryUsage, SystemStatus
|
from .system import DiskUsage, MemoryUsage, SystemStatus
|
||||||
from .tunnel import TunnelStatusResponse
|
from .tunnel import TunnelStatusResponse
|
||||||
from .whiteboard import (
|
from .whiteboard import (
|
||||||
Stroke,
|
|
||||||
StrokeOp,
|
|
||||||
WhiteboardListItem,
|
WhiteboardListItem,
|
||||||
WhiteboardListResponse,
|
WhiteboardListResponse,
|
||||||
WhiteboardOut,
|
WhiteboardOut,
|
||||||
@@ -26,8 +24,6 @@ __all__ = [
|
|||||||
"FileUploadResponse",
|
"FileUploadResponse",
|
||||||
"MemoryUsage",
|
"MemoryUsage",
|
||||||
"SessionStatusResponse",
|
"SessionStatusResponse",
|
||||||
"Stroke",
|
|
||||||
"StrokeOp",
|
|
||||||
"SystemStatus",
|
"SystemStatus",
|
||||||
"TunnelStatusResponse",
|
"TunnelStatusResponse",
|
||||||
"UploadedFileOut",
|
"UploadedFileOut",
|
||||||
|
|||||||
@@ -1,27 +1,19 @@
|
|||||||
"""白板接口 DTO。"""
|
"""白板接口 DTO(文本记事本)。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class Stroke(BaseModel):
|
|
||||||
"""一条笔画:点序列 + 样式。结构宽松(Any)以兼容前端扩展字段。"""
|
|
||||||
|
|
||||||
points: list[list[float]] = Field(default_factory=list, description="[[x,y],...]")
|
|
||||||
color: str = Field(default="#1565c0", description="笔画颜色")
|
|
||||||
width: float = Field(default=3, description="笔画宽度")
|
|
||||||
|
|
||||||
|
|
||||||
class WhiteboardOut(BaseModel):
|
class WhiteboardOut(BaseModel):
|
||||||
"""白板完整内容(GET /whiteboard/{id} 与 WS init 帧)。"""
|
"""白板完整内容(GET /api/wb/{id} 与 WS init 帧)。"""
|
||||||
|
|
||||||
board_id: str
|
board_id: str
|
||||||
strokes: list[Any] = Field(default_factory=list, description="笔画数组")
|
content: str = Field("", description="白板文本内容")
|
||||||
stroke_count: int = Field(0, description="累计修改次数")
|
version: int = Field(0, description="乐观锁版本号,每次编辑 +1")
|
||||||
|
edit_count: int = Field(0, description="累计编辑次数")
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -32,7 +24,7 @@ class WhiteboardListItem(BaseModel):
|
|||||||
"""管理页列表项。"""
|
"""管理页列表项。"""
|
||||||
|
|
||||||
board_id: str
|
board_id: str
|
||||||
stroke_count: int
|
edit_count: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -44,10 +36,3 @@ class WhiteboardListResponse(BaseModel):
|
|||||||
|
|
||||||
total: int
|
total: int
|
||||||
items: list[WhiteboardListItem]
|
items: list[WhiteboardListItem]
|
||||||
|
|
||||||
|
|
||||||
class StrokeOp(BaseModel):
|
|
||||||
"""WS 笔画操作(type=stroke 时携带)。"""
|
|
||||||
|
|
||||||
type: str = Field(..., description="add / clear")
|
|
||||||
stroke: dict[str, Any] | None = Field(None, description="type=add 时携带的笔画对象")
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""白板服务:CRUD + 笔画操作。
|
"""白板服务(文本记事本):CRUD + 文本更新 + 清空。
|
||||||
|
|
||||||
不持有 WebSocket 连接状态(那是 hub 的职责);删除白板时通过可选的 hub 回调
|
不持有 WebSocket 连接状态(那是 hub 的职责);删除白板时通过可选的 hub 回调
|
||||||
通知 hub 踢出该 board 的所有在线连接,由 controller 在装配时注入,避免 service -> hub
|
通知 hub 踢出该 board 的所有在线连接,由 controller 在装配时注入,避免 service -> hub
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ class WhiteboardService:
|
|||||||
cfg = get_settings().whiteboard
|
cfg = get_settings().whiteboard
|
||||||
self.max_board_id_length = cfg.max_board_id_length
|
self.max_board_id_length = cfg.max_board_id_length
|
||||||
self.list_limit = cfg.list_limit
|
self.list_limit = cfg.list_limit
|
||||||
|
# 文本内容长度上限(防滥用)
|
||||||
|
self.max_content_length = 256 * 1024
|
||||||
|
|
||||||
# ---------------- 校验 ----------------
|
# ---------------- 校验 ----------------
|
||||||
|
|
||||||
@@ -42,6 +44,15 @@ class WhiteboardService:
|
|||||||
):
|
):
|
||||||
raise HTTPException(400, "board_id 非法(仅允许字母数字下划线短横线,1-64 字符)")
|
raise HTTPException(400, "board_id 非法(仅允许字母数字下划线短横线,1-64 字符)")
|
||||||
|
|
||||||
|
def validate_content(self, content: str) -> None:
|
||||||
|
if not isinstance(content, str):
|
||||||
|
raise HTTPException(400, "content 必须是字符串")
|
||||||
|
if len(content) > self.max_content_length:
|
||||||
|
raise HTTPException(
|
||||||
|
413,
|
||||||
|
f"文本过长({len(content)} > {self.max_content_length}),请缩减内容",
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------- 读 ----------------
|
# ---------------- 读 ----------------
|
||||||
|
|
||||||
def get_or_create(self, board_id: str) -> WhiteboardOut:
|
def get_or_create(self, board_id: str) -> WhiteboardOut:
|
||||||
@@ -59,21 +70,18 @@ class WhiteboardService:
|
|||||||
|
|
||||||
# ---------------- 写 ----------------
|
# ---------------- 写 ----------------
|
||||||
|
|
||||||
def append_stroke(self, board_id: str, stroke: dict[str, Any]) -> WhiteboardOut:
|
def update_content(self, board_id: str, content: str) -> WhiteboardOut:
|
||||||
"""追加一条笔画并返回最新状态。"""
|
"""整体替换文本内容(客户端 debounce 后发完整文本)。"""
|
||||||
self.validate_board_id(board_id)
|
self.validate_board_id(board_id)
|
||||||
board = self.dao.append_strokes(board_id, [stroke])
|
self.validate_content(content)
|
||||||
|
board = self.dao.update_content(board_id, content)
|
||||||
if board is None:
|
if board is None:
|
||||||
raise HTTPException(404, "白板不存在")
|
raise HTTPException(404, "白板不存在")
|
||||||
return WhiteboardOut.model_validate(board)
|
return WhiteboardOut.model_validate(board)
|
||||||
|
|
||||||
def clear(self, board_id: str) -> WhiteboardOut:
|
def clear(self, board_id: str) -> WhiteboardOut:
|
||||||
"""清空白板;stroke_count 仍自增以记录这次修改。"""
|
"""清空白板(内容置空),edit_count 仍自增以记录这次修改。"""
|
||||||
self.validate_board_id(board_id)
|
return self.update_content(board_id, "")
|
||||||
board = self.dao.replace_strokes(board_id, [])
|
|
||||||
if board is None:
|
|
||||||
raise HTTPException(404, "白板不存在")
|
|
||||||
return WhiteboardOut.model_validate(board)
|
|
||||||
|
|
||||||
def delete(self, board_id: str) -> bool:
|
def delete(self, board_id: str) -> bool:
|
||||||
"""删除白板;同时通知 hub 踢出该 board 的所有在线连接。"""
|
"""删除白板;同时通知 hub 踢出该 board 的所有在线连接。"""
|
||||||
|
|||||||
@@ -53,13 +53,15 @@ CREATE TABLE IF NOT EXISTS `tunnel_session` (
|
|||||||
KEY `idx_status` (`status`)
|
KEY `idx_status` (`status`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 共享白板表。由 app/services/whiteboard_service.py 使用。
|
-- 共享白板表(文本记事本)。由 app/services/whiteboard_service.py 使用。
|
||||||
-- 白板长期留存,strokes 以 JSON 保存全部笔画;实时同步由 WebSocket hub 在内存维护。
|
-- content 存完整文本,version 是乐观锁版本号(每次编辑 +1),edit_count 累计编辑次数。
|
||||||
|
-- 白板长期留存,实时同步由 WebSocket hub 在内存维护在线连接。
|
||||||
CREATE TABLE IF NOT EXISTS `whiteboard` (
|
CREATE TABLE IF NOT EXISTS `whiteboard` (
|
||||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
`board_id` VARCHAR(64) NOT NULL COMMENT '用户可读的 url id,[a-zA-Z0-9_-]{1,64}',
|
`board_id` VARCHAR(64) NOT NULL COMMENT '用户可读的 url id,[a-zA-Z0-9_-]{1,64}',
|
||||||
`strokes` JSON NOT NULL COMMENT '笔画数组 [{points,color,width}, ...]',
|
`content` MEDIUMTEXT NOT NULL COMMENT '白板文本内容',
|
||||||
`stroke_count` INT NOT NULL DEFAULT 0 COMMENT '累计修改次数(新增笔画/清空各 +1)',
|
`version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次编辑 +1',
|
||||||
|
`edit_count` INT NOT NULL DEFAULT 0 COMMENT '累计编辑次数(含清空)',
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* 白板页专属样式:全屏画布、悬浮工具栏、移动端适配。 */
|
/* 记事本页专属样式:全屏 textarea、悬浮工具栏、移动端适配。 */
|
||||||
:root { --bar-h: 52px; }
|
:root { --bar-h: 52px; }
|
||||||
body { overflow: hidden; background: var(--bg); }
|
body { overflow: hidden; background: var(--bg); }
|
||||||
.wb-app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
|
.wb-app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
|
||||||
@@ -14,22 +14,20 @@ body { overflow: hidden; background: var(--bg); }
|
|||||||
.wb-id { background: var(--surface-2); padding: 0.15em 0.5em; border-radius: 6px; font-size: 0.82em; color: var(--text-dim); }
|
.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 { color: var(--success); font-size: 0.7em; }
|
||||||
.wb-online.off { color: var(--text-dim); }
|
.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-bar .btn { padding: 0.4em 0.9em; font-size: 0.86em; }
|
||||||
|
|
||||||
.wb-stage { position: relative; flex: 1; overflow: hidden; }
|
.wb-stage { position: relative; flex: 1; overflow: hidden; }
|
||||||
#canvas {
|
.wb-editor {
|
||||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
position: absolute; inset: 0;
|
||||||
display: block; touch-action: none; cursor: crosshair;
|
width: 100%; height: 100%;
|
||||||
background:
|
display: block; resize: none; border: none; outline: none;
|
||||||
linear-gradient(var(--border) 1px, transparent 1px) 0 0 / 24px 24px,
|
padding: 1em 1.2em;
|
||||||
linear-gradient(90deg, var(--border) 1px, transparent 1px) 0 0 / 24px 24px,
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, "JetBrains Mono", monospace;
|
||||||
var(--surface);
|
font-size: 14px; line-height: 1.6;
|
||||||
background-blend-mode: normal;
|
background: var(--surface); color: var(--text);
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
.wb-editor::placeholder { color: var(--text-dim); }
|
||||||
.wb-status {
|
.wb-status {
|
||||||
position: absolute; bottom: 0.8em; left: 50%; transform: translateX(-50%);
|
position: absolute; bottom: 0.8em; left: 50%; transform: translateX(-50%);
|
||||||
background: var(--surface); border: 1px solid var(--border);
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
@@ -44,7 +42,7 @@ body { overflow: hidden; background: var(--bg); }
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.wb-bar { padding: 0.4em 0.5em; gap: 0.4em; }
|
.wb-bar { padding: 0.4em 0.5em; gap: 0.4em; }
|
||||||
.wb-id { max-width: 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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-bar .btn { padding: 0.45em 0.7em; }
|
||||||
.wb-title { display: none; }
|
.wb-title { display: none; }
|
||||||
|
.wb-editor { font-size: 15px; padding: 0.8em; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
<meta name="theme-color" content="#1565c0">
|
<meta name="theme-color" content="#1565c0">
|
||||||
<title>白板 - zikai</title>
|
<title>记事本 - zikai</title>
|
||||||
<link rel="stylesheet" href="/static/common.css">
|
<link rel="stylesheet" href="/static/common.css">
|
||||||
<link rel="stylesheet" href="/static/whiteboard.css">
|
<link rel="stylesheet" href="/static/whiteboard.css">
|
||||||
</head>
|
</head>
|
||||||
@@ -12,24 +12,17 @@
|
|||||||
<div class="wb-app">
|
<div class="wb-app">
|
||||||
<header class="wb-bar">
|
<header class="wb-bar">
|
||||||
<div class="wb-bar-left">
|
<div class="wb-bar-left">
|
||||||
<span class="wb-title">白板</span>
|
<span class="wb-title">记事本</span>
|
||||||
<code class="wb-id mono" id="boardId" title="白板 ID"></code>
|
<code class="wb-id mono" id="boardId" title="记事本 ID"></code>
|
||||||
<span class="wb-online" id="online" title="在线人数">●</span>
|
<span class="wb-online" id="online" title="在线人数">●</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="wb-bar-right">
|
<div class="wb-bar-right">
|
||||||
<label class="wb-tool" title="笔画颜色">
|
<button class="btn" id="copyBtn" title="复制全部文本">复制文本</button>
|
||||||
<input type="color" id="color" value="#1565c0">
|
<button class="btn danger" id="clearBtn" title="清空全部内容(所有人)">清空</button>
|
||||||
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="wb-stage">
|
<main class="wb-stage">
|
||||||
<canvas id="canvas"></canvas>
|
<textarea id="editor" class="wb-editor" placeholder="在此输入文本,所有人会实时看到你的编辑…" spellcheck="false" autocomplete="off"></textarea>
|
||||||
<div class="wb-status" id="status">连接中…</div>
|
<div class="wb-status" id="status">连接中…</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,35 +1,26 @@
|
|||||||
/* 白板:Canvas 绘画 + WebSocket 实时同步 + 心跳。
|
/* 记事本:textarea + WebSocket 实时同步 + 心跳。
|
||||||
- 笔画以 {points:[[x,y],...], color, width} 表示,增量广播。
|
- 文本以整文本 debounce 400ms 后发服务端,服务端存为新版本并广播给其他端。
|
||||||
- 心跳 3s 一次 ping;服务端 15s 无心跳判失活会主动断连,前端据此重连。
|
- 收到他人 update 时用 diff 应用变更,保留本地光标位置(按相对偏移调整)。
|
||||||
- 收到他人 stroke 增量重绘该笔;收到 cleared 清空本地画布。
|
- 收到 cleared 清空本地 textarea。
|
||||||
- 兼容鼠标 + 触摸:统一用 pointer events,touch-action:none 防滚动缩放。 */
|
- 心跳 3s 一次 ping;服务端 15s 无心跳判失活会主动断连,前端据此重连。 */
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
const { el, toast, copyText } = window.ZK;
|
const { toast, copyText } = window.ZK;
|
||||||
|
|
||||||
// ---------- 从 URL 解析 board_id ----------
|
// ---------- 从 URL 解析 board_id ----------
|
||||||
// 路径形如 /wb/{id};id 为 [a-zA-Z0-9_-]{1,64}
|
|
||||||
const m = location.pathname.match(/^\/wb\/([^/]+)\/?$/);
|
const m = location.pathname.match(/^\/wb\/([^/]+)\/?$/);
|
||||||
let boardId = m ? decodeURIComponent(m[1]) : "default";
|
let boardId = m ? decodeURIComponent(m[1]) : "default";
|
||||||
// 合法性兜底:前端非法字符直接回退到 default,真正校验在服务端
|
|
||||||
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default";
|
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default";
|
||||||
document.getElementById("boardId").textContent = boardId;
|
document.getElementById("boardId").textContent = boardId;
|
||||||
|
|
||||||
// ---------- DOM ----------
|
// ---------- DOM ----------
|
||||||
const canvas = document.getElementById("canvas");
|
const editor = document.getElementById("editor");
|
||||||
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 clearBtn = document.getElementById("clearBtn");
|
||||||
const copyBtn = document.getElementById("copyBtn");
|
const copyBtn = document.getElementById("copyBtn");
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
const onlineEl = document.getElementById("online");
|
const onlineEl = document.getElementById("online");
|
||||||
|
|
||||||
// ---------- 状态 ----------
|
// ---------- 状态 ----------
|
||||||
let strokes = []; // 已确认的笔画
|
|
||||||
let current = null; // 正在画的笔画(本地未提交)
|
|
||||||
let drawing = false;
|
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let clientId = localStorage.getItem("wb_cid") || "";
|
let clientId = localStorage.getItem("wb_cid") || "";
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
@@ -39,111 +30,99 @@
|
|||||||
let heartbeatTimer = null;
|
let heartbeatTimer = null;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let connected = false;
|
let connected = false;
|
||||||
|
let lastSentText = ""; // 上次发到服务端的文本(避免无变更时重复发)
|
||||||
|
let suppressInput = false; // 应用远端更新时抑制 input 事件,防回环
|
||||||
|
let debounceTimer = null;
|
||||||
|
|
||||||
// ---------- 画布尺寸 ----------
|
// ---------- 本地编辑 -> debounce -> 发送 ----------
|
||||||
function resize() {
|
editor.addEventListener("input", () => {
|
||||||
const dpr = window.devicePixelRatio || 1;
|
if (suppressInput) return;
|
||||||
const w = canvas.clientWidth;
|
scheduleSend();
|
||||||
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));
|
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", () => {
|
clearBtn.addEventListener("click", () => {
|
||||||
if (!connected) { flashStatus("未连接"); return; }
|
if (!connected) { flashStatus("未连接"); return; }
|
||||||
if (!confirm("确定清空白板?所有人的内容都会被清除。")) return;
|
if (!confirm("确定清空全部内容?所有人的内容都会被清除。")) return;
|
||||||
send({ type: "clear" });
|
send({ type: "clear" });
|
||||||
});
|
});
|
||||||
|
|
||||||
copyBtn.addEventListener("click", async () => {
|
copyBtn.addEventListener("click", async () => {
|
||||||
const url = `${location.origin}/wb/${boardId}`;
|
const text = editor.value;
|
||||||
const ok = await copyText(url);
|
if (!text) { toast("内容为空"); return; }
|
||||||
toast(ok ? "链接已复制" : "复制失败");
|
const ok = await copyText(text);
|
||||||
|
toast(ok ? "已复制全部文本" : "复制失败");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- 应用远端更新(保留光标) ----------
|
||||||
|
// 策略:用最长公共前后缀算出变更区间,仅替换该区间,光标按相对位置调整。
|
||||||
|
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--;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- WebSocket ----------
|
// ---------- WebSocket ----------
|
||||||
function wsUrl() {
|
function wsUrl() {
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
@@ -160,7 +139,7 @@
|
|||||||
}
|
}
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
connected = true;
|
connected = true;
|
||||||
setStatus("已连接", true);
|
setStatus("已连接", false);
|
||||||
onlineEl.classList.remove("off");
|
onlineEl.classList.remove("off");
|
||||||
send({ type: "hello", client_id: clientId });
|
send({ type: "hello", client_id: clientId });
|
||||||
startHeartbeat();
|
startHeartbeat();
|
||||||
@@ -175,23 +154,26 @@
|
|||||||
try { msg = JSON.parse(raw); } catch { return; }
|
try { msg = JSON.parse(raw); } catch { return; }
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "init":
|
case "init":
|
||||||
strokes = Array.isArray(msg.strokes) ? msg.strokes : [];
|
suppressInput = true;
|
||||||
redraw();
|
editor.value = msg.content || "";
|
||||||
setStatus(`已同步 ${strokes.length} 笔`, true);
|
lastSentText = editor.value;
|
||||||
|
suppressInput = false;
|
||||||
|
editor.focus();
|
||||||
|
setStatus("已同步", false);
|
||||||
break;
|
break;
|
||||||
case "pong":
|
case "pong":
|
||||||
// 心跳回声,保持连接
|
|
||||||
break;
|
break;
|
||||||
case "stroke":
|
case "update":
|
||||||
if (msg.client_id === clientId) break; // 自己的,已本地画
|
if (msg.client_id === clientId) break; // 自己的,已本地更新
|
||||||
strokes.push(msg.stroke);
|
applyRemoteUpdate(msg.content || "");
|
||||||
drawStroke(msg.stroke);
|
flashStatus("对方有更新");
|
||||||
break;
|
break;
|
||||||
case "cleared":
|
case "cleared":
|
||||||
strokes = [];
|
suppressInput = true;
|
||||||
current = null;
|
editor.value = "";
|
||||||
redraw();
|
lastSentText = "";
|
||||||
flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了白板");
|
suppressInput = false;
|
||||||
|
flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了内容");
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
flashStatus(msg.msg || "错误", true);
|
flashStatus(msg.msg || "错误", true);
|
||||||
@@ -244,26 +226,31 @@
|
|||||||
// ---------- 启动 ----------
|
// ---------- 启动 ----------
|
||||||
// 先 GET /api/wb/{id} 确保白板存在(不存在则服务端新建),再连 WS
|
// 先 GET /api/wb/{id} 确保白板存在(不存在则服务端新建),再连 WS
|
||||||
fetch(`/api/wb/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } })
|
fetch(`/api/wb/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } })
|
||||||
.then((r) => r.ok ? r.json() : null)
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
if (body && Array.isArray(body.strokes)) {
|
if (body && typeof body.content === "string") {
|
||||||
strokes = body.strokes;
|
editor.value = body.content;
|
||||||
redraw();
|
lastSentText = body.content;
|
||||||
}
|
}
|
||||||
resize();
|
|
||||||
connect();
|
connect();
|
||||||
})
|
})
|
||||||
.catch(() => { resize(); connect(); });
|
.catch(() => { connect(); });
|
||||||
|
|
||||||
// 页面隐藏时不发心跳可被服务端判失活;重新可见时主动重连
|
// 页面可见性:重新可见时若已断连则主动重连;隐藏时 flush 未发送的编辑
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (document.visibilityState === "visible" && (!ws || ws.readyState !== WebSocket.OPEN)) {
|
if (document.visibilityState === "visible") {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
connect();
|
connect();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
flushSend();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 离开前 flush 未发送编辑
|
||||||
window.addEventListener("beforeunload", () => {
|
window.addEventListener("beforeunload", () => {
|
||||||
|
flushSend();
|
||||||
stopHeartbeat();
|
stopHeartbeat();
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
try { ws && ws.close(); } catch {}
|
try { ws && ws.close(); } catch {}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
el("td", { class: "col-id" },
|
el("td", { class: "col-id" },
|
||||||
el("a", { class: "bid link", href: `/wb/${b.board_id}`, target: "_blank" }, b.board_id)
|
el("a", { class: "bid link", href: `/wb/${b.board_id}`, target: "_blank" }, b.board_id)
|
||||||
),
|
),
|
||||||
el("td", { class: "col-mods mono" }, String(b.stroke_count ?? 0)),
|
el("td", { class: "col-mods mono" }, String(b.edit_count ?? 0)),
|
||||||
el("td", { class: "col-created muted" }, fmtTime(b.created_at)),
|
el("td", { class: "col-created muted" }, fmtTime(b.created_at)),
|
||||||
el("td", { class: "col-updated muted" }, fmtTime(b.updated_at)),
|
el("td", { class: "col-updated muted" }, fmtTime(b.updated_at)),
|
||||||
el("td", { class: "col-act" },
|
el("td", { class: "col-act" },
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
"""白板 WebSocket 端到端烟测:两客户端实时同步 + 心跳 + 清空。
|
"""白板 WebSocket 端到端烟测(文本记事本):两客户端实时同步 + 心跳 + 清空。
|
||||||
|
|
||||||
验证:
|
验证:
|
||||||
1. 客户端 A 连入 -> 收到 init
|
1. 客户端 A 连入 -> 收到 init(含 content/version)
|
||||||
2. 客户端 B 连入 -> 收到 init
|
2. 客户端 B 连入 -> 收到 init
|
||||||
3. A 画一笔 -> B 收到 stroke 广播(A 不收自己的)
|
3. A 编辑文本 -> B 收到 update(A 不收自己的)
|
||||||
4. 心跳 ping -> pong
|
4. 心跳 ping -> pong
|
||||||
5. A 清空 -> A、B 都收到 cleared
|
5. A 清空 -> A、B 都收到 cleared
|
||||||
6. 停发心跳的连接会被服务端 reaper 移除(15s,这里只验证 ping/pong 即可,reaper 已在 hub 单测覆盖)
|
6. 持久化:重连后 init 应返回清空后的内容
|
||||||
|
7. edit_count 累计
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
@@ -31,7 +33,6 @@ async def recv_msg(ws, timeout=2.0) -> dict | None:
|
|||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
async with websockets.connect(f"{BASE_WS}/{BOARD}") as a, \
|
async with websockets.connect(f"{BASE_WS}/{BOARD}") as a, \
|
||||||
websockets.connect(f"{BASE_WS}/{BOARD}") as b:
|
websockets.connect(f"{BASE_WS}/{BOARD}") as b:
|
||||||
# hello
|
|
||||||
await a.send(json.dumps({"type": "hello", "client_id": "A"}))
|
await a.send(json.dumps({"type": "hello", "client_id": "A"}))
|
||||||
await b.send(json.dumps({"type": "hello", "client_id": "B"}))
|
await b.send(json.dumps({"type": "hello", "client_id": "B"}))
|
||||||
|
|
||||||
@@ -42,18 +43,18 @@ async def main() -> None:
|
|||||||
assert init_a and init_a["type"] == "init"
|
assert init_a and init_a["type"] == "init"
|
||||||
assert init_b and init_b["type"] == "init"
|
assert init_b and init_b["type"] == "init"
|
||||||
|
|
||||||
# A 画一笔
|
# A 编辑文本
|
||||||
stroke = {"points": [[10, 10], [20, 20]], "color": "#1565c0", "width": 3}
|
text = "Hello, this is a shared note.\nSecond line."
|
||||||
await a.send(json.dumps({"type": "stroke", "stroke": stroke}))
|
await a.send(json.dumps({"type": "edit", "content": text}))
|
||||||
# A 不应收到自己的(排除发送者)
|
# A 不应收到自己的 update
|
||||||
echo = await recv_msg(a, timeout=1.0)
|
echo = await recv_msg(a, timeout=1.0)
|
||||||
print("A self-echo (expect None):", echo)
|
print("A self-echo (expect None):", echo)
|
||||||
assert echo is None, "发送者不应收到自己的 stroke"
|
assert echo is None, "发送者不应收到自己的 update"
|
||||||
# B 应收到
|
# B 应收到 update
|
||||||
got = await recv_msg(b)
|
got = await recv_msg(b)
|
||||||
print("B recv:", got.get("type") if got else None, "client_id=", got.get("client_id") if got else None)
|
print("B recv:", got.get("type") if got else None, "content=", repr(got.get("content")) if got else None)
|
||||||
assert got and got["type"] == "stroke" and got["client_id"] == "A"
|
assert got and got["type"] == "update" and got["client_id"] == "A"
|
||||||
assert got["stroke"] == stroke
|
assert got["content"] == text
|
||||||
|
|
||||||
# 心跳
|
# 心跳
|
||||||
await a.send(json.dumps({"type": "ping"}))
|
await a.send(json.dumps({"type": "ping"}))
|
||||||
@@ -70,20 +71,19 @@ async def main() -> None:
|
|||||||
assert cleared_b and cleared_b["type"] == "cleared" and cleared_b["client_id"] == "B"
|
assert cleared_b and cleared_b["type"] == "cleared" and cleared_b["client_id"] == "B"
|
||||||
assert cleared_a and cleared_a["type"] == "cleared" and cleared_a["client_id"] == "B"
|
assert cleared_a and cleared_a["type"] == "cleared" and cleared_a["client_id"] == "B"
|
||||||
|
|
||||||
# 验证持久化:重连后 init 应 strokes 为空(已清空)
|
# 验证持久化:重连后 init 应 content 为空(已清空)
|
||||||
async with websockets.connect(f"{BASE_WS}/{BOARD}") as c:
|
async with websockets.connect(f"{BASE_WS}/{BOARD}") as c:
|
||||||
await c.send(json.dumps({"type": "hello", "client_id": "C"}))
|
await c.send(json.dumps({"type": "hello", "client_id": "C"}))
|
||||||
init_c = await recv_msg(c)
|
init_c = await recv_msg(c)
|
||||||
print("C init after clear, strokes=", init_c.get("strokes") if init_c else None)
|
print("C init after clear, content=", repr(init_c.get("content")) if init_c else None)
|
||||||
assert init_c and init_c["type"] == "init"
|
assert init_c and init_c["type"] == "init"
|
||||||
assert init_c["strokes"] == [], "清空后重连应得到空 strokes"
|
assert init_c["content"] == "", "清空后重连应得到空 content"
|
||||||
|
|
||||||
# 验证 stroke_count 累计(之前 1 笔 + 1 次清空 = 2)
|
# 验证 edit_count 累计(1 次编辑 + 1 次清空 = 2)
|
||||||
import urllib.request
|
|
||||||
with urllib.request.urlopen(f"http://127.0.0.1:6867/api/wb/{BOARD}") as r:
|
with urllib.request.urlopen(f"http://127.0.0.1:6867/api/wb/{BOARD}") as r:
|
||||||
meta = json.load(r)
|
meta = json.load(r)
|
||||||
print("stroke_count after ops:", meta["stroke_count"])
|
print("edit_count after ops:", meta["edit_count"])
|
||||||
assert meta["stroke_count"] == 2, "1 笔 + 1 清空 = 2 次修改"
|
assert meta["edit_count"] == 2, "1 编辑 + 1 清空 = 2 次"
|
||||||
|
|
||||||
print("\nWS 端到端全部通过 ✅")
|
print("\nWS 端到端全部通过 ✅")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user