Compare commits
20 Commits
f38444c97a
...
ffabcd4a0d
| Author | SHA1 | Date | |
|---|---|---|---|
| ffabcd4a0d | |||
| 4abaf9c7e4 | |||
| 66a564710f | |||
| bb33e0f0e5 | |||
| 4b6242ba70 | |||
| c7132025f7 | |||
| de4e745d90 | |||
| d1fc6349b7 | |||
| 86857dedf0 | |||
| bfe4468fdb | |||
| 6f873be367 | |||
| ab8fcc2d42 | |||
| 17aa69f8a7 | |||
| aef687bcfc | |||
| 457df1ef3c | |||
| b14639d890 | |||
| 1db2c49b87 | |||
| c04570b4ad | |||
| 59191c9433 | |||
| 275e70e23a |
446
apps/api/src/collab/handler.ts
Normal file
446
apps/api/src/collab/handler.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
113
apps/api/src/collab/state.ts
Normal file
113
apps/api/src/collab/state.ts
Normal 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()
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,守卫会整体短路,匿名的权限反而大于登录学生。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
1
apps/web/src/env.d.ts
vendored
1
apps/web/src/env.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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),
|
||||
|
||||
117
apps/web/src/shared/components/CollabModal.vue
Normal file
117
apps/web/src/shared/components/CollabModal.vue
Normal 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.vue:EditorView 是类实例,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 的 view,start() 静默绑到死编辑器上,
|
||||
* 老师对着空白框干等,服务端那边房间却是活的。
|
||||
*/
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
98
apps/web/src/shared/components/HelpRequestList.vue
Normal file
98
apps/web/src/shared/components/HelpRequestList.vue
Normal 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>
|
||||
@@ -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,不是 ref:CodeMirror 的 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>
|
||||
|
||||
176
apps/web/src/shared/composables/collabDoc.ts
Normal file
176
apps/web/src/shared/composables/collabDoc.ts
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
/**
|
||||
* 课堂求助 / 协作通道。和另外两条的区别是它**双向**且**收发二进制** ——
|
||||
* 控制面是 JSON,Yjs 的 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?.()
|
||||
}
|
||||
}
|
||||
|
||||
209
apps/web/src/shared/store/collab.ts
Normal file
209
apps/web/src/shared/store/collab.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -176,7 +176,8 @@ export interface ProblemFiltered {
|
||||
_id: string
|
||||
id: number
|
||||
title: string
|
||||
difficulty: "简单" | "中等" | "困难"
|
||||
// 比赛进行中难度不下发,见 contract 的 maskedProblemDifficultySchema
|
||||
difficulty: "简单" | "中等" | "困难" | null
|
||||
tags: string[]
|
||||
submission: number
|
||||
rate: string
|
||||
|
||||
37
bun.lock
37
bun.lock
@@ -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=="],
|
||||
|
||||
|
||||
2009
docs/plans/2026-08-28-collab-help-request.md
Normal file
2009
docs/plans/2026-08-28-collab-help-request.md
Normal file
File diff suppressed because it is too large
Load Diff
299
docs/specs/2026-08-28-collab-help-request-design.md
Normal file
299
docs/specs/2026-08-28-collab-help-request-design.md
Normal 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/TURN(sync.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**」一句本就是错的(协作在代码编辑器,流程图被显式排除)。本次至少要把
|
||||
实时特性与环境变量两节改对,整份文档的翻新另计。
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user