Files
OJ2/docs/plans/2026-08-28-collab-help-request.md
yuetsh 59191c9433 docs: 课堂求助与协作编辑实现计划
8 个任务:前端 WS 基类支持二进制 → 后端 collab 通道控制面 → 房间与帧转发
→ 前端 store → 学生求助按钮 → 教师顶栏列表 → 协作编辑 → 清理旧实现。
每个任务收尾是手动验证步骤(项目约定不写测试)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 02:00:37 -06:00

66 KiB
Raw Blame History

课堂求助与协作编辑 实现计划

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 + Drizzlews.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/apibun 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 storeWS 连接、求助列表(老师)、自身求助状态(学生)、当前房间。全局单例
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.tsoj/composables/syncStatus.ts

两点说明:

  • spec 里这个 composable 叫 shared/composables/collab.ts,本计划改名为 collabDoc.ts —— 与 shared/store/collab.ts 同名会让 import 路径极易看混。功能不变。
  • 前端配了 unplugin-auto-importvite.config.ts:101vue / 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:158send() 无条件 JSON.stringifywebsocket.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,登录后:

  1. 打开 devtools Network → WS确认 /ws/config/ws/submissions 照常连上、心跳 pong 正常
  2. 提交一份代码,确认判题状态照常实时刷新(走 /ws/submissions
  3. 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: SubmissionSocketDatawebsocket.ts:52TEACHER_ROLESroutes/helpers.ts:74touchSessionauth/session.ts:161

  • Produces:

    • state.ts: HelpRequestCollabSocket 类型;addRequestremoveRequestgetRequestlistRequestsqueueAheadOfaddTeacherremoveTeacherhasTeacherOnlineteacherSockets
    • handler.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.tssubmissionWebSocketHandler()。在 openmessageclose 三处加 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

getRequestSessionUserauth/session.ts:142返回的 user 已含 adminTypesession.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)

然后:

  1. **A教师**连上后应立刻收到一条 {"type":"requests","list":[]}
  2. **B学生**发 ws.send(JSON.stringify({type:"help_request",problemId:"1"})) problemId 换成一道真实存在的非比赛题的展示号)
    • B 收到 {"type":"help_status","status":"pending","queueAhead":0}
    • A 收到 requestslist 里有一条,含 studentNameproblemTitleclassName
  3. B{"type":"help_cancel"} → A 收到空 list
  4. B{"type":"help_request",problemId:"不存在的号"} → 收到 题目不存在或不支持求助
  5. **A教师**发 help_request → 收到 教师不能发起求助
  6. 关掉 A 的连接B 再发 help_request → 收到 {"status":"no_teacher"}
  7. 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 类型;openRoomgetRoomcloseRoomroomOf
    • handler.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 的新导出要加进顶部 importopenRoomcloseRoomroomOftype RoomgetRoom 本任务用不到,别顺手加进去。)

  • 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))
  1. B 发 help_request → A 收到列表
  2. A 发 {"type":"accept","studentId":<B的用户id>}
    • 双方各收到一条 room_openpeer.name 分别是对方的用户名
    • B 另收到 {"type":"help_status","status":"active","teacherName":"..."}
    • A 再收到 requests,那条的 status 变成 active
  3. A 发二进制:ws.send(new Uint8Array([1,2,3]))B 收到 Uint8Array(3) [1,2,3];反向同样
  4. A 再发一次 accept(换个学生)→ 收到 请先退出当前协作
  5. A 发 {"type":"leave"} → 双方收到 {"reason":"done"}A 的列表变空
  6. 重新 accept 后关掉 A 的标签页 → B 收到 {"reason":"peer_offline"} 再开一个教师连接,列表里那条应回到 pending
  7. 重新 accept 后关掉 B 的标签页 → A 收到 peer_offline,且列表里那条消失
  8. 未在房间里时发二进制 → 无任何转发、不报错
  9. 用学生账号发 {"type":"accept","studentId":1} → 收到 无权限
  10. 连续快速发 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 / sendRawTask 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) 以及只读状态 requestshelpStatusqueueAheadteacherNameroom

  • 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"
}

/**
 * 课堂求助 / 协作通道。和另外两条的区别是它**双向**且**收发二进制** ——
 * 控制面是 JSONYjs 的 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.vueuseConfigUpdate() / 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 登录后:

  1. devtools Network → WS 里能看到 /ws/collab 连上,且任何页面(首页、后台、题目页)都在
  2. 教师端 ConsoleuseCollabStore 不好直接取,改在 Vue devtools 里看 pinia 的 collab store —— requests 初始为空数组
  3. 学生端 Console 手动发一条求助(借用上一个任务的裸 WS 方式即可),教师端 store 的 requests 应实时出现一条,pendingCount 变 1
  4. 学生登出 → 连接断开;重新登录 → 自动重连
  5. 停掉 apiCtrl-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 不再 emit toggleSync,也不再 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 里的 toggleSynctoggleSync() 函数、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-syncformRefuseTemplateRef 声明一并删)。<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 里把 syncProps 去掉、把两个 emit 去掉、把 watch(() => sync, ...)handleEditorReady 里的 if (sync) 去掉, initSync / cleanupSyncResourcesuseCodeSync 的引用全部删掉, 只留一个干净的 CodeMirror 包装。Task 7 再把协作接回来。

  • Step 3: 验证

bun run dev,学生账号打开任意一道非比赛题:

  1. 题目页出现「求助」按钮(原来的「开启同步」不见了)
  2. 教师账号在另一个 profile 登录并保持任意页面打开
  3. 学生点「求助」→ 按钮变「取消求助」,旁边出现「已求助,等待老师接入」标签
  4. 教师端 Vue devtools 里 collab store 的 requests 出现一条
  5. 学生再点一次 → 撤销,标签消失,教师端列表变空
  6. 教师账号打开题目页 → 不应出现求助按钮
  7. 比赛题页面、流程图语言、手机宽度下 → 不应出现求助按钮
  8. 教师全部下线时学生点求助 → 弹出「当前没有老师在线」,按钮保持「求助」
  9. 确认页面其余功能正常:切语言、提交代码、重置代码、比赛题页面能正常打开 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()groupedRequestspendingCountacceptreject

  • 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,教师账号 + 两三个学生账号(可以用同一台机器的多个隐私窗口):

  1. 教师顶栏出现「求助」按钮,角标为 0
  2. 学生 B 对第 5 题求助 → 角标变 1点开能看到 B 的名字、班级、等待时长在走
  3. 学生 C 也对第 5 题求助 → 角标 2两人归在同一组下组标题旁出现「2 人」标签
  4. 学生 D 对第 9 题求助 → 出现第二个分组;人多的第 5 题排在前面
  5. 点某条的 × → 该学生收到「老师已取消你的求助」,条目消失,角标减 1
  6. 点某条的空白处 → 触发 accept此时还没有协作界面Task 7 才有); 刷新后该条应显示为灰色的「XX 处理中」
  7. 学生账号登录后顶栏不应出现这个按钮
  8. 窗口缩到手机宽度 → 按钮消失
  • 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

lib0y-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

  1. 学生在第 5 题写几行代码,点「求助」
  2. 教师点开列表,点那条 → 弹出模态框,里面出现学生写的那几行代码
  3. 确认学生那边的编辑器内容没有任何变化(没被清空、没闪、没被覆盖)—— 这是本任务的核心
  4. 教师在模态框里改代码 → 学生编辑器实时跟着变,且能看到教师的光标(红色)
  5. 学生在自己编辑器里打字 → 教师模态框实时跟着变,光标蓝色
  6. 两边同时在不同位置打字 → 不冲突、不丢字
  7. 教师点「结束协作」→ 模态框关闭,学生编辑器保留改后的代码,求助按钮回到「求助」
  8. 学生刷新页面 → 代码还在localStorage 照常写入)
  9. 再走一遍,这次教师直接关标签页 → 学生收到「对方已断开连接」,且求助自动回到排队状态
  10. 再走一遍,这次学生关标签页 → 教师模态框关闭,列表里那条消失
  11. 教师快速连续敲 100 个字符 → 连接不断(限流宽松档),学生端跟得上
  12. 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

预期只剩下待删的那几个文件自身、四个 .envenv.d.tspackage.jsonCLAUDE.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

确认 yjsy-codemirror.nexty-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

两处:

  1. 环境变量表里删掉这一行:
    | `PUBLIC_SIGNALING_URL` | WebRTC signaling server |
    
  2. ### 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 走一遍完整回归:

  1. 学生求助 → 教师接单 → 双向编辑 → 结束,学生代码保留
  2. 多人求助的排序、同题聚合、取消
  3. 无教师在线时求助的提示
  4. 比赛题页面能正常打开且无求助按钮(ContestEditor.vue 改动后没有 inject 报错)
  5. 流程图题目页面正常
  6. 提交代码、判题状态实时刷新(/ws/submissions 没被 Task 2 的改动碰坏)
  7. 超管改站点配置,其他页面不刷新就生效(/ws/config 同上)
  8. 浏览器 Console 与 devtools Network → WS 里,没有任何指向 signaling.xuyue.cc10.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/ 编排里,需要用户自行到部署它的地方下线 —— 计划不包含这一步,因为仓库里 查不到它怎么起。