Compare commits

...

20 Commits

Author SHA1 Message Date
ffabcd4a0d fix(collab): 提示会丢、排队位置不刷新;显式声明 lib0
Some checks failed
Deploy / deploy (push) Has been cancelled
- 一次性提示原来挂在题目页的 Form.vue 上消费:学生排着队切去看提交记录,
  老师这时取消了他的求助,那条提示就永远没人消费。挪到顶栏统一弹,
  教师端的 error 提示同理。另外加了序号——连着两次同样的文案在 Vue 眼里
  === 相等,watch(notice) 不会第二次触发。

- queueAhead 原来只在「建请求 / 重连 / 退回排队」推过,前面的人被接走或
  被取消之后不重算,第五个学生会一直显示「前面还有 4 人」。跟着
  broadcastRequests 一起推,两件事永远同时发生。

- collabDoc 动态 import 了 lib0/encoding 和 lib0/decoding,但 lib0 不在
  package.json 里,靠 yjs 提升到根 node_modules 才能解析。上游依赖树一变
  就断,而且断在运行时不在构建时。按现装的 0.2.117 钉住。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
2026-08-30 09:38:04 -06:00
4abaf9c7e4 fix(collab): 动态 import 竞态、切走页面的语义、演示模式的身份错位
- collabDoc.start 要 await 六个动态 import,房间可能在这期间就关了(机房首次
  加载 y* 那几个 chunk 正是最慢的时候)。stop() 先跑完面对的是 doc === null,
  什么也拆不到;import 回来之后 start 的后半段照样建文档、装 binaryHandler、
  挂 yCollab、发 SyncStep1——给一个不存在的房间。加会话代号,过期就整个放弃。

- SyncCodeEditor 卸载(切走题目页、把语言切成流程图)原来只是悄悄 stop(),
  房间和 active 都还留着,老师那边模态框照开、字照敲,一个也到不了;watch
  没有 immediate,学生切回来也不会重建。改成明确 leave() 结束协作。
  另外 @ready 也作为起点之一:学生排队时切走、老师这期间接了单,切回来能接上。

- 演示模式下 isTeacherOrAbove 被强制 false,服务端却按库里的 adminType 照样
  把他算作在线老师:学生因此拿到 pending 而不是 no_teacher,排队等一个顶栏里
  根本没有求助列表的人;他自己看到的求助按钮点下去,服务端回「教师不能发起求助」。
  两头都不对,索性对演示模式整个关掉这个功能。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
2026-08-30 09:38:04 -06:00
66a564710f fix(collab): 两处会让协作「看起来正常、其实各写各的」的缺陷
都是实测复现出来的,不是推测。

1. 教师第二次接单,看到的是上一个学生的代码拼在自己文档里
   CollabModal 的 code ref 跨会话不清。n-modal 默认 display-directive="if",
   每次打开都重挂 CodeMirror,拿这个 ref 当初始文档;而 yCollab 只观察 ytext、
   从不反过来用 ytext 覆盖编辑器。于是上一轮的代码留在文档里,新学生的内容
   作为 delta 插到位置 0,两边偏移从此对不上。
   实测三轮会累积成 CCC/BBB/AAA/XXX,且教师敲一行之后,教师看到
   「BBB/AAA/XXX」、学生看到「BBB/XXX」——两份不同的文档。
   改法:不绑 v-model,文档完全交给 Yjs;并把 start 的起点从「房间打开 +
   await nextTick」换成「编辑器 ready」,顺带修掉绑到已 destroy 的旧 view。

2. 学生换连接后房间静默死掉
   handleCollabOpen 迁移 socket 时只补发了 help_status,没补 room_open,
   而前端每次连接建立都会把 room 清成 null —— 页面显示「老师正在帮你」,
   编辑器却早把 yCollab 摘了,老师敲的字一个也到不了。
   补发 room_open 也修不好:CRDT 状态跟着旧连接没了,新建 Y.Doc 再 seed
   会和老师那份合并成重复文本。所以改成直接拆房、请求退回排队,
   老师再点一次 —— 和老师掉线走同一条路子。

顺带修掉验证时挖出来的第三个:握手帧会被丢。
两端挂 yCollab 的时刻不同步(教师等模态框、学生等 chunk),先挂好的那端发的
SyncStep1 到对面时还没有 binaryHandler,服务端转发是成功的、客户端却静静丢掉。
丢的偏偏是握手——y-protocols 里 A 的内容靠 B 发的 Step1 换回来,教师的 Step1
一丢,学生的代码就永远到不了教师那边(单向同步,同样看着像在协作)。
CollabWebSocket 改成 handler 装上之前先把二进制帧缓着,装上再按序放行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
2026-08-30 09:37:41 -06:00
bb33e0f0e5 refactor(web): 删掉 SyncCodeEditor 的 problem prop
老实现的残留:sync.ts 拿它拼房间名 problem-${problemId}。新实现的房间以
学生为键、由服务端分配,前端不需要题号 —— 这个必填 prop 声明之后
整个组件再没用过第二次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
2026-08-30 09:05:48 -06:00
4b6242ba70 fix(比赛): 比赛进行中,学生打开比赛题必 500
contest.ts 两处把「藏起来的难度」表示成空串,而 problemDifficultySchema
是严格的三值枚举,parse 直接抛 ZodError。contestDetailsAllowed 在
「比赛进行中 + 看的人不是管理员」时就是假 —— 也就是正常学生在正常比赛里,
比赛题列表和详情页一开就挂。dev 库 contest 表一直是空的,这条路径没被跑到过。

改成下发 null,契约里给这两个字段单独起了 maskedProblemDifficultySchema。
语义上对齐旧 Django:ProblemSafeSerializer 把 difficulty 放在 exclude 里,
字段整个不下发,而不是给一个假值。

端上顺着 null 补了四处:ProblemInfo 的「难度」项整条不渲染(和旧栈表现一致),
题目列表、题单列表同理,transforms 的 ProblemFiltered.difficulty 也放开为可空。
这几处是 vue-tsc 逐个揪出来的,正是严格枚举该起的作用。

实测(学生 / 超管 × 比赛题 / 普通题 四种组合):学生看比赛题详情与列表
从 500 变 200 且 difficulty 为 null,超管仍拿到真值 Low,普通题不受影响;
页面上「难度」整项消失,统计面板其余内容正常。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
2026-08-30 09:01:56 -06:00
c7132025f7 fix(collab): 二进制帧的宽松限流档名存实亡
两档共用一个令牌桶:桶按严格档的 20 初始化,之后每条文本帧(含 30 秒
一次的心跳)都会 Math.min(RATE_BURST, ...) 把它压回 20,二进制帧那档
标的 200 突发根本拿不到。实测连打 150 帧在第 101 帧被 1008 踢下线——
正是设计里要避免的「协作编辑时打字把自己踢掉」。

改成两个独立的桶。文本帧仍是 20 / 每秒 2,二进制帧 200 / 每秒 100。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
2026-08-30 08:29:36 -06:00
de4e745d90 docs: 课堂求助与协作编辑标记为已实施
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
2026-08-30 08:22:12 -06:00
d1fc6349b7 refactor(web): 删掉 y-webrtc 协同编辑的旧实现
sync.ts / syncStatus.ts、y-webrtc 依赖、PUBLIC_SIGNALING_URL 全部移除,
外部信令服务器 signaling.xuyue.cc 不再被使用。
顺带修掉 CLAUDE.md 里「协作在流程图编辑器」这句一直是错的描述。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
2026-08-30 08:22:06 -06:00
86857dedf0 feat(web): 协作编辑走 collab 通道
Yjs sync/awareness 协议直接跑在 /ws/collab 上,服务端哑转发。
内容源是学生:学生端先把编辑器内容写进 ytext 再挂 yCollab,
教师端 seedContent 恒为 null —— 这条根治了老实现里谁的代码活下来看运气的问题。

订正 brief 里的一处疏漏:SyncCodeEditor.vue 挂在每个用户的题目页上,教师也不例外
(ProblemEditor.vue 只按语言是不是 Flowchart 分支,不看角色)。教师接单同样会让
collabStore.room 非空,若不按角色收窄,教师自己停在某道题页面上接单时,这个组件
会把教师自己的编辑器内容当成种子插入文档,还会跟 CollabModal 抢
setBinaryHandler 这个单例槽位。改为 `room && !collabStore.isTeacher` 才起协作,
教师端的协作只归 CollabModal 管。

另外两处修正:
- editorView 用 shallowRef 而非 ref —— CodeMirror 的 EditorView 是带 getter 的类
  实例,ref() 的深度 UnwrapRef 会把它拆成丢了原型方法的假类型,vue-tsc 报错。
- Header.vue 挂 CollabModal 放进根 n-flex 内部而不是同级 —— 组件一旦变成多根
  fragment,default.vue 里 `<Header class="header" />` 的 class 就没有单一根节点
  可以落地,header 行会丢掉居中样式(实测触发了 Vue 的
  Extraneous non-props attributes 警告)。n-modal 默认 teleport 到 body,塞在
  这里不影响其实际渲染位置。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 07:43:08 -06:00
bfe4468fdb feat(web): 教师顶栏的求助列表
按题目分组,同题多人时标出人数。按等待时长排序但不强制先来先到 ——
上课时有的问题一句话说清、有的要讲五分钟。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 06:48:07 -06:00
6f873be367 feat(web): 题目页的「开启同步」换成「求助」
学生发起求助,状态与排队位置显示在按钮旁。
教师端入口不在题目页,所以对教师隐藏这个按钮。

顺带修正 handleHelpCancel:去掉 request.socket !== ws 的校验。
学生取消自己的求助,不管从他哪个标签页发起都合法——
getRequest(ws.data.userId) 已经把范围锁在这一个用户上了,
不是跨用户操作,不需要再比对是不是同一条连接。这层校验此前
会让「第二个标签页点取消」被服务端静默丢弃,前端却已经乐观
地把按钮变回「求助」,是一处 UI 说谎;现在两边状态一致。
handleCollabClose 里排队分支的 socket 归属校验不受影响,
那里的关闭事件是顺带触发的,仍然需要认出是不是本人这条连接。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 06:33:25 -06:00
ab8fcc2d42 fix(collab): room_closed 无条件归位、断线重连补状态、学生 socket 重连迁移
Critical:room_closed 不再按 reason 挑着重置 helpStatus——学生自己的 socket
发送失败时服务端删了请求却发不出任何纠正帧,store 会卡在陈旧的 active/pending
上出不来。现在一律先归 idle,老师掉线那条路径服务端会紧跟着补一条
help_status:pending,同一条连接消息严格按序到达,不会被这次重置抢跑。

Critical:断线重连没有补状态。前端 CollabWebSocket 加 onConnected 钩子,
每次连接建立(含重连)都清空本地 requests/helpStatus/room,等服务端补发;
后端 handleCollabOpen 对非老师的重连方,如果这个账号名下还有请求,
补发对应的 help_status(pending 带重算的 queueAhead,active 带 teacherName)。

Critical:重连后请求仍绑在旧 socket 上,导致 handleHelpCancel 的归属校验
认不出新连接、后续通知也写进死连接。handleCollabOpen 里把请求和(如果有)
房间迁移到新 socket,并清掉旧 socket 的 roomOwnerId,防止它稍后的 close
反过来拆掉刚迁移出去的房间。

Minor:disconnect() 补齐 queueAhead/teacherName/notice 的重置,不留陈旧值。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 06:01:41 -06:00
17aa69f8a7 feat(web): 课堂求助 store 与全局 collab 连接
连接全局常驻,不跟题目页起落 —— 老师在任何页面都要能收到求助。
列表按题目聚合,同题多人时能一眼看出该全班讲。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 05:36:51 -06:00
aef687bcfc fix(api): collab 二进制转发的背压误判与发送失败后的幽灵请求
Important 1:handleCollabBinary 把 send() 返回的 -1 当成失败,实测证明
-1 只是背压——消息已排队,最终照样送达(8MB 帧原样完整到达);只有 0
才是真丢。之前 sent <= 0 一触发背压就拆房间,慢网/大粘贴反而先弄死正常
会话。改成只认 sent === 0;同时空 Uint8Array 的 send() 也回 0(送达和
真丢用同一个返回值分不清),先按长度 0 直接忽略,不转发也不参与失败
判定,否则任意一方发一个空二进制帧就能把房间拆了。

Important 2:发送失败后走 teardownRoom("peer_offline") 从不 removeRequest
(只有 reason === "done" 才删),请求卡在 status: "active" 没有房间,
之后 handleReject / handleHelpCancel / handleLeave / handleAccept 全部
因为状态或归属对不上而拒绝处理,那个学生的后续求助永远被静默吞掉。
补上 offlineSide 参数,和教师断线共用同一条收尾路径:老师那侧消失,
请求退回 pending 并清 teacherId/teacherName,学生收到新的 help_status
pending;学生那侧消失,请求整条清掉。两种情况都发 room_closed 并
broadcastRequests()。

Minor:handleHelpCancel 没有 request.socket === ws 校验,同一账号第二个
标签页能取消第一个标签页排队中的请求——和上一轮修的 close 排队分支是
同一类归属漏洞,补上同样的检查。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 05:28:14 -06:00
457df1ef3c fix(api): collab 房间越权与几处时序/静默丢帧缺口
Critical:学生排队断线分支补归属+状态校验,避免同账号双标签页
把 active 请求错删、被另一个学生请求顶替后又被别的老师抢建出
第二间房;handleAccept 补 getRoom(studentId) 兜底同一学生 id
下已有房间的情况。

Important:handleAccept 的库查询是个 await 点,之后补上
teacherSockets().has(ws) 判活,教师在等待期间断线不会再对着
死 socket 建房间。

Minor:handleReject 补上和 handleAccept 一致的库复核,堵住
连接存活期间被降级/禁用的教师继续掐请求的口子;
handleCollabBinary 检查 peer.send() 返回值,转发失败时走既有
teardownRoom 拆房间,不再静默丢帧、悄悄分叉两边的代码。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 02:48:35 -06:00
b14639d890 feat(api): collab 房间管理与 Yjs 帧转发
accept 时按库里的 adminType 复核身份,房间以学生为键。
服务端只按房间转发二进制帧,不解析内容。
老师掉线请求退回排队,学生掉线请求随人清除。

顺带处理三项 Task 2 评审遗留:
- TEACHER_ROLES 去重,handler.ts 改为从 routes/helpers.ts 导入,不再自留一份
- 补全学生排队中断线(尚未进房间)的清理分支,避免陈旧请求卡死在列表里
- 修正 websocket.ts 里一处过期注释:username/adminType 是三种 kind 都会填,
  不是只有 collab 才填

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 02:36:12 -06:00
1db2c49b87 feat(api): 新增 /ws/collab 通道与课堂求助控制面
学生发起/撤销求助,在线教师收到全量列表。求助只在内存,不落库。
二进制帧的限流单独一档,避免协作输入把连接踢掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 02:24:44 -06:00
c04570b4ad feat(web): BaseWebSocket 支持二进制帧收发
onmessage 原来无条件 JSON.parse,收到 Yjs 二进制帧会落进 catch。
加 onBinary 钩子与 sendRaw,让 collab 通道能复用基类的重连与心跳。
2026-08-28 02:06:13 -06:00
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
275e70e23a docs: 课堂求助与协作编辑设计文档
学生发起求助、老师从全局列表接单进入协作。传输从 y-webrtc P2P 改为
后端 WebSocket 通道,房间归属与权限由服务端判定。

替换掉现有 sync.ts 的四个结构性问题:房间按题号分导致老师无法指定帮谁、
初始内容源竞态、权限靠客户端自报且三人以上失效、信令服务器无部署源。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 01:50:00 -06:00
34 changed files with 3839 additions and 702 deletions

View File

@@ -0,0 +1,446 @@
import { and, eq, isNull } from "drizzle-orm"
import { touchSession } from "../auth/session"
import { db, schema } from "../db"
import { TEACHER_ROLES } from "../routes/helpers"
import {
addRequest,
addTeacher,
closeRoom,
getRequest,
getRoom,
hasTeacherOnline,
listRequests,
openRoom,
queueAheadOf,
removeRequest,
removeTeacher,
roomOf,
teacherSockets,
type CollabSocket,
type HelpRequest,
type Room,
} from "./state"
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,
}
}
/**
* 把最新的排队位置推给每个还在等的学生。
*
* queueAhead 原来只在「建请求 / 重连 / 退回排队」这三处推过,前面的人被接走或被
* 取消之后不重算 —— 五个人排队、前四个都处理完了,第五个还一直显示「前面还有 4 人」。
*/
function broadcastQueuePositions() {
for (const request of listRequests()) {
if (request.status !== "pending") continue
sendHelpStatus(request.socket, "pending", {
queueAhead: queueAheadOf(request.studentId),
})
}
}
/**
* 队列变了:老师端收全量列表,排队中的学生各自收自己的新位置。
*
* 两件事捏在一起是因为它们永远同时发生 —— 拆成两个函数分别调,迟早会在某条
* 路径上漏掉一个。
*/
export function broadcastRequests() {
const payload = JSON.stringify({
type: "requests",
list: listRequests().map(serializeRequest),
})
for (const ws of teacherSockets()) ws.send(payload)
broadcastQueuePositions()
}
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) }),
)
return
}
// 学生(重)连:如果这个账号名下已经有一条请求(掉线重连回来,或者干脆是
// 同账号第二个标签页),把它迁移到这条新连接上,并把当前状态补发回去 ——
// 前端 onConnected 时会先把本地状态清空等着这条补发,不发的话就永远卡在
// idle不迁移 socket 归属的话sendHelpStatus/accept 等后续推送会发到一条
// 已经不用的旧连接上,新连接(新标签页)什么都收不到
const request = getRequest(ws.data.userId)
if (!request) return
request.socket = ws
const room = getRoom(ws.data.userId)
if (room && room.studentSocket !== ws) {
// 协作中的学生换了一条连接。**不迁移房间,直接拆掉。**
//
// 原来这里是把 studentSocket 换成新连接就算完,转发确实转到新连接了,
// 但客户端接不住:前端每次连接建立都会把 room 清成 null旧连接的状态
// 不该越过重连活下来),而这里只补发了 help_status没补 room_open ——
// 于是学生页面显示「老师正在帮你」、编辑器却早就把 yCollab 摘了,
// 老师照常敲字、一个字也到不了对面。正是 handleCollabBinary 注释里说的
// 「看起来在协作、其实各看各的」,比老实断开更糟。
//
// 而补发 room_open 也修不好Yjs 的文档状态跟着旧连接一起没了,新连接
// 只能新建 Y.Doc再拿学生编辑器里的内容当种子插进去就会和老师那份
// 已有内容合并成重复文本(两份 doc 的 item 身份不同CRDT 不去重)。
// 续接一个 CRDT 会话不是哑转发层做得到的事。
//
// 所以退回排队,老师再点一次 —— 和老师掉线走的是同一条路子。学生的代码
// 一直在他自己的编辑器里,不受影响。
closeRoom(room.studentId)
room.studentSocket.data.roomOwnerId = undefined
room.teacherSocket.data.roomOwnerId = undefined
room.teacherSocket.send(
JSON.stringify({ type: "room_closed", reason: "peer_offline" }),
)
request.status = "pending"
request.teacherId = undefined
request.teacherName = undefined
broadcastRequests()
}
if (request.status === "pending") {
sendHelpStatus(ws, "pending", { queueAhead: queueAheadOf(ws.data.userId) })
} else if (request.status === "active") {
sendHelpStatus(ws, "active", { teacherName: request.teacherName ?? "" })
}
}
/** 老师从房间消失(掉线,或发送失败被判定为事实上不可达):请求退回排队,
* 学生不必重新点 —— 可能只是网络抖了一下 */
function requeueAfterTeacherGone(studentId: number) {
const request = getRequest(studentId)
if (request) {
request.status = "pending"
request.teacherId = undefined
request.teacherName = undefined
sendHelpStatus(request.socket, "pending", {
queueAhead: queueAheadOf(studentId),
})
}
}
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) {
requeueAfterTeacherGone(room.studentId)
} else {
// 学生掉线:请求随人走
removeRequest(room.studentId)
}
} else if (!isTeacher(ws)) {
// 还在排队时关掉页面,请求也该消失 —— 但只能收自己这条。同一账号可能开了两个
// 标签页,另一个标签页可能已经把请求接成 active甚至已经换了一拨新请求
// 不加 socket 归属和状态检查,这里会把活跃房间的请求记录连根拔起
const request = getRequest(ws.data.userId)
if (request && request.socket === ws && request.status !== "active") {
removeRequest(ws.data.userId)
}
}
broadcastRequests()
}
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
case "accept":
await handleAccept(ws, message.studentId)
return
case "reject":
await handleReject(ws, message.studentId)
return
case "leave":
handleLeave(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
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,
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)
// 不比对 socket 归属:取消的是这个学生自己的求助,不管从他哪个标签页发起都
// 合法——getRequest(ws.data.userId) 已经把范围锁在这一个用户上了,不是跨用户
// 操作。这里和 handleCollabClose 的排队分支不是同一类问题:那边关闭事件是
// 「顺带」触发的,必须认出是不是本人这条连接;这里是用户主动点了取消
if (!request || request.status === "active") return
removeRequest(ws.data.userId)
broadcastRequests()
}
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
}
// 上面这次查询是个 await 点,等待期间这条连接可能已经断开——断线时
// handleCollabClose 已经把它从 teacherSockets 摘掉了,用它来判断这次 accept
// 还作不作数。continuation 里不能再对着一个死 socket 建房间
if (!teacherSockets().has(ws)) return
// 老师同时只能在一个房间
if (roomOf(ws)) {
ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" }))
return
}
const request = getRequest(studentId)
if (!request || request.status === "active" || getRoom(studentId)) {
// 被别人接走了、学生已经撤销,或者这个学生 id 名下已经有一个房间在挂着
// (正常路径走不到,是两个标签页 + 断线重连缝隙的最后一道闸)——
// 回一份最新列表让老师端自己纠正
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()
}
async function handleReject(ws: CollabSocket, studentId: unknown) {
if (!isTeacher(ws) || typeof studentId !== "number") return
// reject 很少见,多这一次查询不心疼;不然握手快照挡不住"连接活着期间被降级
// 或禁用"的老师继续掐掉排队中的求助
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
}
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 /
* handleCollabBinary。offlineSide 是消失的那一方:老师消失,
* 请求退回排队;学生消失,请求随人清掉。不传时(当前只有
* handleLeave 走 "done")不做这一步,只拆房间
*/
function teardownRoom(
room: Room,
reason: "done" | "peer_offline",
offlineSide?: "student" | "teacher",
) {
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)
} else if (offlineSide === "teacher") {
requeueAfterTeacherGone(room.studentId)
} else if (offlineSide === "student") {
removeRequest(room.studentId)
}
broadcastRequests()
}
/**
* Yjs 的 update / awareness 帧。服务端不解析、不留存,只转发给房间里的另一个人。
*
* 「服务端不知道代码内容」是有意的:这个通道要做的事只有认证和分房间,
* 权限由 accept 时的库查询决定,与帧里装的是什么无关。
*/
export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array) {
// 空帧Bun.serve 探测过send() 对 0 字节帧也回 0同一个返回值,
// 真实送达和真实丢弃分不清),不转发、不参与下面的失败判定,直接忽略。
// 否则任何一方发一个 0 字节二进制帧就能把整间房拆掉
if (data.length === 0) return
const room = roomOf(ws)
if (!room) return
const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
const sent = peer.send(data)
// Bun.serve 探测过:-1 不代表失败,是背压——消息已排队,最终会送达(实测 8MB
// 帧照样完整到达);只有 0 才是真的丢了(对端事实上已经断开)。之前把 <= 0
// 当成失败,慢网/大粘贴一触发背压就把正常房间拆掉,是本该保护的场景反而先死
if (sent === 0) {
// 真丢帧:两边的 Yjs 文档会从此悄悄分叉——教学工具里"看起来在协作、其实
// 各看各的代码"比老实断开更糟,不做续传,直接拆房间。和教师断线走同一条
// 收尾路径:老师那侧消失就把请求退回排队,不让学生卡死在 active 出不来
console.error("Collab binary forward failed, tearing down room", {
studentId: room.studentId,
})
const offlineSide = peer === room.teacherSocket ? "teacher" : "student"
teardownRoom(room, "peer_offline", offlineSide)
}
}

View File

@@ -0,0 +1,113 @@
/**
* 课堂求助的内存状态。
*
* 不落库是有意的:求助是课堂上的即时行为,学生关掉页面这条请求就该消失。
* 服务端只有一个 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 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)
}
/** 仅供进程退出或测试用,正常路径不该调 */
export function resetCollabState() {
requests.clear()
teachers.clear()
rooms.clear()
}

View File

@@ -100,19 +100,30 @@ const server = Bun.serve<SubmissionSocketData>({
new Response("Not found", { status: 404 })
)
}
if (url.pathname === "/ws/submissions" || url.pathname === "/ws/config") {
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" : "submissions"
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,
},
})
) {

View File

@@ -144,7 +144,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("
title: problem.title,
submissionNumber: allowed ? problem.submissionNumber : 0,
acceptedNumber: allowed ? problem.acceptedNumber : 0,
difficulty: allowed ? problem.difficulty : "",
difficulty: allowed ? problem.difficulty : null,
createdBy: sampleUser(user, realName),
tags: tags.get(problem.id) ?? [],
contestId: contest.id,
@@ -179,7 +179,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
lastUpdateTime: row.problem.lastUpdateTime,
timeLimit: row.problem.timeLimit,
memoryLimit: row.problem.memoryLimit,
difficulty: allowed ? row.problem.difficulty : "",
difficulty: allowed ? row.problem.difficulty : null,
source: row.problem.source,
prompt: row.problem.prompt,
submissionNumber: allowed ? row.problem.submissionNumber : 0,

View File

@@ -71,7 +71,7 @@ export function queryInteger(
// 任何角色(助教、家长……)都会**默认拿到管理员权限**,包括 canViewSubmission 里的
//「看所有人代码」。加角色的人多半想不到要回来改这里,白名单则会默认拒绝。
const ADMIN_ROLES = ["Student Admin", "Teacher Admin", "Super Admin"]
const TEACHER_ROLES = ["Teacher Admin", "Super Admin"]
export const TEACHER_ROLES = ["Teacher Admin", "Super Admin"]
// 注意:不要再加 isRegularUser(user) 这类「是普通用户才受限」的判断 ——
// 匿名用户 user 为 null 时它返回 false守卫会整体短路匿名的权限反而大于登录学生。

View File

@@ -2,6 +2,12 @@ import { flowchartUpdateSchema, submissionUpdateSchema } from "@oj2/contract"
import { and, eq } from "drizzle-orm"
import { touchSession } from "./auth/session"
import {
handleCollabBinary,
handleCollabClose,
handleCollabMessage,
handleCollabOpen,
} from "./collab/handler"
import { config } from "./config"
import { db, schema } from "./db"
import {
@@ -49,14 +55,32 @@ export function isAllowedWebSocketOrigin(origin: string | null, url: URL) {
)
}
interface RateBucket {
tokens: number
updatedAt: number
}
export interface SubmissionSocketData {
userId: number
/** 同一个 Bun.serve 只能挂一个 websocket handler用它区分两条通道 */
kind: "submissions" | "config"
/** 同一个 Bun.serve 只能挂一个 websocket handler用它区分通道 */
kind: "submissions" | "config" | "collab"
/** 握手时那张会话的 token留着定期确认它还没被登出 / 过期,见 sweepSessions */
token: string
/** 令牌桶open 时初始化,见 allowMessage */
rate?: { tokens: number; updatedAt: number }
/** 文本控制帧的令牌桶open 时初始化,见 allowMessage */
rate?: RateBucket
/**
* collab 二进制帧的令牌桶,和 rate 分开。
*
* 共用一个桶的话宽松档名存实亡:每条文本帧(含 30 秒一次的心跳)都会
* `Math.min(RATE_BURST, ...)` 把桶压回 20二进制帧再怎么标 200 突发也拿不到。
* 实测连打 150 帧会在第 101 帧被 1008 踢下线。
*/
binaryRate?: RateBucket
/** 握手时从会话里读,三种 kind 都会填collab 通道用它判断老师身份、拼 room_open 里的姓名 */
username?: string
adminType?: string
/** 当前所在协作房间的房主学生id见 collab/handler.ts */
roomOwnerId?: number
}
/**
@@ -69,17 +93,40 @@ export interface SubmissionSocketData {
const RATE_BURST = 20
const RATE_REFILL_PER_SECOND = 2
function allowMessage(ws: Bun.ServerWebSocket<SubmissionSocketData>) {
/**
* collab 通道的二进制帧Yjs update / awareness单独一档。
*
* 它不查库、不解析,纯内存按房间转发,成本和文本控制帧完全不是一个量级;
* 而连续快速输入大约 5-10 帧/秒,用严格档几秒钟就会把正在协作的人踢下线。
*/
const COLLAB_BINARY_BURST = 200
const COLLAB_BINARY_REFILL_PER_SECOND = 100
function consume(bucket: RateBucket, burst: number, refillPerSecond: number) {
const now = Date.now()
const rate = (ws.data.rate ??= { tokens: RATE_BURST, updatedAt: now })
const refill = ((now - rate.updatedAt) / 1000) * RATE_REFILL_PER_SECOND
rate.tokens = Math.min(RATE_BURST, rate.tokens + refill)
rate.updatedAt = now
if (rate.tokens < 1) return false
rate.tokens -= 1
const refill = ((now - bucket.updatedAt) / 1000) * refillPerSecond
bucket.tokens = Math.min(burst, bucket.tokens + refill)
bucket.updatedAt = now
if (bucket.tokens < 1) return false
bucket.tokens -= 1
return true
}
/** 文本帧:严格档。会查库,走这一档的都按最坏情况算 */
function allowMessage(ws: Bun.ServerWebSocket<SubmissionSocketData>) {
const bucket = (ws.data.rate ??= { tokens: RATE_BURST, updatedAt: Date.now() })
return consume(bucket, RATE_BURST, RATE_REFILL_PER_SECOND)
}
/** collab 二进制帧:宽松档,独立的桶 —— 见 binaryRate 的注释 */
function allowCollabBinary(ws: Bun.ServerWebSocket<SubmissionSocketData>) {
const bucket = (ws.data.binaryRate ??= {
tokens: COLLAB_BINARY_BURST,
updatedAt: Date.now(),
})
return consume(bucket, COLLAB_BINARY_BURST, COLLAB_BINARY_REFILL_PER_SECOND)
}
/**
* 当前挂着的连接。Bun 不提供遍历连接的接口,要定期巡检就得自己登记。
* open 时加入、close 时移除,见 sweepSessions。
@@ -165,6 +212,11 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
open(ws) {
liveSockets.add(ws)
ws.data.rate = { tokens: RATE_BURST, updatedAt: Date.now() }
if (ws.data.kind === "collab") {
ws.data.binaryRate = { tokens: COLLAB_BINARY_BURST, updatedAt: Date.now() }
handleCollabOpen(ws)
return
}
if (ws.data.kind === "config") {
ws.subscribe(configTopic)
return
@@ -173,6 +225,25 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
ws.subscribe(userEventTopic(ws.data.userId))
},
message(ws, message) {
if (ws.data.kind === "collab") {
if (typeof message !== "string") {
if (!allowCollabBinary(ws)) {
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
}
if (!allowMessage(ws)) {
ws.close(1008, "Too many messages")
return
@@ -187,6 +258,10 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
},
close(ws) {
liveSockets.delete(ws)
if (ws.data.kind === "collab") {
handleCollabClose(ws)
return
}
if (ws.data.kind === "config") {
ws.unsubscribe(configTopic)
return

View File

@@ -2,5 +2,4 @@ PUBLIC_ENV=xuyue.cc
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=dd37457027c40b39
PUBLIC_CODE_URL=https://code.xuyue.cc
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
PUBLIC_ICONIFY_URL=https://icon.xuyue.cc

View File

@@ -3,4 +3,3 @@ PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13
PUBLIC_CODE_URL=http://10.13.114.114:82
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085

View File

@@ -3,4 +3,3 @@ PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13
PUBLIC_CODE_URL=http://10.13.114.114:82
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085

View File

@@ -41,8 +41,8 @@ Each feature module (under `oj/` or `admin/`) typically has:
- `api.ts` — API calls specific to the feature
Shared logic lives in `shared/`:
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `screenMode` (problem split-screen layout), `loginSummary` (AI activity summary)
- `composables/``pagination` (URL-synced), `websocket` (reconnect + heartbeat), `sync` (Yjs/y-webrtc for collaborative editing), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `breakpoints`, `maxkb`
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `screenMode` (problem split-screen layout), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room)
- `composables/``pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `breakpoints`, `maxkb`
- `layout/``default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha)
@@ -94,7 +94,6 @@ Variables prefixed with `PUBLIC_` are injected at build time. Env files: `.env`,
| `PUBLIC_CODE_URL` | Code execution service |
| `PUBLIC_JUDGE0_URL` | Judge0 API |
| `PUBLIC_MAXKB_URL` | Knowledge base service |
| `PUBLIC_SIGNALING_URL` | WebRTC signaling server |
| `PUBLIC_ICONIFY_URL` | Iconify icon CDN |
### Routing
@@ -107,7 +106,10 @@ Routes are defined in `src/routes.ts` with two root routes: `ojs` (user-facing)
### Real-time Features
- WebSocket via composable in `shared/composables/` for submission status updates
- 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`.
## Related Repository

View File

@@ -40,6 +40,7 @@
"date-fns": "^4.4.0",
"fflate": "^0.8.3",
"highlight.js": "^11.12.0",
"lib0": "0.2.117",
"md-editor-v3": "^6.5.6",
"mermaid": "^11.17.2",
"mermaid-legacy": "npm:mermaid@^9.4.3",
@@ -53,7 +54,7 @@
"vue-codemirror": "^6.1.1",
"vue-router": "^5.2.0",
"y-codemirror.next": "^0.3.6",
"y-webrtc": "^10.3.0",
"y-protocols": "1.0.7",
"yjs": "^13.6.32"
},
"devDependencies": {

View File

@@ -6,10 +6,12 @@ import { useConfigStore } from "shared/store/config"
import { useConfigUpdate } from "shared/composables/configUpdate"
import { useMaxKB } from "shared/composables/maxkb"
import { useUserStore } from "shared/store/user"
import { useCollabStore } from "shared/store/collab"
const isDark = useDark()
const configStore = useConfigStore()
const userStore = useUserStore()
const collabStore = useCollabStore()
// 初始化配置和实时更新
onMounted(() => {
@@ -23,6 +25,23 @@ onMounted(() => {
useConfigUpdate()
useMaxKB()
// 课堂求助通道。和 /ws/config 一样是全局常驻的:老师可能正在后台改题时
// 收到求助,学生也要在排队期间一直挂着,所以不放在题目页里起落。
//
// 演示模式下整个关掉。它是超管把界面伪装成学生用来投屏的,而服务端只认库里的
// 真实身份:连着的话这个人会被算进「在线老师」,学生因此拿到 pending 而不是
// no_teacher排队等一个顶栏里根本没有求助列表的人反过来他自己看到的求助
// 按钮点下去,服务端回的是「教师不能发起求助」。两头都不对,索性对演示模式
// 关闭这个功能Form.vue 的按钮同步隐藏)。
watch(
() => userStore.isAuthed && !userStore.demoMode,
(available) => {
if (available) collabStore.connect()
else collabStore.disconnect()
},
{ immediate: true },
)
// 延迟加载 highlight.js避免阻塞首屏
const hljsInstance = ref<any>(null)
const loadHighlightJS = async () => {

View File

@@ -6,7 +6,6 @@ interface ImportMetaEnv {
readonly PUBLIC_CODE_URL: string
readonly PUBLIC_JUDGE0_URL: string
readonly PUBLIC_ICONIFY_URL: string
readonly PUBLIC_SIGNALING_URL: string
}
interface ImportMeta {

View File

@@ -1,73 +0,0 @@
import { ref, provide, inject } from "vue"
/**
* 同步状态管理 composable
* 使用 provide/inject 模式在组件树中共享状态
*/
export interface SyncStatusState {
hadConnection: boolean
otherUser?: { name: string; isSuperAdmin: boolean }
lastLeftUser?: { name: string; isSuperAdmin: boolean } // 保存离开之人的信息
}
// 提供/注入的 key
export const SYNC_STATUS_KEY = Symbol("syncStatus")
/**
* 创建同步状态实例
* 每次调用创建新的状态实例
*/
export function createSyncStatus() {
const otherUser = ref<{ name: string; isSuperAdmin: boolean }>()
const hadConnection = ref(false)
const lastLeftUser = ref<{ name: string; isSuperAdmin: boolean }>()
const setOtherUser = (user?: { name: string; isSuperAdmin: boolean }) => {
// 如果之前有其他用户,现在没有了,说明用户离开了
if (otherUser.value && !user) {
lastLeftUser.value = otherUser.value
}
otherUser.value = user
if (user) {
hadConnection.value = true
}
}
const reset = () => {
otherUser.value = undefined
hadConnection.value = false
lastLeftUser.value = undefined
}
return {
otherUser,
hadConnection,
lastLeftUser,
setOtherUser,
reset,
}
}
/**
* 提供同步状态到子组件
* 在父组件中调用
*/
export function provideSyncStatus() {
const syncStatus = createSyncStatus()
provide(SYNC_STATUS_KEY, syncStatus)
return syncStatus
}
/**
* 注入同步状态
* 在子组件中调用,获取父组件提供的状态
*/
export function injectSyncStatus() {
const syncStatus =
inject<ReturnType<typeof createSyncStatus>>(SYNC_STATUS_KEY)
if (!syncStatus) {
throw new Error("syncStatus must be provided by a parent component")
}
return syncStatus
}

View File

@@ -5,7 +5,6 @@ import { useProblemStore } from "oj/store/problem"
import { SOURCES } from "utils/constants"
import CodeEditor from "shared/components/CodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { provideSyncStatus } from "oj/composables/syncStatus"
import storage from "utils/storage"
import type { LANGUAGE } from "utils/types"
import Form from "./Form.vue"
@@ -18,10 +17,6 @@ const { problem } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
// 提供空的同步状态,避免 Form 组件注入错误
// 在竞赛模式下,同步功能会被 showSyncFeature 自动禁用
provideSyncStatus()
const contestID = route.params.contestID || null
const storageKey = computed(
() =>

View File

@@ -3,8 +3,7 @@ import { storeToRefs } from "pinia"
import { copyToClipboard, utoa } from "utils/functions"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { injectSyncStatus } from "oj/composables/syncStatus"
import { SYNC_MESSAGES } from "shared/composables/sync"
import { useCollabStore } from "shared/store/collab"
import {
ICON_SET,
LANGUAGE_FORMAT_VALUE,
@@ -27,17 +26,14 @@ const SubmitFlowchart = defineAsyncComponent(
interface Props {
storageKey: string
isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立)
}
const { storageKey, isConnected = false } = defineProps<Props>()
const { storageKey } = defineProps<Props>()
// 注入同步状态
const syncStatus = injectSyncStatus()
const collabStore = useCollabStore()
const emit = defineEmits<{
changeLanguage: [v: LANGUAGE]
toggleSync: [v: boolean]
}>()
const message = useMessage()
@@ -50,20 +46,36 @@ const { problem, languages } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
const statisticPanel = ref(false)
// 计算属性
const isContestMode = computed(() => route.name === "contest problem")
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
const showSyncFeature = computed(
// 可见条件沿用原来的 showSyncFeature,再加上「不是教师」——
// 教师端的入口在顶栏,不在题目页
const showHelpButton = computed(
() =>
isDesktop.value &&
userStore.isAuthed &&
!userStore.isTeacherOrAbove &&
// 演示模式下协作通道是断开的(见 App.vue按钮点了也没人收
!userStore.demoMode &&
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)
}
const showGoSubmissionButton = computed(() => {
if (isContestMode.value) return true
else if (userStore.isAdminRole) return true
@@ -191,17 +203,6 @@ const goEdit = () => {
window.open(router.resolve(url).href, "_blank")
}
const toggleSync = () => {
syncEnabled.value = !syncEnabled.value
emit("toggleSync", syncEnabled.value)
}
defineExpose({
resetSyncStatus: () => {
syncEnabled.value = false
},
})
onMounted(() => {
if (!languages.value.includes(codeStore.code.language)) {
// 回退到题目支持的第一种语言(如 SQL 题只有 "SQL",硬编码 Python3 会被后端拒绝)
@@ -250,31 +251,22 @@ onMounted(() => {
<n-button :size="buttonSize">更多操作</n-button>
</n-dropdown>
<template v-if="showSyncFeature">
<template v-if="showHelpButton">
<n-button
:size="buttonSize"
:type="syncEnabled ? 'warning' : 'default'"
@click="toggleSync"
:type="collabStore.helpStatus === 'idle' ? 'default' : 'warning'"
:disabled="collabStore.helpStatus === 'active'"
@click="toggleHelp"
>
{{ syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF }}
{{ helpButtonText }}
</n-button>
<!-- 同步状态标签 -->
<template v-if="isConnected">
<n-tag v-if="syncStatus.otherUser.value" type="info">
{{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }}
</n-tag>
<n-tag
v-if="
userStore.isSuperAdmin &&
!syncStatus.otherUser.value &&
syncStatus.hadConnection.value
"
type="warning"
>
{{ SYNC_MESSAGES.STUDENT_LEFT(syncStatus.lastLeftUser.value?.name) }}
</n-tag>
</template>
<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>
</n-flex>

View File

@@ -2,7 +2,6 @@
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { provideSyncStatus } from "oj/composables/syncStatus"
import { SOURCES } from "utils/constants"
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
@@ -15,7 +14,6 @@ const FlowchartEditor = defineAsyncComponent(
)
const route = useRoute()
const formRef = useTemplateRef<InstanceType<typeof Form>>("formRef")
const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
const codeStore = useCodeStore()
@@ -24,10 +22,6 @@ const { problem } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
const sync = ref(false)
// 提供同步状态给子组件使用
const syncStatus = provideSyncStatus()
const contestID = route.params.contestID || null
const storageKey = computed(
() =>
@@ -72,38 +66,13 @@ const changeLanguage = (v: LANGUAGE) => {
)
}
const toggleSync = (value: boolean) => {
sync.value = value
if (!value) {
syncStatus.reset()
}
}
const handleSyncClosed = () => {
sync.value = false
syncStatus.reset()
formRef.value?.resetSyncStatus()
}
const handleSyncStatusChange = (status: {
otherUser?: { name: string; isSuperAdmin: boolean }
}) => {
syncStatus.setOtherUser(status.otherUser)
}
// 提供FlowchartEditor的ref给子组件
provide("flowchartEditorRef", flowchartEditorRef)
</script>
<template>
<n-flex vertical>
<Form
ref="formRef"
:storage-key="storageKey"
:is-connected="sync"
@change-language="changeLanguage"
@toggle-sync="toggleSync"
/>
<Form :storage-key="storageKey" @change-language="changeLanguage" />
<FlowchartEditor
v-if="codeStore.code.language === 'Flowchart'"
ref="flowchartEditorRef"
@@ -111,13 +80,9 @@ provide("flowchartEditorRef", flowchartEditorRef)
<SyncCodeEditor
v-else
v-model:value="codeStore.code.value"
:sync="sync"
:problem="problem!._id"
:language="codeStore.code.language"
:height="editorHeight"
@update:model-value="changeCode"
@sync-closed="handleSyncClosed"
@sync-status-change="handleSyncStatusChange"
/>
</n-flex>
</template>

View File

@@ -121,7 +121,8 @@ onMounted(() => {
<n-descriptions-item label="创建时间">
{{ parseTime(problem.createTime) }}
</n-descriptions-item>
<n-descriptions-item label="难度">
<!-- 比赛进行中难度不下发difficulty null这一项整个不显示 -->
<n-descriptions-item v-if="problem.difficulty" label="难度">
<n-tag :type="getTagColor(problem.difficulty)">
{{ DIFFICULTY[problem.difficulty] }}
</n-tag>

View File

@@ -173,7 +173,9 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
key: "difficulty",
width: 100,
render: (row) =>
h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty),
row.difficulty
? h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty)
: null,
},
{
title: renderTableTitle("标签", "streamline-ultimate-color:attachment"),

View File

@@ -54,6 +54,7 @@ function handleProblemClick(problemId: string) {
<n-flex align="center" size="small">
<n-tag
v-if="problemSetProblem.problem.difficulty"
:type="getTagColor(problemSetProblem.problem.difficulty)"
size="small"
>

View File

@@ -8,7 +8,7 @@ export function filterResult(result: ProblemListItem): ProblemFiltered {
id: result.id,
_id: result._id,
title: result.title,
difficulty: DIFFICULTY[result.difficulty],
difficulty: result.difficulty ? DIFFICULTY[result.difficulty] : null,
tags: result.tags,
submission: result.submissionNumber,
rate: getACRate(result.acceptedNumber, result.submissionNumber),

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { cpp } from "@codemirror/lang-cpp"
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()
// shallowRef理由见 SyncCodeEditor.vueEditorView 是类实例ref() 的深度
// UnwrapRef 会把它拆成一个丢了原型方法的假类型vue-tsc 报莫名其妙的类型错。
const editorView = shallowRef<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 bind = (view: EditorView) => {
if (!collabStore.isTeacher || !collabStore.room) return
// ★ 教师端 seedContent 必须是 null —— 内容全部来自学生端
start({ editorView: view, seedContent: null })
}
/**
* 起点是「编辑器就绪」,不是「房间打开」。
*
* n-modal 默认 display-directive="if",每次打开都会重挂一个全新的 CodeMirror。
* 原来在 watch 里 `await nextTick()` 之后去取 editorView赶上 teleport + 离场
* 过渡没走完,取到的是上一轮那个已经 destroy 的 viewstart() 静默绑到死编辑器上,
* 老师对着空白框干等,服务端那边房间却是活的。
*/
const handleEditorReady = (payload: { view: EditorView }) => {
editorView.value = payload.view
bind(payload.view)
}
watch(
() => collabStore.room,
(room) => {
// 常规路径下开房时编辑器还没挂,这里是 null由 @ready 接手;
// 万一哪天 display-directive 改成 show编辑器常驻这条分支才起作用
if (room && collabStore.isTeacher) {
if (editorView.value) bind(editorView.value)
} else {
stop()
// 编辑器随模态框一起卸载了,留着这个引用下轮就会绑到一个死 view 上
editorView.value = null
}
},
)
onUnmounted(() => {
stop()
editorView.value = null
})
</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>
<!--
不绑 v-model这个编辑器的内容完全由 Yjs 文档接管
原来绑了一个跨会话不清的 code ref 模态框重挂时 CodeMirror 拿它当初始
文档 yCollab 只观察 ytext从不反过来用 ytext 覆盖编辑器于是上一个
学生的代码留在文档里新学生的内容作为 delta 插到位置 0两边的偏移从此
对不上教师和学生显示的是两份不同的文档
-->
<Codemirror
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>

View File

@@ -4,14 +4,34 @@ import { RouterLink } from "vue-router"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useAuthModalStore } from "shared/store/authModal"
import { useCollabStore } from "shared/store/collab"
import { useScreenModeStore } from "shared/store/screenMode"
import { logout } from "../api"
import CollabModal from "./CollabModal.vue"
import HelpRequestList from "./HelpRequestList.vue"
import { useConfigStore } from "../store/config"
import { useUserStore } from "../store/user"
import { trickOrTreat } from "utils/functions"
const userStore = useUserStore()
const configStore = useConfigStore()
const collabStore = useCollabStore()
const message = useMessage()
/**
* 课堂求助的一次性提示,统一在这里弹。
*
* 原来挂在题目页的 Form.vue 上:学生排着队切去看提交记录,老师这时候取消了
* 他的求助,那条「老师已取消你的求助」就永远没人消费。顶栏是全局的,放这儿
* 才收得全 —— 教师端的 error 提示(比如「请先退出当前协作」)同理。
*/
watch(
() => collabStore.noticeSeq,
() => {
const text = collabStore.consumeNotice()
if (text) message.info(text)
},
)
const authStore = useAuthModalStore()
const screenModeStore = useScreenModeStore()
const route = useRoute()
@@ -328,6 +348,7 @@ function handleMenuSelect(key: string) {
>
{{ screenMode }}
</n-button>
<HelpRequestList v-if="isDesktop && userStore.isTeacherOrAbove" />
<div v-if="userStore.isFinished">
<n-dropdown v-if="userStore.isAuthed" :options="options" size="large">
<n-button>
@@ -361,6 +382,14 @@ function handleMenuSelect(key: string) {
</template>
</n-button>
</n-flex>
<!--
挂在根 n-flex 内部而不是同级Header.vue 一旦变成多根 fragment
default.vue 里 `<Header class="header" />` 那个 class 就没有任何单一
根节点可以落地Vue 会报 "Extraneous non-props attributes" 警告并把它
整个丢弃header 行随之丢掉 `max-width: 2000px` 那条居中样式。
n-modal 默认 teleport 到 body塞在这里不影响它的实际渲染位置。
-->
<CollabModal v-if="userStore.isTeacherOrAbove" />
</n-flex>
</template>

View File

@@ -0,0 +1,98 @@
<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>

View File

@@ -3,7 +3,6 @@ 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 { EditorView } from "@codemirror/view"
import { Codemirror } from "vue-codemirror"
import {
autocompletion,
@@ -11,25 +10,20 @@ import {
completeAnyWord,
} from "@codemirror/autocomplete"
import type { Extension } from "@codemirror/state"
import type { EditorView } from "@codemirror/view"
import type { LANGUAGE } from "utils/types"
import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy"
import { styleTheme } from "shared/extensions/baseTheme"
import { useCodeSync, SYNC_ERROR_CODES } from "../composables/sync"
import { useBreakpoints } from "../composables/breakpoints"
import { enhanceCompletion } from "shared/extensions/autocompletion"
import { useCollabDoc } from "../composables/collabDoc"
import { useCollabStore } from "shared/store/collab"
const isDark = useDark()
interface EditorReadyPayload {
view: EditorView
state: any
container: HTMLElement
}
const collabStore = useCollabStore()
const { start, stop, getInitialExtension } = useCollabDoc()
interface Props {
sync: boolean
problem: string
language?: LANGUAGE
fontSize?: number
height?: string
@@ -38,8 +32,6 @@ interface Props {
}
const {
sync,
problem,
language = "Python3",
fontSize = 20,
height = "100%",
@@ -48,15 +40,6 @@ const {
} = defineProps<Props>()
const code = defineModel<string>("value")
const emit = defineEmits<{
syncClosed: []
syncStatusChange: [
status: { otherUser?: { name: string; isSuperAdmin: boolean } },
]
}>()
const { isDesktop } = useBreakpoints()
const langExtension = computed((): Extension => {
if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true })
@@ -75,68 +58,52 @@ const extensions = computed(() => [
getInitialExtension(),
])
const { startSync, stopSync, getInitialExtension } = useCodeSync()
const editorView = ref<EditorView | null>(null)
let cleanupSync: (() => void) | null = null
const cleanupSyncResources = () => {
if (cleanupSync) {
cleanupSync()
cleanupSync = null
}
stopSync()
interface EditorReadyPayload {
view: EditorView
}
const initSync = async () => {
if (!editorView.value || !problem || !isDesktop.value) return
// shallowRef不是 refCodeMirror 的 EditorView 是带 getter 的类实例,
// Vue 的 UnwrapRef 深度展开会把它结构化成一个丢了原型方法的假类型,
// vue-tsc 会报 "missing dispatchTransactions/_root/..." 这类莫名其妙的错。
// 项目里旧的 sync.ts 用的是裸变量,同一个道理,这里换成 shallowRef 规避。
const editorView = shallowRef<EditorView | null>(null)
cleanupSyncResources()
cleanupSync = await startSync({
problemId: problem,
editorView: editorView.value as EditorView,
onStatusChange: (status) => {
// 处理需要断开同步的情况
if (
(status.errorCode === SYNC_ERROR_CODES.SUPER_ADMIN_LEFT ||
status.errorCode === SYNC_ERROR_CODES.MISSING_SUPER_ADMIN) &&
!status.connected
) {
emit("syncClosed")
}
emit("syncStatusChange", { otherUser: status.otherUser })
},
})
const bind = (view: EditorView) => {
if (collabStore.isTeacher || !collabStore.room) return
// 学生端:当前编辑器内容就是内容源
start({ editorView: view, seedContent: view.state.doc.toString() })
}
const handleEditorReady = (payload: EditorReadyPayload) => {
editorView.value = payload.view as EditorView
if (sync) {
initSync()
}
editorView.value = payload.view
// 也从这里起:学生排队时切去看提交记录、老师在这期间接了单,
// 等他切回来时 room 早就非空了,只靠下面的 watch 是等不到的
bind(payload.view)
}
// 房间开了才建文档。学生点求助时什么都不做 —— 老师没来之前不该动他的编辑器。
// 只在学生端启用:这个组件挂在每个用户的题目页上(教师也不例外,
// ProblemEditor.vue 只按语言是不是 Flowchart 分支,不看角色),
// 教师接单同样会让 collabStore.room 非空 —— 如果这里不按角色收窄,
// 教师自己停在某道题的页面上接单时,这个组件会把**教师自己的编辑器内容**
// 当成种子插入文档,还会跟 CollabModal 抢 setBinaryHandler 这个单例槽位,
// 两者谁后调用谁把对方顶掉。教师端的协作只归 CollabModal 管。
watch(
() => sync,
(shouldSync) => {
if (shouldSync) {
initSync()
} else {
cleanupSyncResources()
}
() => collabStore.room,
(room) => {
if (room && !collabStore.isTeacher && editorView.value) bind(editorView.value)
else stop()
},
)
watch(
() => problem,
(newProblem, oldProblem) => {
if (newProblem !== oldProblem && sync) {
initSync()
}
},
)
onUnmounted(cleanupSyncResources)
onUnmounted(() => {
stop()
// 这个组件卸载意味着编辑器没了(切走页面、或者把语言切成流程图),
// CRDT 会话没法接着用:回来时只能新建 Y.Doc再拿编辑器内容当种子就会和
// 老师那份合并成重复文本。所以不是"悄悄把绑定拆了",而是明确结束协作 ——
// 否则老师那边模态框照开、字照敲,一个也到不了学生那里
if (collabStore.room && !collabStore.isTeacher) collabStore.leave()
})
</script>
<template>

View File

@@ -0,0 +1,176 @@
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
/**
* 会话代号。每次 start / stop 都推进一格。
*
* start 要 await 六个动态 import房间可能在这期间就关了机房首次加载
* y* 那几个 chunk 正是最慢的时候。stop() 先跑完的话它面对的是 doc === null
* 什么也拆不到;等 import 回来 start 的后半段照样建文档、装 binaryHandler、
* 往编辑器里挂 yCollab、还发一轮 SyncStep1 —— 给一个已经不存在的房间。
*/
let generation = 0
async function start({ editorView, seedContent }: StartOptions) {
// 已经有一份在跑就先收掉。重复 start 只可能来自时序竞争,叠加没有意义
if (doc) stop()
const myGeneration = ++generation
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"),
])
// 等 chunk 的这段时间里房间关了(或者又开了新的一轮),整个放弃
if (myGeneration !== generation) return
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() {
generation += 1
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 }
}

View File

@@ -1,412 +0,0 @@
import { useMessage } from "naive-ui"
import { useUserStore } from "../store/user"
import type { EditorView } from "@codemirror/view"
import { Compartment } from "@codemirror/state"
import type { WebrtcProvider } from "y-webrtc"
import type { Doc, Text } from "yjs"
// 常量定义
const SYNC_CONSTANTS = {
MAX_ROOM_USERS: 2,
AWARENESS_SYNC_DELAY: 500,
INIT_SYNC_TIMEOUT: 500,
SUPER_ADMIN_COLOR: "#ff6b6b",
REGULAR_USER_COLOR: "#4dabf7",
} as const
// 错误类型码
export const SYNC_ERROR_CODES = {
SUPER_ADMIN_LEFT: "SUPER_ADMIN_LEFT",
MISSING_SUPER_ADMIN: "MISSING_SUPER_ADMIN",
} as const
// 界面和通知文案
export const SYNC_MESSAGES = {
// 超管离开
SUPER_ADMIN_LEFT: (name: string) => `超管 ${name} 已离开`,
// 缺少超管
MISSING_SUPER_ADMIN: "协同编辑需要超管",
// 连接成功
SYNC_ACTIVE: "协同编辑已激活!",
// 连接断开
CONNECTION_LOST: "协同编辑已断开",
// 等待相关
WAITING_STUDENT: "正在等待学生加入...",
WAITING_ADMIN: "正在等待超管加入...",
// Form.vue 界面文案
SYNC_ON: "断开同步",
SYNC_OFF: "开启同步",
SYNCING_WITH: (name: string) => `${name} 同步中`,
STUDENT_LEFT: (name?: string) => (name ? `${name}已离开` : "可以关闭同步"),
} as const
// 类型定义
type SyncState = "waiting" | "active" | "error"
type SyncErrorCode = (typeof SYNC_ERROR_CODES)[keyof typeof SYNC_ERROR_CODES]
interface UserInfo {
name: string
isSuperAdmin: boolean
}
interface PeersEvent {
webrtcPeers: string[]
}
interface StatusEvent {
connected: boolean
}
interface SyncedEvent {
synced: boolean
}
interface SyncOptions {
problemId: string
editorView: EditorView
onStatusChange?: (status: SyncStatus) => void
}
export interface SyncStatus {
connected: boolean
roomUsers: number
canSync: boolean
message: string
error?: string
errorCode?: SyncErrorCode
otherUser?: UserInfo
}
/**
* 代码同步 composable
* 每次调用创建新的同步实例
*/
export function useCodeSync() {
const userStore = useUserStore()
const message = useMessage()
// 每次调用创建新的实例变量
let ydoc: Doc | null = null
let provider: WebrtcProvider | null = null
let ytext: Text | null = null
const collabCompartment = new Compartment()
let currentEditorView: EditorView | null = null
let lastSyncState: SyncState | null = null
let roomUserInfo = new Map<number, UserInfo>()
let hasShownSuperAdminLeftMessage = false
const updateStatus = (
status: SyncStatus,
onStatusChange?: (status: SyncStatus) => void,
) => {
onStatusChange?.(status)
}
const normalizeClientId = (clientId: number | string): number => {
return typeof clientId === "string" ? parseInt(clientId, 10) : clientId
}
const checkHasSuperAdmin = (awarenessStates: Map<number, any>): boolean => {
if (userStore.isSuperAdmin) return true
return Array.from(awarenessStates.values()).some(
(state) => state.user?.isSuperAdmin,
)
}
const getOtherUserInfo = (
awarenessStates: Map<number, any>,
): UserInfo | undefined => {
if (!provider) return undefined
const localClientId = provider.awareness.clientID
for (const [clientId, state] of awarenessStates) {
if (clientId !== localClientId && state.user) {
return {
name: state.user.name,
isSuperAdmin: state.user.isSuperAdmin,
}
}
}
return undefined
}
const checkIfSuperAdminLeft = (
removedClientIds: number[],
onStatusChange?: (status: SyncStatus) => void,
) => {
if (userStore.isSuperAdmin || hasShownSuperAdminLeftMessage) return
const superAdminInfo = removedClientIds
.map((id) => roomUserInfo.get(id))
.find((info) => info?.isSuperAdmin)
if (superAdminInfo) {
hasShownSuperAdminLeftMessage = true
const leftMessage = SYNC_MESSAGES.SUPER_ADMIN_LEFT(superAdminInfo.name)
updateStatus(
{
connected: false,
roomUsers: 0,
canSync: false,
message: leftMessage,
error: leftMessage,
errorCode: SYNC_ERROR_CODES.SUPER_ADMIN_LEFT,
},
onStatusChange,
)
message.warning(leftMessage)
stopSync()
}
}
const checkRoomPermissions = (
roomUsers: number,
onStatusChange?: (status: SyncStatus) => void,
) => {
const awarenessStates = provider?.awareness.getStates()
if (!awarenessStates) return
const hasSuperAdmin = checkHasSuperAdmin(awarenessStates)
const canSync = roomUsers === SYNC_CONSTANTS.MAX_ROOM_USERS && hasSuperAdmin
const otherUser = getOtherUserInfo(awarenessStates)
if (roomUsers === SYNC_CONSTANTS.MAX_ROOM_USERS && !hasSuperAdmin) {
if (lastSyncState === "error") return
updateStatus(
{
connected: false,
roomUsers,
canSync: false,
message: SYNC_MESSAGES.MISSING_SUPER_ADMIN,
error: SYNC_MESSAGES.MISSING_SUPER_ADMIN,
errorCode: SYNC_ERROR_CODES.MISSING_SUPER_ADMIN,
otherUser,
},
onStatusChange,
)
message.error(SYNC_MESSAGES.MISSING_SUPER_ADMIN)
lastSyncState = "error"
stopSync()
return
} else if (canSync) {
updateStatus(
{
connected: true,
roomUsers,
canSync: true,
message: SYNC_MESSAGES.SYNC_ACTIVE,
otherUser,
},
onStatusChange,
)
if (lastSyncState !== "active") {
message.success(SYNC_MESSAGES.SYNC_ACTIVE)
lastSyncState = "active"
}
} else {
updateStatus(
{
connected: true,
roomUsers,
canSync: false,
message:
roomUsers === 1
? SYNC_MESSAGES.WAITING_STUDENT
: SYNC_MESSAGES.WAITING_ADMIN,
otherUser,
},
onStatusChange,
)
lastSyncState = "waiting"
}
}
const setupContentSync = (
ytext: Text,
provider: WebrtcProvider,
savedContent: string,
) => {
let hasInitialized = false
const initTimeout = setTimeout(() => {
if (!hasInitialized && ytext.length === 0 && savedContent) {
ytext.insert(0, savedContent)
}
hasInitialized = true
}, SYNC_CONSTANTS.INIT_SYNC_TIMEOUT)
provider.on("synced", (event: SyncedEvent) => {
if (!event.synced || hasInitialized) return
clearTimeout(initTimeout)
if (ytext.length === 0 && savedContent) {
ytext.insert(0, savedContent)
}
hasInitialized = true
})
}
async function startSync(options: SyncOptions): Promise<() => void> {
const { problemId, editorView, onStatusChange } = options
if (!userStore.isAuthed) {
return () => {}
}
// 动态导入 yjs 相关模块
const [Y, { WebrtcProvider }, { yCollab }] = await Promise.all([
import("yjs"),
import("y-webrtc"),
import("y-codemirror.next"),
])
// 初始化文档和提供者
ydoc = new Y.Doc()
ytext = ydoc.getText("codemirror")
const roomName = `problem-${problemId}`
provider = new WebrtcProvider(roomName, ydoc, {
signaling: [import.meta.env.PUBLIC_SIGNALING_URL],
maxConns: 1,
filterBcConns: true,
})
// 监听连接状态
provider.on("status", (event: StatusEvent) => {
if (!event.connected) {
updateStatus(
{
connected: false,
roomUsers: 0,
canSync: false,
message: SYNC_MESSAGES.CONNECTION_LOST,
error: SYNC_MESSAGES.CONNECTION_LOST,
},
onStatusChange,
)
message.warning(SYNC_MESSAGES.CONNECTION_LOST)
}
})
// 监听用户加入/离开
provider.on("peers", (event: PeersEvent) => {
const roomUsers = event.webrtcPeers.length + 1
setTimeout(() => {
checkRoomPermissions(roomUsers, onStatusChange)
}, SYNC_CONSTANTS.AWARENESS_SYNC_DELAY)
})
// 监听 awareness 变化
provider.awareness.on("change", (changes: any) => {
if (!provider) return
const awarenessStates = provider.awareness.getStates()
if (changes.removed?.length > 0) {
checkIfSuperAdminLeft(changes.removed, onStatusChange)
}
awarenessStates.forEach((state, clientId) => {
if (state.user) {
const normalizedId = normalizeClientId(clientId)
roomUserInfo.set(normalizedId, {
name: state.user.name,
isSuperAdmin: state.user.isSuperAdmin,
})
}
})
checkRoomPermissions(awarenessStates.size, onStatusChange)
})
// 配置编辑器扩展
if (editorView && ytext) {
currentEditorView = editorView
const userColor = userStore.isSuperAdmin
? SYNC_CONSTANTS.SUPER_ADMIN_COLOR
: SYNC_CONSTANTS.REGULAR_USER_COLOR
const userName = userStore.user?.username || "匿名用户"
const savedContent = editorView.state.doc.toString()
// 设置用户信息
provider.awareness.setLocalStateField("user", {
name: userName,
color: userColor,
isSuperAdmin: userStore.isSuperAdmin,
})
// 清空编辑器并应用协同扩展
editorView.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: "" },
})
const collabExt = yCollab(ytext, provider.awareness)
editorView.dispatch({
effects: collabCompartment.reconfigure(collabExt),
})
// 设置内容同步
setupContentSync(ytext, provider, savedContent)
// 设置初始状态
const waitingMessage = userStore.isSuperAdmin
? SYNC_MESSAGES.WAITING_STUDENT
: SYNC_MESSAGES.WAITING_ADMIN
updateStatus(
{
connected: true,
roomUsers: 1,
canSync: false,
message: waitingMessage,
},
onStatusChange,
)
message.info(waitingMessage)
lastSyncState = "waiting"
}
return () => stopSync()
}
function stopSync() {
if (currentEditorView) {
try {
currentEditorView.dispatch({
effects: collabCompartment.reconfigure([]),
})
} catch (error) {
console.warn("移除协同编辑扩展失败:", error)
}
currentEditorView = null
}
provider?.disconnect()
provider?.destroy()
ydoc?.destroy()
provider = null
ydoc = null
ytext = null
lastSyncState = null
roomUserInfo.clear()
hasShownSuperAdminLeftMessage = false
}
function getInitialExtension() {
return collabCompartment.of([])
}
return {
startSync,
stopSync,
getInitialExtension,
}
}

View File

@@ -140,6 +140,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
// 迟到的 onclose / onerror 不该去改现在这条连接的状态
const ws = new WebSocket(this.url)
this.ws = ws
ws.binaryType = "arraybuffer"
ws.onopen = () => {
if (ws !== this.ws) return
@@ -154,6 +155,14 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
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
@@ -330,6 +339,24 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
return false
}
/**
* 二进制帧钩子。基类不认二进制默认丢弃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
}
/**
* 添加消息处理器
*/
@@ -682,3 +709,93 @@ export function useConfigWebSocket(handler?: MessageHandler<ConfigUpdate>) {
removeHandler: (h: MessageHandler<ConfigUpdate>) => ws.removeHandler(h),
}
}
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
private connectHandler: (() => void) | null = null
/**
* 还没装 handler 时先收着的二进制帧。
*
* room_open 是两端同时收到的,但两端把 yCollab 挂上去的时刻并不同步:
* 教师端要等模态框挂出 CodeMirror学生端要等 y* 那几个 chunk 下载完。
* 谁先挂好谁就先发 SyncStep1而对面这时还没有 handler ——
* 服务端只管转发peer.send() 是成功的),帧就在客户端这儿被静静丢掉了。
*
* 丢的偏偏是握手y-protocols 里 A 的内容是靠 **B 发的 Step1** 换回来的。
* 教师的 Step1 一丢,学生的代码就永远到不了教师那边 —— 教师看到空编辑器,
* 自己敲的字倒是能同步过去,看着像在协作,实际上只有单向。
*
* 所以在这儿缓一手,等 setBinaryHandler 装上再按序放行。
*/
private pendingBinary: ArrayBuffer[] = []
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
if (!handler) {
// 协作结束:攒下的帧属于上一轮,放到下一轮去只会污染新文档
this.pendingBinary = []
return
}
const buffered = this.pendingBinary
this.pendingBinary = []
for (const data of buffered) handler(data)
}
/**
* 每次连接**建立**都触发,含重连 —— 不止首次 connect()。用来在重连瞬间
* 清掉本地缓存的求助/房间状态:旧连接期间的 pending/active 可能早就过时了,
* 服务端会在 handleCollabOpen 里紧接着补发 requests老师或 help_status
* (还在排队/协作中的学生),补发落地前先归零,好过让过时状态活过一次重连。
*/
setConnectHandler(handler: (() => void) | null) {
this.connectHandler = handler
}
/** 一轮握手也就几帧,给个上限纯粹是防呆:真堆到这个数说明哪里不对 */
private static readonly MAX_PENDING_BINARY = 64
protected override onBinary(data: ArrayBuffer) {
if (this.binaryHandler) {
this.binaryHandler(data)
return
}
if (this.pendingBinary.length < CollabWebSocket.MAX_PENDING_BINARY) {
this.pendingBinary.push(data)
}
}
protected override onConnected() {
// 新连接,旧连接攒下的帧一概作废
this.pendingBinary = []
this.connectHandler?.()
}
}

View File

@@ -0,0 +1,209 @@
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)
/** 一次性提示,由 Header 统一消费后清空 */
const notice = ref("")
/**
* 提示序号,每次设置都自增。
*
* 消费方 watch 的是这个,不是 notice 本身:连着两次同样的文案(老师取消了
* 求助、学生又求助、又被取消)在 Vue 眼里 `===` 相等watch(notice) 不会
* 第二次触发,第二条提示就这么没了。
*/
const noticeSeq = ref(0)
function setNotice(text: string) {
notice.value = text
noticeSeq.value += 1
}
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"
setNotice("老师已取消你的求助")
} else if (data.status === "no_teacher") {
helpStatus.value = "idle"
setNotice("当前没有老师在线")
}
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":
// 不管 reason 一律先归位到 idle学生这一侧的 socket 发送失败时,
// 服务端把它从请求表里摘掉却**发不出**任何纠正性的 help_status
// (那正是刚失败的那条 socket不这样兜底 store 会卡在陈旧的
// active/pending 上再也回不来。老师掉线的情况服务端会紧接着另发一条
// help_status:pending——teardownRoom 里 room_closed 先发、
// requeueAfterTeacherGone 后发,同一条连接上消息严格按发送顺序到达,
// 这里先归零,那条 pending 补发会立刻把它纠正回来,不会被这次重置盖掉
room.value = null
helpStatus.value = "idle"
setNotice(
data.reason === "peer_offline" ? "对方已断开连接" : "协作已结束",
)
return
case "error":
setNotice(String(data.message ?? ""))
return
}
}
ws.addHandler(handleMessage)
// 每次连接**建立**都清一遍本地状态,不止首次 connect() —— 重连(掉线重连、
// API 重启后的自动重连)同样会触发。旧连接期间的 pending/active/requests
// 可能早就过时了:老师端等服务端在 handleCollabOpen 里重新推 requests 补齐;
// 学生端等服务端补发的 help_status 补齐(真在排队/协作中会被立刻纠正回来),
// 不该让上一条连接的陈旧状态越过重连活下来
ws.setConnectHandler(() => {
requests.value = []
helpStatus.value = "idle"
queueAhead.value = 0
teacherName.value = ""
room.value = null
})
function connect() {
ws.connect()
}
function disconnect() {
ws.disconnect()
requests.value = []
helpStatus.value = "idle"
queueAhead.value = 0
teacherName.value = ""
room.value = null
notice.value = ""
}
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,
noticeSeq,
isTeacher: computed(() => userStore.isTeacherOrAbove),
connect,
disconnect,
requestHelp,
cancelHelp,
accept,
reject,
leave,
sendBinary,
setBinaryHandler,
consumeNotice,
}
})

View File

@@ -176,7 +176,8 @@ export interface ProblemFiltered {
_id: string
id: number
title: string
difficulty: "简单" | "中等" | "困难"
// 比赛进行中难度不下发,见 contract 的 maskedProblemDifficultySchema
difficulty: "简单" | "中等" | "困难" | null
tags: string[]
submission: number
rate: string

View File

@@ -67,6 +67,7 @@
"date-fns": "^4.4.0",
"fflate": "^0.8.3",
"highlight.js": "^11.12.0",
"lib0": "0.2.117",
"md-editor-v3": "^6.5.6",
"mermaid": "^11.17.2",
"mermaid-legacy": "npm:mermaid@^9.4.3",
@@ -80,7 +81,7 @@
"vue-codemirror": "^6.1.1",
"vue-router": "^5.2.0",
"y-codemirror.next": "^0.3.6",
"y-webrtc": "^10.3.0",
"y-protocols": "1.0.7",
"yjs": "^13.6.32",
},
"devDependencies": {
@@ -822,8 +823,6 @@
"babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "https://registry.npmjs.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="],
"base64-js": ["base64-js@1.5.1", "https://registry.npmjs.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.12", "https://registry.npmjs.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA=="],
"birpc": ["birpc@2.9.0", "https://registry.npmjs.com/birpc/-/birpc-2.9.0.tgz", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
@@ -832,8 +831,6 @@
"browserslist-to-esbuild": ["browserslist-to-esbuild@2.1.1", "https://registry.npmjs.com/browserslist-to-esbuild/-/browserslist-to-esbuild-2.1.1.tgz", { "dependencies": { "meow": "^13.0.0" }, "peerDependencies": { "browserslist": "*" }, "bin": { "browserslist-to-esbuild": "cli/index.js" } }, "sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw=="],
"buffer": ["buffer@6.0.3", "https://registry.npmjs.com/buffer/-/buffer-6.0.3.tgz", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-from": ["buffer-from@1.1.2", "https://registry.npmjs.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bullmq": ["bullmq@6.2.2", "https://registry.npmjs.com/bullmq/-/bullmq-6.2.2.tgz", { "dependencies": { "cron-parser": "5.10.0", "msgpackr": "2.0.5", "node-abort-controller": "3.1.1", "semver": "7.8.5", "tslib": "2.8.1" }, "peerDependencies": { "bullmq-otel": ">=2.0.0", "ioredis": ">=5.0.0", "pg": ">=8.0.0", "redis": ">=5.0.0" }, "optionalPeers": ["bullmq-otel", "ioredis", "pg", "redis"] }, "sha512-dwpI14djPYpG15C6Wc7c14/rw8zXXbsGWRgVLR/x8TUi/UBl5+WI6xiBVQH1jnxa3Rdf+T7nylqNui0gysMqQg=="],
@@ -992,8 +989,6 @@
"entities": ["entities@4.5.0", "https://registry.npmjs.com/entities/-/entities-4.5.0.tgz", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"err-code": ["err-code@3.0.1", "https://registry.npmjs.com/err-code/-/err-code-3.0.1.tgz", {}, "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA=="],
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmjs.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "https://registry.npmjs.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
@@ -1046,8 +1041,6 @@
"gensync": ["gensync@1.0.0-beta.2", "https://registry.npmjs.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-browser-rtc": ["get-browser-rtc@1.1.0", "https://registry.npmjs.com/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz", {}, "sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmjs.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "https://registry.npmjs.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
@@ -1078,12 +1071,8 @@
"iconv-lite": ["iconv-lite@0.6.3", "https://registry.npmjs.com/iconv-lite/-/iconv-lite-0.6.3.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ieee754": ["ieee754@1.2.1", "https://registry.npmjs.com/ieee754/-/ieee754-1.2.1.tgz", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "https://registry.npmjs.com/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
"inherits": ["inherits@2.0.4", "https://registry.npmjs.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"internmap": ["internmap@2.0.3", "https://registry.npmjs.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ioredis": ["ioredis@6.0.0", "https://registry.npmjs.com/ioredis/-/ioredis-6.0.0.tgz", { "dependencies": { "@ioredis/commands": "2.0.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "standard-as-callback": "2.1.0" } }, "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w=="],
@@ -1274,12 +1263,6 @@
"quansync": ["quansync@0.2.11", "https://registry.npmjs.com/quansync/-/quansync-0.2.11.tgz", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
"queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmjs.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"randombytes": ["randombytes@2.1.0", "https://registry.npmjs.com/randombytes/-/randombytes-2.1.0.tgz", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
"readable-stream": ["readable-stream@3.6.2", "https://registry.npmjs.com/readable-stream/-/readable-stream-3.6.2.tgz", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@5.1.1", "https://registry.npmjs.com/readdirp/-/readdirp-5.1.1.tgz", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
"redis-errors": ["redis-errors@1.2.0", "https://registry.npmjs.com/redis-errors/-/redis-errors-1.2.0.tgz", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
@@ -1310,8 +1293,6 @@
"rw": ["rw@1.3.3", "https://registry.npmjs.com/rw/-/rw-1.3.3.tgz", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "https://registry.npmjs.com/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmjs.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "https://registry.npmjs.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
@@ -1322,8 +1303,6 @@
"semver": ["semver@7.8.5", "https://registry.npmjs.com/semver/-/semver-7.8.5.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"simple-peer": ["simple-peer@9.11.1", "https://registry.npmjs.com/simple-peer/-/simple-peer-9.11.1.tgz", { "dependencies": { "buffer": "^6.0.3", "debug": "^4.3.2", "err-code": "^3.0.1", "get-browser-rtc": "^1.1.0", "queue-microtask": "^1.2.3", "randombytes": "^2.1.0", "readable-stream": "^3.6.0" } }, "sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw=="],
"skulpt": ["skulpt@1.2.0", "https://registry.npmjs.com/skulpt/-/skulpt-1.2.0.tgz", { "dependencies": { "jsbi": "^3.1.4" } }, "sha512-T0cv0sdSOXLlIJTuyXSeYJ3TFdWYSZfX2PcBLpRKrKZ3dTbVQXRMiYudSuko2xzcyMif47DS2ShatCwjCbsSsA=="],
"slate": ["slate@0.124.1", "https://registry.npmjs.com/slate/-/slate-0.124.1.tgz", {}, "sha512-ii7DwezgvbLAyKtHBIunjTR1kzbNfYLCUKLMzJELlbTZkvHzX4DzN7HKIwcakf6dPxO6AoeT/P7kHOcyTym/hA=="],
@@ -1346,8 +1325,6 @@
"strictdom": ["strictdom@1.0.1", "https://registry.npmjs.com/strictdom/-/strictdom-1.0.1.tgz", {}, "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg=="],
"string_decoder": ["string_decoder@1.3.0", "https://registry.npmjs.com/string_decoder/-/string_decoder-1.3.0.tgz", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-literal": ["strip-literal@4.0.0", "https://registry.npmjs.com/strip-literal/-/strip-literal-4.0.0.tgz", { "dependencies": { "js-tokens": "^10.0.0" } }, "sha512-PaqAvfUZKBwc/SLmNZtHmzK+v19Z4O4eS3cKPeGvbIv/U3pnyEq4Tuw3/4v/FwfM8VQaEawsyCcOQ0P+kpwWWw=="],
"style-mod": ["style-mod@4.1.3", "https://registry.npmjs.com/style-mod/-/style-mod-4.1.3.tgz", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
@@ -1366,7 +1343,7 @@
"tree-sitter-c": ["tree-sitter-c@0.24.1", "https://registry.npmjs.com/tree-sitter-c/-/tree-sitter-c-0.24.1.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-lkYwWN3SRecpvaeqmFKkuPNR3ZbtnvHU+4XAEEkJdrp3JfSp2pBrhXOtvfsENUneye76g889Y0ddF2DM0gEDpA=="],
"tree-sitter-cpp": ["tree-sitter-cpp@0.23.4", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2", "tree-sitter-c": "^0.23.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw=="],
"tree-sitter-cpp": ["tree-sitter-cpp@0.23.4", "https://registry.npmjs.com/tree-sitter-cpp/-/tree-sitter-cpp-0.23.4.tgz", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2", "tree-sitter-c": "^0.23.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw=="],
"tree-sitter-python": ["tree-sitter-python@0.25.0", "https://registry.npmjs.com/tree-sitter-python/-/tree-sitter-python-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw=="],
@@ -1408,8 +1385,6 @@
"update-browserslist-db": ["update-browserslist-db@1.3.0", "https://registry.npmjs.com/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA=="],
"util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmjs.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@14.0.1", "https://registry.npmjs.com/uuid/-/uuid-14.0.1.tgz", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
"vdirs": ["vdirs@0.1.8", "https://registry.npmjs.com/vdirs/-/vdirs-0.1.8.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw=="],
@@ -1444,16 +1419,12 @@
"wildcard": ["wildcard@1.1.2", "https://registry.npmjs.com/wildcard/-/wildcard-1.1.2.tgz", {}, "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng=="],
"ws": ["ws@8.21.2", "https://registry.npmjs.com/ws/-/ws-8.21.2.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="],
"xss": ["xss@1.0.15", "https://registry.npmjs.com/xss/-/xss-1.0.15.tgz", { "dependencies": { "commander": "^2.20.3", "cssfilter": "0.0.10" }, "bin": { "xss": "bin/xss" } }, "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg=="],
"y-codemirror.next": ["y-codemirror.next@0.3.6", "https://registry.npmjs.com/y-codemirror.next/-/y-codemirror.next-0.3.6.tgz", { "dependencies": { "lib0": "^0.2.42" }, "peerDependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "yjs": "^13.5.6" } }, "sha512-GnmVXhTe+UtoFbbaSdwhq6gdAQZdIY9az0Xj6HR2J4PO6Yw4w3xaaSssU+6T72WhXAI0wf9FbuY2s0Ms+WrexA=="],
"y-protocols": ["y-protocols@1.0.7", "https://registry.npmjs.com/y-protocols/-/y-protocols-1.0.7.tgz", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],
"y-webrtc": ["y-webrtc@10.3.0", "https://registry.npmjs.com/y-webrtc/-/y-webrtc-10.3.0.tgz", { "dependencies": { "lib0": "^0.2.42", "simple-peer": "^9.11.0", "y-protocols": "^1.0.6" }, "optionalDependencies": { "ws": "^8.14.2" }, "peerDependencies": { "yjs": "^13.6.8" }, "bin": { "y-webrtc-signaling": "bin/server.js" } }, "sha512-KalJr7dCgUgyVFxoG3CQYbpS0O2qybegD0vI4bYnYHI0MOwoVbucED3RZ5f2o1a5HZb1qEssUKS0H/Upc6p1lA=="],
"yallist": ["yallist@3.1.1", "https://registry.npmjs.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.9.0", "https://registry.npmjs.com/yaml/-/yaml-2.9.0.tgz", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -1540,7 +1511,7 @@
"strip-literal/js-tokens": ["js-tokens@10.0.0", "https://registry.npmjs.com/js-tokens/-/js-tokens-10.0.0.tgz", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
"tree-sitter-cpp/tree-sitter-c": ["tree-sitter-c@0.23.6", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ=="],
"tree-sitter-cpp/tree-sitter-c": ["tree-sitter-c@0.23.6", "https://registry.npmjs.com/tree-sitter-c/-/tree-sitter-c-0.23.6.tgz", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ=="],
"tsx/esbuild": ["esbuild@0.28.1", "https://registry.npmjs.com/esbuild/-/esbuild-0.28.1.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,299 @@
# OJ2 设计文档:课堂求助与协作编辑
日期2026-08-28
状态:已实施
## 1. 背景
现有的「协同编辑」功能从 ojnext 原样搬进 `apps/web`commit `ae1fb32`,一行未改),
用 y-webrtc 做点对点同步,信令走仓库外的 `wss://signaling.xuyue.cc`
涉及文件:
| 文件 | 职责 |
|---|---|
| `apps/web/src/shared/composables/sync.ts` | Y.Doc + WebrtcProvider + yCollab房间权限状态机 |
| `apps/web/src/shared/components/SyncCodeEditor.vue` | CodeMirror 挂载点,`sync` prop 驱动启停 |
| `apps/web/src/oj/composables/syncStatus.ts` | provide/inject 的对端用户状态 |
| `apps/web/src/oj/problem/components/Form.vue` | 「开启同步 / 断开同步」按钮与状态标签 |
| `apps/web/src/oj/problem/components/ProblemEditor.vue` | 组装 |
| `apps/web/src/oj/problem/components/ContestEditor.vue` | 仅为避免 inject 抛错而空 provide |
后端对此功能零参与。
### 1.1 为什么要重做
本功能的实际用途是**课堂辅导**:学生做题卡住,老师进入他的编辑器一起改。
按这个用途重新审视,现有实现有四个结构性问题:
**a. 老师无法指定帮谁。** 房间名是 `problem-${problemId}`sync.ts:272
不含用户身份。一个班几十人做同一道题时,老师点「开启同步」进的是「第 N 题房间」,
和谁连上取决于谁先在房间里。这不是缺陷,是这个功能**没有实现「帮谁」这个概念**。
**b. 谁的代码保留是随机的。** `setupContentSync`sync.ts:230-249两端完全对称
各自等 500ms谁先发现 `ytext` 为空谁就把自己的内容插进去。老师接入可能覆盖学生
正在写的代码;两端同时超时还会把两份代码拼在一起。
**c. 权限判定是客户端自报,且拦不住。** `isSuperAdmin` 写在 awareness 里
sync.ts:344任何人都能在 devtools 里改。而且判定分支只覆盖
`roomUsers === 2 && !hasSuperAdmin`sync.ts:180—— 三人及以上落进 else 分支,
提示「正在等待超管加入」但 yCollab 扩展不会被摘掉,三个人一直在同步编辑。
即使判定命中,它也发生在扩展挂载、文档交换**之后**。
**d. 外部依赖无处可管。** `signaling.xuyue.cc``docker/` 下没有任何部署源
compose、Caddyfile、deploy.sh 均无),仓库里查不到它怎么起、怎么重启。
`.env.production` 之外的三个环境都指向内网 IP `10.13.114.114:8085`
另外 WebrtcProvider 未配置任何 ICE/TURNsync.ts:274-278跨 NAT 场景
只能依赖 simple-peer 默认的 Google STUN。
根因是同一件事:**服务端不知道谁是谁**,所以身份、权限、房间归属只能由客户端
自行声明,于是 sync.ts 里写了两百行自报身份 + 人数猜测的状态机,既不可靠也无约束力。
## 2. 目标与非目标
### 目标
- 学生可以就某道题发起求助;老师能看到求助列表并进入协作。
- 房间归属与权限由**服务端**判定,不再依赖客户端自报。
- 协作起点确定为**学生的代码**,不存在覆盖与竞态。
- 传输改走后端已有的 WebSocket 层,去掉 y-webrtc 与外部信令服务器。
### 非目标
- **不做「老师主动连线」**:老师不能在学生未求助时进入其编辑器。若将来要做,
需要额外的在线学生列表与学生端知情提示,属另一个设计。
- **不做「老师演示给全班看」**1 对 N 广播是另一套模型,本次不涉及。
- **不落库**:求助请求只在内存,不新增表、不动 drizzle 迁移。
- **不改判题、提交、比赛任何流程。**
- 比赛模式仍然不提供本功能(沿用 `Form.vue``isContestMode` 判断,并在服务端
一并拒绝比赛题的求助)。
## 3. 交互流程
```
学生 服务端 老师
│ │ │
├── help_request{problemId} ──►│ │
│ ├── requests{list} ─────────►│ 顶栏红点 +1
│◄── help_status{pending, ─────┤ │
│ queueAhead:2} │ │
│ │◄── accept{studentId} ──────┤ 点击某条
│◄── room_open{peer} ──────────┼── room_open{peer} ────────►│ 弹出协作模态框
│ │ │
│◄═══ Yjs 二进制帧(服务端按房间转发,不解析)═══════════════►│
│ │ │
│◄── room_closed{done} ────────┼◄── leave ──────────────────┤ 老师关闭模态框
```
### 3.1 多人同时求助
老师端是一个**排队列表**,不是弹窗。顶栏红点显示待处理数量,点开是列表。
- **按等待时长排序,但不强制先来先到。** 老师点谁就是谁 —— 上课时有的问题一句话
说清、有的要讲五分钟,强制 FIFO 只会碍事。
- **同题聚合**:同一道题有多人求助时,列表按题目分组显示(`第5题 冒泡排序 · 3人`
展开是该题下的学生。这本身是个教学信号:一道题堆了好几个人,说明该停下来全班讲,
而不是挨个救。
- **老师一次只进一个房间。** 其余请求继续排队,不受影响。
- **多老师在线时**,请求被接走后在其他老师列表里变灰并标注「李老师处理中」,不会撞车。
### 3.2 学生端状态
学生必须随时看到自己的处境,否则会反复点:
| 状态 | 学生看到 |
|---|---|
| `pending` | 「已求助,前面还有 N 人」 |
| `active` | 「X 老师正在帮你」 |
| `no_teacher` | 「当前没有老师在线」(点击求助时立即告知,不让他干等) |
| `cancelled` | 「老师已取消你的求助」,可重新求助 |
`queueAhead` = 比自己早创建、且仍为 `pending` 的请求数。`cancelled``no_teacher`
是推给学生的**瞬时通知**,不是服务端存储的状态 —— 请求在服务端只有 `pending`
`active` 两态,取消即从表中移除。
### 3.3 老师取消请求
老师可在列表里对某条点 **× 取消**,请求直接消失,不建立连线,学生收到
`cancelled` 提示。适用于:学生举手后自己想明白了没撤销、老师已当面讲过、乱点。
这与「帮完了」是两回事 —— 帮完是老师退出房间(`leave`),请求一并清除。
## 4. 服务端设计
### 4.1 复用现有 WebSocket 基建
后端只有一个 serve 进程(`main.ts` 单二进制 + 子命令;`docker/compose.debian.yml`
`oj-api` 一个容器,`oj-worker` 不跑 HTTP因此**内存态房间可行,不需要
Redis 同步**。进程重启丢掉全部房间,两端重连后回到干净状态。
新增通道 `/ws/collab`,沿用现有结构:
- `apps/api/src/index.ts:103` 的 upgrade 分支加一条路径,握手时校验 origin 与会话,
`userId` / `token` 放进 `ws.data`
- `SubmissionSocketData.kind` 增加 `"collab"`(该字段的存在理由就是「同一个
`Bun.serve` 只能挂一个 websocket handler用它区分通道」
- 会话中途失效踢人(`sweepSessions`)、`touchSession` 复验、账号禁用检查全部白拿。
新增目录 `apps/api/src/collab/`
| 文件 | 职责 |
|---|---|
| `state.ts` | 内存状态:求助表、房间表、在线老师集合 |
| `handler.ts` | collab 通道的消息处理与广播 |
### 4.2 限流必须分档(关键坑)
现有令牌桶是 20 突发 / 每秒 2 个(`websocket.ts:69-81`),且 `allowMessage`
消息类型判断**之前**就拦截,超额直接 `ws.close(1008, "Too many messages")`
协作编辑时每敲一个字就是一条 Yjs 帧,几秒钟就会把连接踢掉。
**方案**collab 通道分两档 ——
- **二进制帧**Yjs 数据)走宽松档。它不查库、不解析,纯内存转发,成本极低。
- **文本控制帧**`help_request` / `accept` 等)沿用严格档。它们会查库。
起始阈值:二进制帧 200 突发 / 每秒 100 个(连续快速输入约 5-10 帧/秒,余量十倍以上),
文本帧沿用 20 / 2。实测再调。
### 4.3 消息协议
控制面是文本 JSON数据面是二进制帧Yjs update / awareness服务端不解析
**客户端 → 服务端**
| 消息 | 发送方 | 说明 |
|---|---|---|
| `{type:"help_request", problemId}` | 学生 | 发起求助 |
| `{type:"help_cancel"}` | 学生 | 撤销自己的求助 |
| `{type:"accept", studentId}` | 老师 | 接单 |
| `{type:"reject", studentId}` | 老师 | 取消某条请求 |
| `{type:"leave"}` | 双方 | 退出房间 |
| `{type:"ping"}` | 双方 | 沿用现有心跳,不查库 |
| 二进制帧 | 房间内双方 | 转发给房间里的另一个人 |
**服务端 → 客户端**
| 消息 | 接收方 | 说明 |
|---|---|---|
| `{type:"requests", list}` | 所有在线老师 | 全量列表(表很小,不做增量) |
| `{type:"help_status", status, queueAhead?, teacherName?}` | 发起求助的学生 | 见 3.2 |
| `{type:"room_open", peer:{name, role}, problemId}` | 房间内双方 | 建立协作 |
| `{type:"room_closed", reason}` | 房间内双方 | `done`(老师主动结束)/ `peer_offline`(对方断线) |
| `{type:"pong", timestamp}` | 双方 | 心跳应答 |
| 二进制帧 | 房间内另一方 | 转发 |
### 4.4 权限
- `accept` / `reject` 由服务端查库校验发送者是 `isTeacherOrAbove`**不读客户端
声明的任何身份字段**。注意用真实身份判定,不受前端演示模式影响
`user.ts:43``isSuperAdmin``!demoMode`,那是纯 UI 概念,服务端不认)。
- 二进制帧只转发给**同一房间的另一个成员**,不做任何广播。
- 比赛题的 `help_request` 服务端直接拒绝。
- 一个学生同时只有一个求助;一个老师同时只在一个房间。
### 4.5 内存状态
```
requests: Map<studentId, {
studentId, username, className, problemId, problemTitle,
createdAt, status: "pending" | "active", teacherId?
}>
rooms: Map<studentId, { studentSocket, teacherSocket, problemId }>
teacherSockets: Set<socket> // 在线老师,用于推 requests 与判断 no_teacher
```
房间以**学生**为键 —— 学生是房间的归属者,这与「学生的代码是内容源」是同一件事。
## 5. 前端设计
### 5.1 新增
| 文件 | 职责 |
|---|---|
| `shared/store/collab.ts` | pinia store持有 WS 连接、求助列表(老师)、自身求助状态(学生)、当前房间 |
| `shared/components/HelpRequestList.vue` | 顶栏红点 + 下拉列表,含同题聚合 |
| `shared/components/CollabModal.vue` | 老师端协作模态框 |
| `shared/composables/collab.ts` | Y.Doc 与 yCollab 绑定,取代 `sync.ts` |
### 5.2 改动
| 文件 | 改动 |
|---|---|
| `App.vue` | 登录后挂载全局 collab 连接(照 `useConfigUpdate()` 的写法) |
| `Header.vue` | 嵌入 `HelpRequestList`,仅老师及以上可见 |
| `Form.vue` | 「开启同步 / 断开同步」按钮改为「求助 / 取消求助」。可见条件沿用现有 `showSyncFeature` 的三条(桌面端、已登录、非流程图、非比赛),再加上「非教师角色」 |
| `SyncCodeEditor.vue` | 改为消费新的 collab composable |
| `ProblemEditor.vue` | 去掉 syncStatus 的 provide/inject改读 store |
### 5.3 删除
- `shared/composables/sync.ts`
- `oj/composables/syncStatus.ts`
- `ContestEditor.vue` 里为避免 inject 抛错而写的空 `provideSyncStatus()`
- `y-webrtc` 依赖(`apps/web/package.json`
- `PUBLIC_SIGNALING_URL``.env` / `.env.production` / `.env.staging` / `.env.test`
/ `env.d.ts` / `apps/web/CLAUDE.md` 环境变量表
保留 `yjs``y-codemirror.next` —— CRDT 与光标显示继续用它们。
### 5.4 老师在模态框里编辑,不跳转页面
老师接单后弹出一个大模态框,内含协作编辑器,标题显示学生名、题号与题面链接。
理由:顶栏是全局的,老师接单时可能正在后台改题或看统计,跳转会丢掉他的上下文;
且老师自己题目页里的代码不会被搅乱。
## 6. 内容源规则(硬性)
学生点「求助」时**不做任何事** —— 不清空编辑器、不建 Y.Doc、不连房间。
只有老师接单、`room_open` 到达之后才:
1. **学生端**:以当前编辑器内容建 Y.Doc 并插入,挂 yCollab。
2. **老师端**:建空 Y.Doc 挂 yCollab等学生端同步过来。
3. **老师端永远不插入初始内容。**
第 3 条是硬规则,不是默认值。它根治了 1.1(b)。
房间关闭时两端摘掉 yCollab 扩展,**学生编辑器保留当前内容** —— 老师帮改的代码留在
学生那里,这是期望行为。学生端照常写 localStorage。
## 7. 边界与失败处理
| 情况 | 处理 |
|---|---|
| 老师断线 | 房间立即销毁,请求退回 `pending` 重新排队,学生不必重新点(可能只是网络抖动);学生端摘掉 yCollab编辑器保留当前内容 |
| 学生断线 / 关页面 | 房间销毁、请求清除,老师端收到 `peer_offline` |
| API 重启 | 内存全清;两端 WS 重连后回到干净状态,学生需重新求助 |
| 没有老师在线 | 学生点求助时当场返回 `no_teacher` |
| 老师尝试进已被接走的请求 | 服务端拒绝,返回最新列表 |
| 会话失效 / 账号禁用 | 沿用现有 `sweepSessions``touchSession` 路径断连 |
## 8. 验证
项目约定不写测试,验证靠实跑。
`bun run dev` 起 api + worker + web开两个浏览器 profile一个学生账号、一个教师
账号),走完整流程:
1. 学生求助 → 老师顶栏出现红点与列表项
2. 多个学生求助 → 排序、同题聚合、等待时长显示正确
3. 老师接单 → 双方进入房间,学生代码出现在老师模态框,两端互相看得到光标
4. 双向编辑 → 内容一致,无覆盖
5. 老师退出 → 学生编辑器保留改后的代码
6. 老师取消某条请求 → 该学生收到提示
7. 断线场景:学生关页面、老师关页面、重启 api
8. 无老师在线时求助 → 学生立即收到提示
9. 比赛题不出现求助按钮,且直接构造 `help_request` 被服务端拒绝
## 9. 遗留与后续
- 求助不落库,因此没有「谁经常卡住、卡在哪道题」的历史统计。若将来需要教学反馈,
再加表不迟,本次刻意不做。
- 「老师主动连线」与「老师演示给全班看」见 2. 非目标,各自需要独立设计。
- `apps/web/CLAUDE.md` 整份仍是 ojnext 时代的内容npm 命令、指向 `../OnlineJudge`
的 Django 后端其中「Yjs + y-webrtc for collaborative editing **in the flowchart
editor**」一句本就是错的(协作在代码编辑器,流程图被显式排除)。本次至少要把
实时特性与环境变量两节改对,整份文档的翻新另计。

View File

@@ -9,6 +9,16 @@ import { paginatedSchema, sampleUserSchema } from "./common"
*/
export const problemDifficultySchema = z.enum(["Low", "Mid", "High"])
/**
* 题目详情/列表里的难度。**可为 null** —— 比赛进行中、看的人又不是管理员时,
* 难度和提交数一样属于「先别告诉参赛者」的信息(旧 Django 的
* `ProblemSafeSerializer` 直接把 difficulty 放进 exclude字段整个不下发
*
* 端上必须当「没有」处理,不要拿它去查 DIFFICULTY 映射表 —— 这正是
* problemDifficultySchema 写成严格枚举要防的那件事。
*/
export const maskedProblemDifficultySchema = problemDifficultySchema.nullable()
/**
* SQL 题配置与展示数据。两者都是 `problem.sql_config` / `problem.sql_display`
* 的 **JSONB 原文**,所以键名保持 snake_case —— 生产库 9 道 SQL 题存的就是这个形状
@@ -292,7 +302,7 @@ export const problemDetailSchema = z.object({
lastUpdateTime: z.string().nullable(),
timeLimit: z.number().int(),
memoryLimit: z.number().int(),
difficulty: problemDifficultySchema,
difficulty: maskedProblemDifficultySchema,
source: z.string().nullable(),
prompt: z.string().nullable(),
submissionNumber: z.number().int(),
@@ -327,7 +337,7 @@ export const problemListItemSchema = z.object({
title: z.string(),
submissionNumber: z.number().int(),
acceptedNumber: z.number().int(),
difficulty: problemDifficultySchema,
difficulty: maskedProblemDifficultySchema,
createdBy: sampleUserSchema,
tags: z.array(z.string()),
contestId: z.number().int().nullable(),