8 个任务:前端 WS 基类支持二进制 → 后端 collab 通道控制面 → 房间与帧转发 → 前端 store → 学生求助按钮 → 教师顶栏列表 → 协作编辑 → 清理旧实现。 每个任务收尾是手动验证步骤(项目约定不写测试)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
66 KiB
课堂求助与协作编辑 实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 学生就某道题发起求助,老师从全局列表接单并进入该学生的编辑器协作改代码;传输从 y-webrtc P2P 换成后端 WebSocket 通道,房间归属与权限由服务端判定。
Architecture: 新增 /ws/collab 通道,登录即连、全局常驻。服务端在内存里维护求助表与房间表,只按房间转发 Yjs 二进制帧、不解析代码内容。房间以学生为键 —— 学生是房间归属者,这与「学生的代码是内容源」是同一件事。CRDT 与光标仍用 yjs + y-codemirror.next,只是把 y-webrtc 这层传输换掉。
Tech Stack: 后端 Bun + Hono + Drizzle(ws.data.kind 区分通道,Bun 原生 pub/sub);前端 Vue 3 + Pinia + Naive UI + CodeMirror 6 + yjs / y-codemirror.next。
Spec: docs/specs/2026-08-28-collab-help-request-design.md
Global Constraints
- 不写测试。 项目约定(
CLAUDE.md):验证靠实跑。每个任务的收尾是本文写明的手动验证步骤,不是测试用例。 - 不新增数据库表、不写 drizzle 迁移。 求助请求只在内存。
- 单二进制约束:
apps/api用bun build --compile打包,运行时不能依赖node_modules里的文件路径解析(require.resolve/__dirname/Bun.resolveSync)。新增代码只用普通 import。 - 前端要兼容 Chrome < 94(机房电脑)。不要引入新的语法/API 依赖,不动 vite 的构建 target。
- 服务端角色常量:
TEACHER_ROLES = ["Teacher Admin", "Super Admin"](apps/api/src/routes/helpers.ts:74)。服务端判角色一律读库里的adminType,不认前端的演示模式(demoMode是纯 UI 概念)。 - 老师端永远不插入初始内容 —— 见 Task 7,这是硬规则不是默认值。
- 直接在
main上提交,不开特性分支。 - 提交信息用中文,与仓库现有风格一致。
文件结构
后端新增
| 文件 | 职责 |
|---|---|
apps/api/src/collab/state.ts |
内存状态:求助表、房间表、在线老师集合。纯数据结构与查询,不碰 socket |
apps/api/src/collab/handler.ts |
collab 通道的消息处理、鉴权、广播、二进制转发 |
后端修改
| 文件 | 改动 |
|---|---|
apps/api/src/websocket.ts |
SubmissionSocketData 加 collab 所需字段;kind 加 "collab";限流分档;open/close/message 分派到 collab handler |
apps/api/src/index.ts |
upgrade 分支加 /ws/collab |
前端新增
| 文件 | 职责 |
|---|---|
apps/web/src/shared/store/collab.ts |
pinia store:WS 连接、求助列表(老师)、自身求助状态(学生)、当前房间。全局单例 |
apps/web/src/shared/composables/collabDoc.ts |
Y.Doc + yCollab 绑定,把 Yjs 帧接到 collab store 的 WS 上 |
apps/web/src/shared/components/HelpRequestList.vue |
顶栏红点 + 下拉列表,含同题聚合 |
apps/web/src/shared/components/CollabModal.vue |
老师端协作模态框 |
前端修改
| 文件 | 改动 |
|---|---|
apps/web/src/shared/composables/websocket.ts |
BaseWebSocket 支持二进制收发 |
apps/web/src/App.vue |
挂载全局 collab 连接 |
apps/web/src/shared/components/Header.vue |
嵌入 HelpRequestList |
apps/web/src/oj/problem/components/Form.vue |
「开启同步」→「求助 / 取消求助」 |
apps/web/src/oj/problem/components/ProblemEditor.vue |
去掉 syncStatus provide/inject,改读 store |
apps/web/src/shared/components/SyncCodeEditor.vue |
改用 collabDoc |
apps/web/src/oj/problem/components/ContestEditor.vue |
删掉空的 provideSyncStatus() |
前端删除:shared/composables/sync.ts、oj/composables/syncStatus.ts
两点说明:
- spec 里这个 composable 叫
shared/composables/collab.ts,本计划改名为collabDoc.ts—— 与shared/store/collab.ts同名会让 import 路径极易看混。功能不变。 - 前端配了
unplugin-auto-import(vite.config.ts:101),vue / vue-router / pinia 的 API 全部自动导入。新文件里不要写import { ref, computed, watch } from "vue"或import { defineStore } from "pinia",现有的 store 与组件都没写。
Task 1: BaseWebSocket 支持二进制帧
现有客户端 onmessage 无条件 JSON.parse(event.data)(websocket.ts:158),send() 无条件
JSON.stringify(websocket.ts:325)—— 收到 Yjs 二进制帧会直接落进 catch 打一条「解析消息失败」。
先给基类开口子,后面的 collab 客户端才能复用它的重连、心跳、force_logout 处理。
Files:
- Modify:
apps/web/src/shared/composables/websocket.ts
Interfaces:
-
Consumes: 无
-
Produces:
BaseWebSocket新增protected onBinary(data: ArrayBuffer): void(默认空实现,子类覆盖)与sendRaw(data: ArrayBuffer | Uint8Array): boolean -
Step 1: 连接时声明二进制格式
在 connect() 里 new WebSocket(this.url) 之后、绑定事件之前加一行。找到创建 socket 的那句,
紧跟着加:
ws.binaryType = "arraybuffer"
不加的话浏览器默认给 Blob,读取要走异步 .arrayBuffer(),Yjs 的消息顺序会乱。
- Step 2: onmessage 里把二进制岔开
把 websocket.ts:155 的 ws.onmessage 改成先判类型。原来的整段 JSON 逻辑保持不动,只在最前面插入:
ws.onmessage = (event) => {
if (ws !== this.ws) return
// Yjs 这类二进制帧不是 JSON,交给子类。基类的 pong / force_logout 都是文本帧,
// 不会走到这条路径上
if (typeof event.data !== "string") {
this.onBinary(event.data as ArrayBuffer)
return
}
try {
const data = JSON.parse(event.data) as T
// ...以下原样保留...
- Step 3: 加 onBinary 钩子与 sendRaw
在 send(data: any)(websocket.ts:325)旁边加两个成员:
/**
* 二进制帧钩子。基类不认二进制,默认丢弃;collab 这类通道在子类里覆盖。
* 和 onMessage 对称,不要在这里做 JSON 解析。
*/
protected onBinary(_data: ArrayBuffer) {}
/**
* 不做 JSON 序列化的发送。Yjs 的 update / awareness 本身就是 Uint8Array,
* 走 send() 会被 JSON.stringify 成一个 {"0":12,"1":3,...} 的对象。
*/
sendRaw(data: ArrayBuffer | Uint8Array) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data)
return true
}
return false
}
- Step 4: 验证没有回归
bun run dev
浏览器打开 http://localhost:5173,登录后:
- 打开 devtools Network → WS,确认
/ws/config与/ws/submissions照常连上、心跳 pong 正常 - 提交一份代码,确认判题状态照常实时刷新(走
/ws/submissions) - Console 里不应出现
[WebSocket] 解析消息失败
- Step 5: 提交
git add apps/web/src/shared/composables/websocket.ts
git commit -m "feat(web): BaseWebSocket 支持二进制帧收发
onmessage 原来无条件 JSON.parse,收到 Yjs 二进制帧会落进 catch。
加 onBinary 钩子与 sendRaw,让 collab 通道能复用基类的重连与心跳。"
Task 2: 后端 collab 通道 —— 求助表与控制面
只做控制面:学生发起/撤销求助,老师收到列表。房间与二进制转发在 Task 3。
Files:
- Create:
apps/api/src/collab/state.ts - Create:
apps/api/src/collab/handler.ts - Modify:
apps/api/src/websocket.ts - Modify:
apps/api/src/index.ts
Interfaces:
-
Consumes:
SubmissionSocketData(websocket.ts:52)、TEACHER_ROLES(routes/helpers.ts:74)、touchSession(auth/session.ts:161) -
Produces:
state.ts:HelpRequest、CollabSocket类型;addRequest、removeRequest、getRequest、listRequests、queueAheadOf、addTeacher、removeTeacher、hasTeacherOnline、teacherSocketshandler.ts:handleCollabOpen(ws)、handleCollabClose(ws)、handleCollabMessage(ws, raw)、handleCollabBinary(ws, data)(Task 3 才填实现)、broadcastRequests()
-
Step 1: 写 state.ts
Create apps/api/src/collab/state.ts:
/**
* 课堂求助的内存状态。
*
* 不落库是有意的:求助是课堂上的即时行为,学生关掉页面这条请求就该消失。
* 服务端只有一个 serve 进程(main.ts 单二进制 + 子命令,compose 里 oj-api 一个容器),
* 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。
*/
export type CollabSocket = Bun.ServerWebSocket<import("../websocket").SubmissionSocketData>
export interface HelpRequest {
studentId: number
studentName: string
className: string | null
/** 题目的展示号(problem._id 列,前端一路用的都是它),不是自增主键 */
problemId: string
problemTitle: string
createdAt: number
status: "pending" | "active"
teacherId?: number
teacherName?: string
socket: CollabSocket
}
/** 求助表,以学生为键 —— 一个学生同时只有一个求助 */
const requests = new Map<number, HelpRequest>()
/** 在线老师的连接。用于推列表,也用于判断 no_teacher */
const teachers = new Set<CollabSocket>()
export function addRequest(request: HelpRequest) {
requests.set(request.studentId, request)
}
export function getRequest(studentId: number) {
return requests.get(studentId)
}
export function removeRequest(studentId: number) {
return requests.delete(studentId)
}
export function hasRequest(studentId: number) {
return requests.has(studentId)
}
/** 按发起时间正序。老师端按等待时长排序展示,不强制先来先到 */
export function listRequests() {
return Array.from(requests.values()).sort((a, b) => a.createdAt - b.createdAt)
}
/** 比自己早创建、且仍在排队的请求数 */
export function queueAheadOf(studentId: number) {
const self = requests.get(studentId)
if (!self) return 0
let ahead = 0
for (const request of requests.values()) {
if (request.status === "pending" && request.createdAt < self.createdAt) ahead += 1
}
return ahead
}
export function addTeacher(ws: CollabSocket) {
teachers.add(ws)
}
export function removeTeacher(ws: CollabSocket) {
teachers.delete(ws)
}
export function hasTeacherOnline() {
return teachers.size > 0
}
export function teacherSockets() {
return teachers
}
/** 仅供进程退出或测试用,正常路径不该调 */
export function resetCollabState() {
requests.clear()
teachers.clear()
}
- Step 2: 扩展 SubmissionSocketData
Modify apps/api/src/websocket.ts:52,给接口加三个字段(kind 加一个取值):
export interface SubmissionSocketData {
userId: number
/** 同一个 Bun.serve 只能挂一个 websocket handler,用它区分通道 */
kind: "submissions" | "config" | "collab"
/** 握手时那张会话的 token,留着定期确认它还没被登出 / 过期,见 sweepSessions */
token: string
/** 令牌桶,open 时初始化,见 allowMessage */
rate?: { tokens: number; updatedAt: number }
/** 以下两项只有 kind === "collab" 时才填,握手时从会话里读 */
username?: string
adminType?: string
/** 当前所在协作房间的房主(学生)id,见 collab/handler.ts */
roomOwnerId?: number
}
- Step 3: 限流分档
collab 的二进制帧是每敲一个字一条,现有的 20 突发 / 每秒 2 个(websocket.ts:69-70)几秒就会
ws.close(1008) 把人踢掉。在 RATE_REFILL_PER_SECOND 下面加一组常量:
/**
* collab 通道的二进制帧(Yjs update / awareness)单独一档。
*
* 它不查库、不解析,纯内存按房间转发,成本和文本控制帧完全不是一个量级;
* 而连续快速输入大约 5-10 帧/秒,用严格档几秒钟就会把正在协作的人踢下线。
*/
const COLLAB_BINARY_BURST = 200
const COLLAB_BINARY_REFILL_PER_SECOND = 100
把 allowMessage 改成接受档位参数:
function allowMessage(
ws: Bun.ServerWebSocket<SubmissionSocketData>,
burst = RATE_BURST,
refillPerSecond = RATE_REFILL_PER_SECOND,
) {
const now = Date.now()
const rate = (ws.data.rate ??= { tokens: burst, updatedAt: now })
const refill = ((now - rate.updatedAt) / 1000) * refillPerSecond
rate.tokens = Math.min(burst, rate.tokens + refill)
rate.updatedAt = now
if (rate.tokens < 1) return false
rate.tokens -= 1
return true
}
注意:令牌桶是每条连接一个,而 collab 连接上文本帧很稀疏(求助、接单各一条),
所以两档共用同一个桶不会互相饿死 —— 二进制帧把桶按宽松档填,文本帧按严格档取,
实际效果是 collab 连接整体走宽松档。这是可以接受的:这条连接上的文本帧同样不查库
(accept 会查,但它一次会话只发一次)。
- Step 4: 在 handler 里分派 collab
Modify apps/api/src/websocket.ts 的 submissionWebSocketHandler()。在 open、message、
close 三处加 collab 分支(放在各自函数最前面,collab 不订阅 submission/config 的 topic):
open(ws) {
liveSockets.add(ws)
ws.data.rate = { tokens: RATE_BURST, updatedAt: Date.now() }
if (ws.data.kind === "collab") {
handleCollabOpen(ws)
return
}
if (ws.data.kind === "config") {
ws.subscribe(configTopic)
return
}
ws.subscribe(userSubmissionTopic(ws.data.userId))
ws.subscribe(userEventTopic(ws.data.userId))
},
message(ws, message) {
if (ws.data.kind === "collab") {
if (typeof message !== "string") {
if (!allowMessage(ws, COLLAB_BINARY_BURST, COLLAB_BINARY_REFILL_PER_SECOND)) {
ws.close(1008, "Too many messages")
return
}
handleCollabBinary(ws, message)
return
}
if (!allowMessage(ws)) {
ws.close(1008, "Too many messages")
return
}
handleCollabMessage(ws, message).catch((error) => {
console.error("Failed to handle collab message", error)
ws.send(JSON.stringify({ type: "error", message: "Internal error" }))
})
return
}
// ...以下原有逻辑原样保留...
},
close(ws) {
liveSockets.delete(ws)
if (ws.data.kind === "collab") {
handleCollabClose(ws)
return
}
// ...以下原有逻辑原样保留...
},
文件顶部加 import:
import {
handleCollabBinary,
handleCollabClose,
handleCollabMessage,
handleCollabOpen,
} from "./collab/handler"
- Step 5: 写 handler.ts 的控制面
Create apps/api/src/collab/handler.ts:
import { and, eq, isNull } from "drizzle-orm"
import { touchSession } from "../auth/session"
import { db, schema } from "../db"
import {
addRequest,
addTeacher,
getRequest,
hasTeacherOnline,
listRequests,
queueAheadOf,
removeRequest,
removeTeacher,
teacherSockets,
type CollabSocket,
type HelpRequest,
} from "./state"
const TEACHER_ROLES = ["Teacher Admin", "Super Admin"]
function isTeacher(ws: CollabSocket) {
return TEACHER_ROLES.includes(ws.data.adminType ?? "")
}
/** 推给老师的列表条目。不含 socket,也不含任何代码内容 */
function serializeRequest(request: HelpRequest) {
return {
studentId: request.studentId,
studentName: request.studentName,
className: request.className,
problemId: request.problemId,
problemTitle: request.problemTitle,
createdAt: request.createdAt,
status: request.status,
teacherName: request.teacherName ?? null,
}
}
export function broadcastRequests() {
const payload = JSON.stringify({
type: "requests",
list: listRequests().map(serializeRequest),
})
for (const ws of teacherSockets()) ws.send(payload)
}
function sendHelpStatus(
ws: CollabSocket,
status: "pending" | "active" | "cancelled" | "no_teacher",
extra: Record<string, unknown> = {},
) {
ws.send(JSON.stringify({ type: "help_status", status, ...extra }))
}
export function handleCollabOpen(ws: CollabSocket) {
if (isTeacher(ws)) {
addTeacher(ws)
// 新上线的老师要立刻看到当前队列,不能等下一次变更
ws.send(
JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }),
)
}
}
export function handleCollabClose(ws: CollabSocket) {
if (isTeacher(ws)) removeTeacher(ws)
// 房间与请求的清理在 Task 3 补
}
export async function handleCollabMessage(ws: CollabSocket, raw: string) {
let message: { type?: unknown; problemId?: unknown; studentId?: unknown }
try {
message = JSON.parse(raw) as typeof message
} catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }))
return
}
// 心跳不查库,和 /ws/submissions 的处理一致
if (message.type === "ping") {
ws.send(JSON.stringify({ type: "pong", timestamp: (message as any).timestamp }))
return
}
// 握手时校验过一次不算数 —— 这条连接能挂几个小时
if (!(await touchSession(ws.data.token))) {
ws.close(1008, "Session expired")
return
}
switch (message.type) {
case "help_request":
await handleHelpRequest(ws, message.problemId)
return
case "help_cancel":
handleHelpCancel(ws)
return
default:
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }))
}
}
async function handleHelpRequest(ws: CollabSocket, problemId: unknown) {
if (typeof problemId !== "string" || !problemId) {
ws.send(JSON.stringify({ type: "error", message: "Invalid problemId" }))
return
}
if (isTeacher(ws)) {
ws.send(JSON.stringify({ type: "error", message: "教师不能发起求助" }))
return
}
if (!hasTeacherOnline()) {
sendHelpStatus(ws, "no_teacher")
return
}
// 只认非比赛题:contest_id 为空的那条。比赛题不提供求助
const [problem] = await db
.select({ title: schema.problem.title })
.from(schema.problem)
.where(
and(
eq(schema.problem.displayId, problemId),
isNull(schema.problem.contestId),
),
)
.limit(1)
if (!problem) {
ws.send(JSON.stringify({ type: "error", message: "题目不存在或不支持求助" }))
return
}
const existing = getRequest(ws.data.userId)
// 已经在协作中就不重复登记,否则会把正在进行的房间挤掉
if (existing?.status === "active") return
addRequest({
studentId: ws.data.userId,
studentName: ws.data.username ?? "",
className: null,
problemId,
problemTitle: problem.title,
createdAt: Date.now(),
status: "pending",
socket: ws,
})
sendHelpStatus(ws, "pending", { queueAhead: queueAheadOf(ws.data.userId) })
broadcastRequests()
}
function handleHelpCancel(ws: CollabSocket) {
const request = getRequest(ws.data.userId)
if (!request || request.status === "active") return
removeRequest(ws.data.userId)
broadcastRequests()
}
className 先填 null:它在 user 表上(schema.ts:663),握手时一并读出来更省事,
下一步就补。
- Step 6: 握手时带上 username / adminType / className
getRequestSessionUser(auth/session.ts:142)返回的 user 已含 adminType(session.ts:106)。
Modify apps/api/src/index.ts:103 那段 upgrade 分支:
if (
url.pathname === "/ws/submissions" ||
url.pathname === "/ws/config" ||
url.pathname === "/ws/collab"
) {
if (!isAllowedWebSocketOrigin(request.headers.get("origin"), url)) {
return new Response("Forbidden", { status: 403 })
}
const user = await getRequestSessionUser(request)
if (!user) return new Response("Unauthorized", { status: 401 })
const kind =
url.pathname === "/ws/config"
? "config"
: url.pathname === "/ws/collab"
? "collab"
: "submissions"
if (
bunServer.upgrade(request, {
data: {
userId: user.id,
kind,
token: readRequestSessionToken(request),
username: user.username,
adminType: user.adminType,
},
})
) {
return undefined
}
return new Response("WebSocket upgrade failed", { status: 400 })
}
className 不在会话里,在 handleHelpRequest 里和题目一起查出来。把上一步 state 写入
改成:
const [student] = await db
.select({ className: schema.user.className })
.from(schema.user)
.where(eq(schema.user.id, ws.data.userId))
.limit(1)
addRequest({
studentId: ws.data.userId,
studentName: ws.data.username ?? "",
className: student?.className ?? null,
// ...其余不变
})
- Step 7: 验证控制面
cd /home/xuyue/Projects/OJ/OJ2 && bun run dev
开两个浏览器 profile。A 用教师账号登录,B 用学生账号登录(.env 里的
OJ2_DEV_USERNAME=student)。两边 devtools Console 各跑:
const ws = new WebSocket(`ws://localhost:5173/ws/collab`)
ws.onmessage = (e) => console.log("recv", e.data)
然后:
- **A(教师)**连上后应立刻收到一条
{"type":"requests","list":[]} - **B(学生)**发
ws.send(JSON.stringify({type:"help_request",problemId:"1"}))(problemId换成一道真实存在的非比赛题的展示号)- B 收到
{"type":"help_status","status":"pending","queueAhead":0} - A 收到
requests,list 里有一条,含studentName、problemTitle、className
- B 收到
- B 发
{"type":"help_cancel"}→ A 收到空 list - B 发
{"type":"help_request",problemId:"不存在的号"}→ 收到题目不存在或不支持求助 - **A(教师)**发
help_request→ 收到教师不能发起求助 - 关掉 A 的连接,B 再发
help_request→ 收到{"status":"no_teacher"} - B 连着不动 60 秒以上,确认没有被限流踢掉(
allowMessage改动没写坏严格档)
- Step 8: 提交
git add apps/api/src/collab apps/api/src/websocket.ts apps/api/src/index.ts
git commit -m "feat(api): 新增 /ws/collab 通道与课堂求助控制面
学生发起/撤销求助,在线教师收到全量列表。求助只在内存,不落库。
二进制帧的限流单独一档,避免协作输入把连接踢掉。"
Task 3: 后端房间与二进制转发
Files:
- Modify:
apps/api/src/collab/state.ts - Modify:
apps/api/src/collab/handler.ts
Interfaces:
-
Consumes: Task 2 的全部导出
-
Produces:
state.ts:Room类型;openRoom、getRoom、closeRoom、roomOfhandler.ts:handleCollabBinary的实现;accept/reject/leave三个消息的处理
-
Step 1: state.ts 加房间表
在 apps/api/src/collab/state.ts 末尾(resetCollabState 之前)加:
export interface Room {
/** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */
studentId: number
teacherId: number
studentSocket: CollabSocket
teacherSocket: CollabSocket
problemId: string
}
const rooms = new Map<number, Room>()
export function openRoom(room: Room) {
rooms.set(room.studentId, room)
}
export function getRoom(studentId: number) {
return rooms.get(studentId)
}
export function closeRoom(studentId: number) {
return rooms.delete(studentId)
}
/** 这条连接当前所在的房间。ws.data.roomOwnerId 是房主(学生)的 id */
export function roomOf(ws: CollabSocket) {
const ownerId = ws.data.roomOwnerId
return ownerId === undefined ? undefined : rooms.get(ownerId)
}
把 resetCollabState 补上 rooms.clear()。
- Step 2: handler.ts 加 accept / reject / leave
在 handleCollabMessage 的 switch 里补三个分支:
case "accept":
await handleAccept(ws, message.studentId)
return
case "reject":
handleReject(ws, message.studentId)
return
case "leave":
handleLeave(ws)
return
然后加这四个函数:
async function handleAccept(ws: CollabSocket, studentId: unknown) {
if (!isTeacher(ws)) {
ws.send(JSON.stringify({ type: "error", message: "无权限" }))
return
}
if (typeof studentId !== "number") {
ws.send(JSON.stringify({ type: "error", message: "Invalid studentId" }))
return
}
// 握手时的 adminType 是那一刻的快照,接单前按库里的真实身份复核一次。
// 注意读的是库,不是前端传的任何东西 —— 前端的演示模式在这里没有意义
const [teacher] = await db
.select({ adminType: schema.user.adminType })
.from(schema.user)
.where(and(eq(schema.user.id, ws.data.userId), eq(schema.user.isDisabled, false)))
.limit(1)
if (!teacher || !TEACHER_ROLES.includes(teacher.adminType)) {
ws.close(1008, "Permission revoked")
return
}
// 老师同时只能在一个房间
if (roomOf(ws)) {
ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" }))
return
}
const request = getRequest(studentId)
if (!request || request.status === "active") {
// 被别人接走了或者学生已经撤销 —— 回一份最新列表让老师端自己纠正
ws.send(
JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }),
)
return
}
request.status = "active"
request.teacherId = ws.data.userId
request.teacherName = ws.data.username ?? ""
ws.data.roomOwnerId = studentId
request.socket.data.roomOwnerId = studentId
openRoom({
studentId,
teacherId: ws.data.userId,
studentSocket: request.socket,
teacherSocket: ws,
problemId: request.problemId,
})
const openFrame = (peerName: string, peerRole: "student" | "teacher") =>
JSON.stringify({
type: "room_open",
peer: { name: peerName, role: peerRole },
problemId: request.problemId,
})
request.socket.send(openFrame(request.teacherName, "teacher"))
ws.send(openFrame(request.studentName, "student"))
sendHelpStatus(request.socket, "active", { teacherName: request.teacherName })
broadcastRequests()
}
function handleReject(ws: CollabSocket, studentId: unknown) {
if (!isTeacher(ws) || typeof studentId !== "number") return
const request = getRequest(studentId)
// 已经在协作中的不能靠 reject 掐掉,那是 leave 的事
if (!request || request.status === "active") return
removeRequest(studentId)
sendHelpStatus(request.socket, "cancelled")
broadcastRequests()
}
/** 主动退出房间。老师点关闭、学生点结束都走这里 */
function handleLeave(ws: CollabSocket) {
const room = roomOf(ws)
if (!room) return
teardownRoom(room, "done")
}
/**
* 拆房间。reason 决定两端看到什么:
* done —— 有人主动结束,双方都收到,请求一并清除
* peer_offline —— 有人断线,见 handleCollabClose
*/
function teardownRoom(room: Room, reason: "done" | "peer_offline") {
closeRoom(room.studentId)
room.studentSocket.data.roomOwnerId = undefined
room.teacherSocket.data.roomOwnerId = undefined
const frame = JSON.stringify({ type: "room_closed", reason })
room.studentSocket.send(frame)
room.teacherSocket.send(frame)
if (reason === "done") removeRequest(room.studentId)
broadcastRequests()
}
state.ts 的新导出要加进顶部 import:openRoom、closeRoom、roomOf、type Room。
(getRoom 本任务用不到,别顺手加进去。)
- Step 3: 断线处理
把 Task 2 里那个占位的 handleCollabClose 换成完整实现:
export function handleCollabClose(ws: CollabSocket) {
if (isTeacher(ws)) removeTeacher(ws)
const room = roomOf(ws)
if (room) {
closeRoom(room.studentId)
room.studentSocket.data.roomOwnerId = undefined
room.teacherSocket.data.roomOwnerId = undefined
const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
peer.send(JSON.stringify({ type: "room_closed", reason: "peer_offline" }))
if (ws === room.teacherSocket) {
// 老师掉线:请求退回排队,学生不必重新点 —— 可能只是网络抖了一下
const request = getRequest(room.studentId)
if (request) {
request.status = "pending"
request.teacherId = undefined
request.teacherName = undefined
sendHelpStatus(request.socket, "pending", {
queueAhead: queueAheadOf(room.studentId),
})
}
} else {
// 学生掉线:请求随人走
removeRequest(room.studentId)
}
} else if (!isTeacher(ws)) {
// 还在排队时关掉页面,请求也该消失
removeRequest(ws.data.userId)
}
broadcastRequests()
}
- Step 4: 二进制转发
/**
* Yjs 的 update / awareness 帧。服务端不解析、不留存,只转发给房间里的另一个人。
*
* 「服务端不知道代码内容」是有意的:这个通道要做的事只有认证和分房间,
* 权限由 accept 时的库查询决定,与帧里装的是什么无关。
*/
export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array) {
const room = roomOf(ws)
if (!room) return
const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
peer.send(data)
}
- Step 5: 验证房间与转发
bun run dev,仍用两个 profile 的 Console(教师 A / 学生 B):
const ws = new WebSocket(`ws://localhost:5173/ws/collab`)
ws.binaryType = "arraybuffer"
ws.onmessage = (e) =>
console.log("recv", typeof e.data === "string" ? e.data : new Uint8Array(e.data))
- B 发
help_request→ A 收到列表 - A 发
{"type":"accept","studentId":<B的用户id>}- 双方各收到一条
room_open,peer.name分别是对方的用户名 - B 另收到
{"type":"help_status","status":"active","teacherName":"..."} - A 再收到
requests,那条的status变成active
- 双方各收到一条
- A 发二进制:
ws.send(new Uint8Array([1,2,3]))→ B 收到Uint8Array(3) [1,2,3];反向同样 - A 再发一次
accept(换个学生)→ 收到请先退出当前协作 - A 发
{"type":"leave"}→ 双方收到{"reason":"done"},A 的列表变空 - 重新 accept 后关掉 A 的标签页 → B 收到
{"reason":"peer_offline"}, 再开一个教师连接,列表里那条应回到pending - 重新 accept 后关掉 B 的标签页 → A 收到
peer_offline,且列表里那条消失 - 未在房间里时发二进制 → 无任何转发、不报错
- 用学生账号发
{"type":"accept","studentId":1}→ 收到无权限 - 连续快速发 100 条二进制帧,确认连接没被 1008 踢掉(限流宽松档生效)
- Step 6: 提交
git add apps/api/src/collab
git commit -m "feat(api): collab 房间管理与 Yjs 帧转发
accept 时按库里的 adminType 复核身份,房间以学生为键。
服务端只按房间转发二进制帧,不解析内容。
老师掉线请求退回排队,学生掉线请求随人清除。"
Task 4: 前端 collab store 与 WS 客户端
Files:
- Modify:
apps/web/src/shared/composables/websocket.ts(加CollabWebSocket类) - Create:
apps/web/src/shared/store/collab.ts - Modify:
apps/web/src/App.vue
Interfaces:
-
Consumes: Task 1 的
onBinary/sendRaw;Task 2、3 的服务端协议 -
Produces:
useCollabStore(),导出connect()、disconnect()、requestHelp(problemId: string)、cancelHelp()、accept(studentId: number)、reject(studentId: number)、leave()、sendBinary(data: Uint8Array)、setBinaryHandler(fn: ((data: ArrayBuffer) => void) | null), 以及只读状态requests、helpStatus、queueAhead、teacherName、room -
Step 1: 加 CollabWebSocket 类
在 apps/web/src/shared/composables/websocket.ts 末尾(useConfigWebSocket 之后)加:
export interface CollabRequestItem {
studentId: number
studentName: string
className: string | null
problemId: string
problemTitle: string
createdAt: number
status: "pending" | "active"
teacherName: string | null
}
export interface CollabMessage extends WebSocketMessage {
type:
| "requests"
| "help_status"
| "room_open"
| "room_closed"
| "error"
}
/**
* 课堂求助 / 协作通道。和另外两条的区别是它**双向**且**收发二进制** ——
* 控制面是 JSON,Yjs 的 update / awareness 走 sendRaw 与 onBinary。
*/
export class CollabWebSocket extends BaseWebSocket<CollabMessage> {
private binaryHandler: ((data: ArrayBuffer) => void) | null = null
constructor() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
super({ url: `${protocol}//${window.location.host}/ws/collab` })
}
setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) {
this.binaryHandler = handler
}
protected override onBinary(data: ArrayBuffer) {
this.binaryHandler?.(data)
}
}
- Step 2: 写 store
Create apps/web/src/shared/store/collab.ts:
import {
CollabWebSocket,
type CollabMessage,
type CollabRequestItem,
} from "shared/composables/websocket"
import { useUserStore } from "shared/store/user"
export type HelpStatus = "idle" | "pending" | "active"
export interface RoomInfo {
peerName: string
peerRole: "student" | "teacher"
problemId: string
}
/**
* 课堂求助的全局状态。
*
* 连接是**全局常驻**的,不跟着题目页起落 —— 老师可能正在后台改题时收到求助,
* 学生也需要在等待期间一直挂着。所以这里不用 onUnmounted,由 App.vue 按登录态开关。
*/
export const useCollabStore = defineStore("collab", () => {
const userStore = useUserStore()
const ws = new CollabWebSocket()
/** 老师端:待处理列表 */
const requests = ref<CollabRequestItem[]>([])
/** 学生端:自己的求助状态 */
const helpStatus = ref<HelpStatus>("idle")
const queueAhead = ref(0)
const teacherName = ref("")
/** 双方:当前房间。null 表示不在协作中 */
const room = ref<RoomInfo | null>(null)
/** 一次性提示,由组件消费后清空 */
const notice = ref("")
const pendingCount = computed(
() => requests.value.filter((it) => it.status === "pending").length,
)
/** 按题目聚合,同题多人时老师能一眼看出该停下来全班讲 */
const groupedRequests = computed(() => {
const groups = new Map<string, { problemId: string; problemTitle: string; items: CollabRequestItem[] }>()
for (const item of requests.value) {
const group = groups.get(item.problemId)
if (group) group.items.push(item)
else
groups.set(item.problemId, {
problemId: item.problemId,
problemTitle: item.problemTitle,
items: [item],
})
}
// 人多的题排前面;人数相同按最久等待排
return Array.from(groups.values()).sort(
(a, b) =>
b.items.length - a.items.length ||
a.items[0].createdAt - b.items[0].createdAt,
)
})
const handleMessage = (data: CollabMessage) => {
switch (data.type) {
case "requests":
requests.value = (data.list ?? []) as CollabRequestItem[]
return
case "help_status":
if (data.status === "pending") {
helpStatus.value = "pending"
queueAhead.value = Number(data.queueAhead ?? 0)
} else if (data.status === "active") {
helpStatus.value = "active"
teacherName.value = String(data.teacherName ?? "")
} else if (data.status === "cancelled") {
helpStatus.value = "idle"
notice.value = "老师已取消你的求助"
} else if (data.status === "no_teacher") {
helpStatus.value = "idle"
notice.value = "当前没有老师在线"
}
return
case "room_open":
room.value = {
peerName: String(data.peer?.name ?? ""),
peerRole: data.peer?.role === "teacher" ? "teacher" : "student",
problemId: String(data.problemId ?? ""),
}
return
case "room_closed":
room.value = null
// 老师掉线时服务端会另发一条 help_status:pending,这里不抢着改学生状态
if (data.reason === "done") helpStatus.value = "idle"
notice.value =
data.reason === "peer_offline" ? "对方已断开连接" : "协作已结束"
return
case "error":
notice.value = String(data.message ?? "")
return
}
}
ws.addHandler(handleMessage)
function connect() {
ws.connect()
}
function disconnect() {
ws.disconnect()
requests.value = []
helpStatus.value = "idle"
room.value = null
}
function requestHelp(problemId: string) {
ws.send({ type: "help_request", problemId })
}
function cancelHelp() {
ws.send({ type: "help_cancel" })
helpStatus.value = "idle"
}
function accept(studentId: number) {
ws.send({ type: "accept", studentId })
}
function reject(studentId: number) {
ws.send({ type: "reject", studentId })
}
function leave() {
ws.send({ type: "leave" })
}
function sendBinary(data: Uint8Array) {
ws.sendRaw(data)
}
function setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) {
ws.setBinaryHandler(handler)
}
function consumeNotice() {
const value = notice.value
notice.value = ""
return value
}
return {
requests,
pendingCount,
groupedRequests,
helpStatus,
queueAhead,
teacherName,
room,
notice,
isTeacher: computed(() => userStore.isTeacherOrAbove),
connect,
disconnect,
requestHelp,
cancelHelp,
accept,
reject,
leave,
sendBinary,
setBinaryHandler,
consumeNotice,
}
})
- Step 3: App.vue 挂全局连接
在 apps/web/src/App.vue 的 useConfigUpdate() / useMaxKB() 附近加:
import { useCollabStore } from "shared/store/collab"
const collabStore = useCollabStore()
// 课堂求助通道。和 /ws/config 一样是全局常驻的:老师可能正在后台改题时
// 收到求助,学生也要在排队期间一直挂着,所以不放在题目页里起落
watch(
() => userStore.isAuthed,
(isAuthed) => {
if (isAuthed) collabStore.connect()
else collabStore.disconnect()
},
{ immediate: true },
)
- Step 4: 验证
bun run dev,两个 profile 登录后:
- devtools Network → WS 里能看到
/ws/collab连上,且任何页面(首页、后台、题目页)都在 - 教师端 Console:
useCollabStore不好直接取,改在 Vue devtools 里看 pinia 的collabstore ——requests初始为空数组 - 学生端 Console 手动发一条求助(借用上一个任务的裸 WS 方式即可),教师端 store 的
requests应实时出现一条,pendingCount变 1 - 学生登出 → 连接断开;重新登录 → 自动重连
- 停掉 api(
Ctrl-C)再起来 → 前端应自动重连(BaseWebSocket默认无限重连)
- Step 5: 提交
git add apps/web/src/shared/composables/websocket.ts apps/web/src/shared/store/collab.ts apps/web/src/App.vue
git commit -m "feat(web): 课堂求助 store 与全局 collab 连接
连接全局常驻,不跟题目页起落 —— 老师在任何页面都要能收到求助。
列表按题目聚合,同题多人时能一眼看出该全班讲。"
Task 5: 学生端求助按钮
Files:
- Modify:
apps/web/src/oj/problem/components/Form.vue - Modify:
apps/web/src/oj/problem/components/ProblemEditor.vue
Interfaces:
-
Consumes: Task 4 的
useCollabStore() -
Produces: 学生能从题目页发起与撤销求助;
Form.vue不再 emittoggleSync,也不再injectSyncStatus -
Step 1: Form.vue 换掉同步按钮
删掉这些 import 与状态:
import { injectSyncStatus } from "oj/composables/syncStatus"
import { SYNC_MESSAGES } from "shared/composables/sync"
// ...
const syncStatus = injectSyncStatus()
const syncEnabled = ref(false)
以及 emit 里的 toggleSync、toggleSync() 函数、defineExpose({ resetSyncStatus })、
Props 里的 isConnected。
换成:
import { useCollabStore } from "shared/store/collab"
const collabStore = useCollabStore()
// 可见条件沿用原来的 showSyncFeature,再加上「不是教师」——
// 教师端的入口在顶栏,不在题目页
const showHelpButton = computed(
() =>
isDesktop.value &&
userStore.isAuthed &&
!userStore.isTeacherOrAbove &&
codeStore.code.language !== "Flowchart" &&
!isContestMode.value,
)
const helpButtonText = computed(() => {
if (collabStore.helpStatus === "active") return "老师正在帮你"
if (collabStore.helpStatus === "pending") return "取消求助"
return "求助"
})
const toggleHelp = () => {
if (collabStore.helpStatus === "pending") collabStore.cancelHelp()
else if (collabStore.helpStatus === "idle")
collabStore.requestHelp(problem.value!._id)
}
// 服务端的一次性提示(没有老师在线、老师取消了求助)
watch(
() => collabStore.notice,
(text) => {
if (text) message.info(collabStore.consumeNotice())
},
)
模板里把原来 <template v-if="showSyncFeature"> 那整段(Form.vue:253-278)换成:
<template v-if="showHelpButton">
<n-button
:size="buttonSize"
:type="collabStore.helpStatus === 'idle' ? 'default' : 'warning'"
:disabled="collabStore.helpStatus === 'active'"
@click="toggleHelp"
>
{{ helpButtonText }}
</n-button>
<n-tag v-if="collabStore.helpStatus === 'pending'" type="info">
已求助{{ collabStore.queueAhead > 0 ? `,前面还有 ${collabStore.queueAhead} 人` : ",等待老师接入" }}
</n-tag>
<n-tag v-else-if="collabStore.helpStatus === 'active'" type="success">
{{ collabStore.teacherName }} 老师正在帮你
</n-tag>
</template>
- Step 2: ProblemEditor.vue 去掉 syncStatus
删掉这些(ProblemEditor.vue:5, 27-29, 75-92, 100-106 的相关部分):
import { provideSyncStatus } from "oj/composables/syncStatus"
const sync = ref(false)
const syncStatus = provideSyncStatus()
const toggleSync = ...
const handleSyncClosed = ...
const handleSyncStatusChange = ...
<Form> 上去掉 ref="formRef"、:is-connected、@toggle-sync(formRef 与
useTemplateRef 声明一并删)。<SyncCodeEditor> 上去掉 :sync、@sync-closed、
@sync-status-change,改成由 store 驱动(Task 7 再接上,本任务先让它编译过):
<SyncCodeEditor
v-else
v-model:value="codeStore.code.value"
:problem="problem!._id"
:language="codeStore.code.language"
:height="editorHeight"
@update:model-value="changeCode"
/>
SyncCodeEditor.vue 里把 sync 从 Props 去掉、把两个 emit 去掉、把
watch(() => sync, ...) 与 handleEditorReady 里的 if (sync) 去掉,
initSync / cleanupSyncResources 与 useCodeSync 的引用全部删掉,
只留一个干净的 CodeMirror 包装。Task 7 再把协作接回来。
- Step 3: 验证
bun run dev,学生账号打开任意一道非比赛题:
- 题目页出现「求助」按钮(原来的「开启同步」不见了)
- 教师账号在另一个 profile 登录并保持任意页面打开
- 学生点「求助」→ 按钮变「取消求助」,旁边出现「已求助,等待老师接入」标签
- 教师端 Vue devtools 里
collabstore 的requests出现一条 - 学生再点一次 → 撤销,标签消失,教师端列表变空
- 教师账号打开题目页 → 不应出现求助按钮
- 比赛题页面、流程图语言、手机宽度下 → 不应出现求助按钮
- 教师全部下线时学生点求助 → 弹出「当前没有老师在线」,按钮保持「求助」
- 确认页面其余功能正常:切语言、提交代码、重置代码、比赛题页面能正常打开
(
ContestEditor.vue里那个空 provide 还在,Task 8 才删)
- Step 4: 提交
git add apps/web/src/oj/problem/components/Form.vue apps/web/src/oj/problem/components/ProblemEditor.vue apps/web/src/shared/components/SyncCodeEditor.vue
git commit -m "feat(web): 题目页的「开启同步」换成「求助」
学生发起求助,状态与排队位置显示在按钮旁。
教师端入口不在题目页,所以对教师隐藏这个按钮。"
Task 6: 教师端顶栏求助列表
Files:
- Create:
apps/web/src/shared/components/HelpRequestList.vue - Modify:
apps/web/src/shared/components/Header.vue
Interfaces:
-
Consumes: Task 4 的
useCollabStore()(groupedRequests、pendingCount、accept、reject) -
Produces: 顶栏一个带数字角标的按钮,点开是分组列表;点某条触发
accept,点 × 触发reject -
Step 1: 写 HelpRequestList.vue
Create apps/web/src/shared/components/HelpRequestList.vue:
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useCollabStore } from "shared/store/collab"
const collabStore = useCollabStore()
// 等待时长要每秒走一格,所以自己转一个 now
const now = ref(Date.now())
let timer: number | null = null
onMounted(() => {
timer = window.setInterval(() => (now.value = Date.now()), 1000)
})
onUnmounted(() => {
if (timer !== null) window.clearInterval(timer)
})
const waited = (createdAt: number) => {
const seconds = Math.max(0, Math.floor((now.value - createdAt) / 1000))
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${String(s).padStart(2, "0")}`
}
const handleAccept = (studentId: number, status: string) => {
// 已被别的老师接走的不能点
if (status === "active") return
collabStore.accept(studentId)
}
</script>
<template>
<n-popover trigger="click" placement="bottom-end" style="padding: 0">
<template #trigger>
<n-badge :value="collabStore.pendingCount" :max="99">
<n-button>
<Icon icon="fluent-emoji:raising-hand" height="20" />
<span style="padding-left: 8px">求助</span>
</n-button>
</n-badge>
</template>
<div style="width: 320px; max-height: 60vh; overflow: auto; padding: 8px">
<n-empty v-if="collabStore.groupedRequests.length === 0" description="暂无求助" />
<div v-for="group in collabStore.groupedRequests" :key="group.problemId">
<!-- 同题多人是个教学信号:该停下来全班讲,而不是挨个救 -->
<n-flex align="center" justify="space-between" style="padding: 6px 4px">
<n-text depth="3" style="font-size: 12px">
{{ group.problemId }} · {{ group.problemTitle }}
</n-text>
<n-tag v-if="group.items.length > 1" size="small" type="warning">
{{ group.items.length }} 人
</n-tag>
</n-flex>
<n-flex
v-for="item in group.items"
:key="item.studentId"
align="center"
justify="space-between"
:style="{
padding: '6px 8px',
borderRadius: '4px',
opacity: item.status === 'active' ? 0.5 : 1,
cursor: item.status === 'active' ? 'default' : 'pointer',
}"
@click="handleAccept(item.studentId, item.status)"
>
<n-flex vertical :size="2">
<n-text>
{{ item.studentName }}
<n-text depth="3" v-if="item.className">({{ item.className }})</n-text>
</n-text>
<n-text depth="3" style="font-size: 12px">
{{
item.status === "active"
? `${item.teacherName} 处理中`
: `等了 ${waited(item.createdAt)}`
}}
</n-text>
</n-flex>
<n-button
v-if="item.status === 'pending'"
quaternary
circle
size="small"
@click.stop="collabStore.reject(item.studentId)"
>
<Icon icon="mdi:close" height="16" />
</n-button>
</n-flex>
<n-divider style="margin: 4px 0" />
</div>
</div>
</n-popover>
</template>
- Step 2: 塞进 Header.vue
在 apps/web/src/shared/components/Header.vue 的 script 里加 import:
import HelpRequestList from "./HelpRequestList.vue"
模板里放在「切换屏幕模式」那个 n-button 之后、<div v-if="userStore.isFinished"> 之前
(Header.vue:329 附近):
<HelpRequestList v-if="isDesktop && userStore.isTeacherOrAbove" />
只在桌面端出现 —— 协作编辑本身也只在桌面端可用。
- Step 3: 验证
bun run dev,教师账号 + 两三个学生账号(可以用同一台机器的多个隐私窗口):
- 教师顶栏出现「求助」按钮,角标为 0
- 学生 B 对第 5 题求助 → 角标变 1,点开能看到 B 的名字、班级、等待时长在走
- 学生 C 也对第 5 题求助 → 角标 2,两人归在同一组下,组标题旁出现「2 人」标签
- 学生 D 对第 9 题求助 → 出现第二个分组;人多的第 5 题排在前面
- 点某条的 × → 该学生收到「老师已取消你的求助」,条目消失,角标减 1
- 点某条的空白处 → 触发 accept(此时还没有协作界面,Task 7 才有); 刷新后该条应显示为灰色的「XX 处理中」
- 学生账号登录后顶栏不应出现这个按钮
- 窗口缩到手机宽度 → 按钮消失
- Step 4: 提交
git add apps/web/src/shared/components/HelpRequestList.vue apps/web/src/shared/components/Header.vue
git commit -m "feat(web): 教师顶栏的求助列表
按题目分组,同题多人时标出人数。按等待时长排序但不强制先来先到 ——
上课时有的问题一句话说清、有的要讲五分钟。"
Task 7: 协作编辑
把 Yjs 接到 collab 通道上。这是内容源硬规则落地的地方。
Files:
- Create:
apps/web/src/shared/composables/collabDoc.ts - Create:
apps/web/src/shared/components/CollabModal.vue - Modify:
apps/web/src/shared/components/SyncCodeEditor.vue - Modify:
apps/web/src/shared/components/Header.vue(挂 CollabModal) - Modify:
apps/web/package.json(显式依赖 y-protocols)
Interfaces:
-
Consumes: Task 4 的
sendBinary/setBinaryHandler/room -
Produces:
useCollabDoc()返回{ start(options), stop(), getInitialExtension() }, 其中options: { editorView: EditorView; seedContent: string | null }——seedContent为字符串时把它作为文档初始内容写入(学生端),为null时什么都不写(教师端) -
Step 1: 显式依赖 y-protocols
y-protocols 现在是靠 y-webrtc 带进来的,Task 8 会把 y-webrtc 删掉。先补成直接依赖:
cd /home/xuyue/Projects/OJ/OJ2/apps/web && bun add y-protocols@1.0.7
lib0 是 y-codemirror.next 的直接依赖,会一直在,不用单独加。
- Step 2: 写 collabDoc.ts
Create apps/web/src/shared/composables/collabDoc.ts:
import { Compartment } from "@codemirror/state"
import type { EditorView } from "@codemirror/view"
import { useCollabStore } from "shared/store/collab"
import { useUserStore } from "shared/store/user"
/** y-websocket 那套消息头,服务端不解析,只有两端认 */
const MESSAGE_SYNC = 0
const MESSAGE_AWARENESS = 1
const TEACHER_COLOR = "#ff6b6b"
const STUDENT_COLOR = "#4dabf7"
interface StartOptions {
editorView: EditorView
/**
* 文档的初始内容。
*
* **学生端传当前编辑器内容,教师端必须传 null。** 这是硬规则:
* 求助是学生发起的,学生的代码是唯一内容源。老师端插入任何初始内容都会
* 与学生的内容合并,结果就是两份代码拼在一起 —— 老实现的竞态就是这么来的。
*/
seedContent: string | null
}
export function useCollabDoc() {
const collabStore = useCollabStore()
const userStore = useUserStore()
const compartment = new Compartment()
let doc: any = null
let awareness: any = null
let view: EditorView | null = null
let detachDocUpdate: (() => void) | null = null
let detachAwarenessUpdate: (() => void) | null = null
async function start({ editorView, seedContent }: StartOptions) {
const [Y, awarenessProtocol, syncProtocol, encoding, decoding, { yCollab }] =
await Promise.all([
import("yjs"),
import("y-protocols/awareness"),
import("y-protocols/sync"),
import("lib0/encoding"),
import("lib0/decoding"),
import("y-codemirror.next"),
])
view = editorView
doc = new Y.Doc()
const ytext = doc.getText("codemirror")
awareness = new awarenessProtocol.Awareness(doc)
// ★ 顺序不能反:先把内容写进 ytext,再挂 yCollab。
// yCollab 挂上去时会用 ytext 覆盖编辑器内容,先挂就会把学生的代码清空。
if (seedContent) ytext.insert(0, seedContent)
const send = (build: (encoder: any) => void) => {
const encoder = encoding.createEncoder()
build(encoder)
collabStore.sendBinary(encoding.toUint8Array(encoder))
}
collabStore.setBinaryHandler((data) => {
const decoder = decoding.createDecoder(new Uint8Array(data))
const messageType = decoding.readVarUint(decoder)
if (messageType === MESSAGE_SYNC) {
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, MESSAGE_SYNC)
syncProtocol.readSyncMessage(decoder, encoder, doc, "remote")
// 只有需要回话时才发(readSyncMessage 可能什么都没写)
if (encoding.length(encoder) > 1) {
collabStore.sendBinary(encoding.toUint8Array(encoder))
}
} else if (messageType === MESSAGE_AWARENESS) {
awarenessProtocol.applyAwarenessUpdate(
awareness,
decoding.readVarUint8Array(decoder),
"remote",
)
}
})
const onDocUpdate = (update: Uint8Array, origin: any) => {
if (origin === "remote") return
send((encoder) => {
encoding.writeVarUint(encoder, MESSAGE_SYNC)
syncProtocol.writeUpdate(encoder, update)
})
}
doc.on("update", onDocUpdate)
detachDocUpdate = () => doc?.off("update", onDocUpdate)
const onAwarenessUpdate = (
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
origin: any,
) => {
if (origin === "remote") return
const changed = added.concat(updated, removed)
send((encoder) => {
encoding.writeVarUint(encoder, MESSAGE_AWARENESS)
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(awareness, changed),
)
})
}
awareness.on("update", onAwarenessUpdate)
detachAwarenessUpdate = () => awareness?.off("update", onAwarenessUpdate)
awareness.setLocalStateField("user", {
name: userStore.user?.username ?? "匿名",
color: userStore.isTeacherOrAbove ? TEACHER_COLOR : STUDENT_COLOR,
})
editorView.dispatch({
effects: compartment.reconfigure(yCollab(ytext, awareness)),
})
// 握手:双方都发 SyncStep1,各自回 Step2,两边收敛。
// 服务端是哑转发,不参与同步,所以这一步必须由两端对称完成
send((encoder) => {
encoding.writeVarUint(encoder, MESSAGE_SYNC)
syncProtocol.writeSyncStep1(encoder, doc)
})
send((encoder) => {
encoding.writeVarUint(encoder, MESSAGE_AWARENESS)
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(awareness, [doc.clientID]),
)
})
}
function stop() {
collabStore.setBinaryHandler(null)
detachDocUpdate?.()
detachAwarenessUpdate?.()
detachDocUpdate = null
detachAwarenessUpdate = null
if (view) {
try {
view.dispatch({ effects: compartment.reconfigure([]) })
} catch (error) {
console.warn("移除协同编辑扩展失败:", error)
}
view = null
}
awareness?.destroy()
doc?.destroy()
awareness = null
doc = null
}
function getInitialExtension() {
return compartment.of([])
}
return { start, stop, getInitialExtension }
}
- Step 3: SyncCodeEditor.vue 接上(学生端)
在 Task 5 精简后的 SyncCodeEditor.vue 上加回协作,由 store 的 room 驱动:
import { useCollabDoc } from "../composables/collabDoc"
import { useCollabStore } from "shared/store/collab"
const collabStore = useCollabStore()
const { start, stop, getInitialExtension } = useCollabDoc()
const editorView = ref<EditorView | null>(null)
const handleEditorReady = (payload: EditorReadyPayload) => {
editorView.value = payload.view as EditorView
}
// 房间开了才建文档。学生点求助时什么都不做 —— 老师没来之前不该动他的编辑器
watch(
() => collabStore.room,
(room) => {
if (room && editorView.value) {
// 学生端:当前编辑器内容就是内容源
start({
editorView: editorView.value as EditorView,
seedContent: editorView.value.state.doc.toString(),
})
} else {
stop()
}
},
)
onUnmounted(stop)
extensions 的计算属性里保留 getInitialExtension()(原来就有,位置不动)。
- Step 4: 写 CollabModal.vue(教师端)
Create apps/web/src/shared/components/CollabModal.vue:
<script setup lang="ts">
import { cpp } from "@codemirror/lang-cpp"
import { python } from "@codemirror/lang-python"
import { sql, SQLite } from "@codemirror/lang-sql"
import { bracketMatching } from "@codemirror/language"
import { closeBrackets } from "@codemirror/autocomplete"
import type { EditorView } from "@codemirror/view"
import { Codemirror } from "vue-codemirror"
import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy"
import { styleTheme } from "shared/extensions/baseTheme"
import { useCollabDoc } from "../composables/collabDoc"
import { useCollabStore } from "shared/store/collab"
const isDark = useDark()
const collabStore = useCollabStore()
const { start, stop, getInitialExtension } = useCollabDoc()
const code = ref("")
const editorView = ref<EditorView | null>(null)
// 教师端只在自己发起接单时开。学生端的协作在 SyncCodeEditor 里
const show = computed({
get: () => collabStore.isTeacher && collabStore.room !== null,
set: (value: boolean) => {
if (!value) collabStore.leave()
},
})
const extensions = computed(() => [
styleTheme,
cpp(),
bracketMatching(),
closeBrackets(),
isDark.value ? oneDark : smoothy,
getInitialExtension(),
])
const handleEditorReady = (payload: { view: EditorView }) => {
editorView.value = payload.view
}
watch(
() => collabStore.room,
async (room) => {
if (room && collabStore.isTeacher) {
await nextTick()
if (!editorView.value) return
// ★ 教师端 seedContent 必须是 null —— 内容全部来自学生端
start({ editorView: editorView.value as EditorView, seedContent: null })
} else {
stop()
}
},
)
onUnmounted(stop)
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:style="{ width: '80vw', maxWidth: '1100px' }"
:title="`正在帮 ${collabStore.room?.peerName ?? ''} · ${collabStore.room?.problemId ?? ''}`"
>
<template #header-extra>
<n-button
text
tag="a"
target="_blank"
:href="`/problem/${collabStore.room?.problemId}`"
>
打开题面
</n-button>
</template>
<Codemirror
v-model="code"
indentWithTab
:extensions="extensions"
:tab-size="4"
style="height: 60vh; font-size: 18px"
@ready="handleEditorReady"
/>
<template #footer>
<n-flex justify="end">
<n-button type="primary" @click="collabStore.leave()">结束协作</n-button>
</n-flex>
</template>
</n-modal>
</template>
模态框里的语言高亮固定用 cpp —— 它对 C / C++ 都合适,而教师端拿不到题目的语言设置。
如果实跑时觉得 Python 题看着别扭,再从 room 里多带一个语言字段过来。
- Step 5: 挂到 Header.vue
Header.vue 的 script 里 import,模板最外层加一行(放在根 n-flex 之后,与它平级):
<CollabModal v-if="userStore.isTeacherOrAbove" />
放在 Header 里是因为它要跟着顶栏的求助列表走,而 Header 在每个页面都有。
- Step 6: 验证协作
bun run dev,教师 profile + 学生 profile:
- 学生在第 5 题写几行代码,点「求助」
- 教师点开列表,点那条 → 弹出模态框,里面出现学生写的那几行代码
- 确认学生那边的编辑器内容没有任何变化(没被清空、没闪、没被覆盖)—— 这是本任务的核心
- 教师在模态框里改代码 → 学生编辑器实时跟着变,且能看到教师的光标(红色)
- 学生在自己编辑器里打字 → 教师模态框实时跟着变,光标蓝色
- 两边同时在不同位置打字 → 不冲突、不丢字
- 教师点「结束协作」→ 模态框关闭,学生编辑器保留改后的代码,求助按钮回到「求助」
- 学生刷新页面 → 代码还在(localStorage 照常写入)
- 再走一遍,这次教师直接关标签页 → 学生收到「对方已断开连接」,且求助自动回到排队状态
- 再走一遍,这次学生关标签页 → 教师模态框关闭,列表里那条消失
- 教师快速连续敲 100 个字符 → 连接不断(限流宽松档),学生端跟得上
- Console 不应出现
[WebSocket] 解析消息失败
- Step 7: 提交
git add apps/web/src/shared/composables/collabDoc.ts apps/web/src/shared/components/CollabModal.vue apps/web/src/shared/components/SyncCodeEditor.vue apps/web/src/shared/components/Header.vue apps/web/package.json bun.lock
git commit -m "feat(web): 协作编辑走 collab 通道
Yjs sync/awareness 协议直接跑在 /ws/collab 上,服务端哑转发。
内容源是学生:学生端先把编辑器内容写进 ytext 再挂 yCollab,
教师端 seedContent 恒为 null —— 这条根治了老实现里谁的代码活下来看运气的问题。"
Task 8: 清理旧实现与文档
新实现跑通后才做这一步 —— 前面每个任务都要能独立回滚。
Files:
- Delete:
apps/web/src/shared/composables/sync.ts - Delete:
apps/web/src/oj/composables/syncStatus.ts - Modify:
apps/web/src/oj/problem/components/ContestEditor.vue - Modify:
apps/web/package.json - Modify:
apps/web/.env、.env.production、.env.staging、.env.test - Modify:
apps/web/src/env.d.ts - Modify:
apps/web/CLAUDE.md
Interfaces:
-
Consumes: Task 1-7 全部完成且验证通过
-
Produces: 无新接口,仅移除
-
Step 1: 确认没有残留引用
cd /home/xuyue/Projects/OJ/OJ2
grep -rn "useCodeSync\|SYNC_MESSAGES\|SYNC_ERROR_CODES\|syncStatus\|y-webrtc\|WebrtcProvider\|PUBLIC_SIGNALING_URL" apps/web/src apps/web/*.env* apps/web/package.json
预期只剩下待删的那几个文件自身、四个 .env、env.d.ts、package.json、CLAUDE.md。
如果还有别的业务文件引用,说明前面某个任务没改干净,先回去补。
- Step 2: 删文件与空 provide
rm apps/web/src/shared/composables/sync.ts apps/web/src/oj/composables/syncStatus.ts
apps/web/src/oj/problem/components/ContestEditor.vue 删掉这两行(原 8、21-23 行):
import { provideSyncStatus } from "oj/composables/syncStatus"
// 提供空的同步状态,避免 Form 组件注入错误
// 在竞赛模式下,同步功能会被 showSyncFeature 自动禁用
provideSyncStatus()
这个空 provide 存在的唯一理由是 injectSyncStatus() 会抛错,随 syncStatus.ts 一起消失。
- Step 3: 卸掉 y-webrtc
cd /home/xuyue/Projects/OJ/OJ2/apps/web && bun remove y-webrtc
确认 yjs、y-codemirror.next、y-protocols 还在 —— 它们仍在用。
- Step 4: 删环境变量
四个 env 文件里删掉 PUBLIC_SIGNALING_URL=... 那一行:
cd /home/xuyue/Projects/OJ/OJ2/apps/web
sed -i '/^PUBLIC_SIGNALING_URL=/d' .env .env.production .env.staging .env.test
apps/web/src/env.d.ts:9 删掉:
readonly PUBLIC_SIGNALING_URL: string
- Step 5: 改 apps/web/CLAUDE.md
两处:
- 环境变量表里删掉这一行:
| `PUBLIC_SIGNALING_URL` | WebRTC signaling server | ### Real-time Features一节(约 108-110 行),把改成- Yjs + y-webrtc for collaborative editing in the flowchart editor原文说协作在流程图编辑器里,这本来就是错的 —— 协作在代码编辑器,流程图被显式排除。- Yjs over the `/ws/collab` channel for classroom help requests and collaborative code editing (students raise a hand, teachers join their editor). The server is a dumb relay — it authenticates, assigns rooms, and forwards frames without parsing them. See `docs/specs/2026-08-28-collab-help-request-design.md`.
(这份文档整体还是 ojnext 时代的内容,npm 命令、指向 ../OnlineJudge 的 Django 后端都过期了。
本任务只改与本次改动直接相关的两节,整份翻新另计。)
- Step 6: 全量验证
cd /home/xuyue/Projects/OJ/OJ2
bun run build
构建必须干净通过(没有找不到模块、没有未使用的类型报错)。然后 bun run dev 走一遍完整回归:
- 学生求助 → 教师接单 → 双向编辑 → 结束,学生代码保留
- 多人求助的排序、同题聚合、取消
- 无教师在线时求助的提示
- 比赛题页面能正常打开且无求助按钮(
ContestEditor.vue改动后没有 inject 报错) - 流程图题目页面正常
- 提交代码、判题状态实时刷新(
/ws/submissions没被 Task 2 的改动碰坏) - 超管改站点配置,其他页面不刷新就生效(
/ws/config同上) - 浏览器 Console 与 devtools Network → WS 里,没有任何指向
signaling.xuyue.cc或10.13.114.114:8085的请求
- Step 7: 提交
git add -A apps/web
git commit -m "refactor(web): 删掉 y-webrtc 协同编辑的旧实现
sync.ts / syncStatus.ts、y-webrtc 依赖、PUBLIC_SIGNALING_URL 全部移除,
外部信令服务器 signaling.xuyue.cc 不再被使用。
顺带修掉 CLAUDE.md 里「协作在流程图编辑器」这句一直是错的描述。"
收尾
全部任务完成后,docs/specs/2026-08-28-collab-help-request-design.md 的状态行从
「已确认,待实施」改成「已实施」,并提交。
外部信令服务器 signaling.xuyue.cc 在本次改动后已无任何调用方。它不在本仓库的
docker/ 编排里,需要用户自行到部署它的地方下线 —— 计划不包含这一步,因为仓库里
查不到它怎么起。