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 |
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
+13
-2
@@ -100,19 +100,30 @@ const server = Bun.serve<SubmissionSocketData>({
|
|||||||
new Response("Not found", { status: 404 })
|
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)) {
|
if (!isAllowedWebSocketOrigin(request.headers.get("origin"), url)) {
|
||||||
return new Response("Forbidden", { status: 403 })
|
return new Response("Forbidden", { status: 403 })
|
||||||
}
|
}
|
||||||
const user = await getRequestSessionUser(request)
|
const user = await getRequestSessionUser(request)
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 })
|
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 (
|
if (
|
||||||
bunServer.upgrade(request, {
|
bunServer.upgrade(request, {
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
kind,
|
kind,
|
||||||
token: readRequestSessionToken(request),
|
token: readRequestSessionToken(request),
|
||||||
|
username: user.username,
|
||||||
|
adminType: user.adminType,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("
|
|||||||
title: problem.title,
|
title: problem.title,
|
||||||
submissionNumber: allowed ? problem.submissionNumber : 0,
|
submissionNumber: allowed ? problem.submissionNumber : 0,
|
||||||
acceptedNumber: allowed ? problem.acceptedNumber : 0,
|
acceptedNumber: allowed ? problem.acceptedNumber : 0,
|
||||||
difficulty: allowed ? problem.difficulty : "",
|
difficulty: allowed ? problem.difficulty : null,
|
||||||
createdBy: sampleUser(user, realName),
|
createdBy: sampleUser(user, realName),
|
||||||
tags: tags.get(problem.id) ?? [],
|
tags: tags.get(problem.id) ?? [],
|
||||||
contestId: contest.id,
|
contestId: contest.id,
|
||||||
@@ -179,7 +179,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
|
|||||||
lastUpdateTime: row.problem.lastUpdateTime,
|
lastUpdateTime: row.problem.lastUpdateTime,
|
||||||
timeLimit: row.problem.timeLimit,
|
timeLimit: row.problem.timeLimit,
|
||||||
memoryLimit: row.problem.memoryLimit,
|
memoryLimit: row.problem.memoryLimit,
|
||||||
difficulty: allowed ? row.problem.difficulty : "",
|
difficulty: allowed ? row.problem.difficulty : null,
|
||||||
source: row.problem.source,
|
source: row.problem.source,
|
||||||
prompt: row.problem.prompt,
|
prompt: row.problem.prompt,
|
||||||
submissionNumber: allowed ? row.problem.submissionNumber : 0,
|
submissionNumber: allowed ? row.problem.submissionNumber : 0,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function queryInteger(
|
|||||||
// 任何角色(助教、家长……)都会**默认拿到管理员权限**,包括 canViewSubmission 里的
|
// 任何角色(助教、家长……)都会**默认拿到管理员权限**,包括 canViewSubmission 里的
|
||||||
//「看所有人代码」。加角色的人多半想不到要回来改这里,白名单则会默认拒绝。
|
//「看所有人代码」。加角色的人多半想不到要回来改这里,白名单则会默认拒绝。
|
||||||
const ADMIN_ROLES = ["Student Admin", "Teacher Admin", "Super Admin"]
|
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) 这类「是普通用户才受限」的判断 ——
|
// 注意:不要再加 isRegularUser(user) 这类「是普通用户才受限」的判断 ——
|
||||||
// 匿名用户 user 为 null 时它返回 false,守卫会整体短路,匿名的权限反而大于登录学生。
|
// 匿名用户 user 为 null 时它返回 false,守卫会整体短路,匿名的权限反而大于登录学生。
|
||||||
|
|||||||
+86
-11
@@ -2,6 +2,12 @@ import { flowchartUpdateSchema, submissionUpdateSchema } from "@oj2/contract"
|
|||||||
import { and, eq } from "drizzle-orm"
|
import { and, eq } from "drizzle-orm"
|
||||||
|
|
||||||
import { touchSession } from "./auth/session"
|
import { touchSession } from "./auth/session"
|
||||||
|
import {
|
||||||
|
handleCollabBinary,
|
||||||
|
handleCollabClose,
|
||||||
|
handleCollabMessage,
|
||||||
|
handleCollabOpen,
|
||||||
|
} from "./collab/handler"
|
||||||
import { config } from "./config"
|
import { config } from "./config"
|
||||||
import { db, schema } from "./db"
|
import { db, schema } from "./db"
|
||||||
import {
|
import {
|
||||||
@@ -49,14 +55,32 @@ export function isAllowedWebSocketOrigin(origin: string | null, url: URL) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RateBucket {
|
||||||
|
tokens: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubmissionSocketData {
|
export interface SubmissionSocketData {
|
||||||
userId: number
|
userId: number
|
||||||
/** 同一个 Bun.serve 只能挂一个 websocket handler,用它区分两条通道 */
|
/** 同一个 Bun.serve 只能挂一个 websocket handler,用它区分通道 */
|
||||||
kind: "submissions" | "config"
|
kind: "submissions" | "config" | "collab"
|
||||||
/** 握手时那张会话的 token,留着定期确认它还没被登出 / 过期,见 sweepSessions */
|
/** 握手时那张会话的 token,留着定期确认它还没被登出 / 过期,见 sweepSessions */
|
||||||
token: string
|
token: string
|
||||||
/** 令牌桶,open 时初始化,见 allowMessage */
|
/** 文本控制帧的令牌桶,open 时初始化,见 allowMessage */
|
||||||
rate?: { tokens: number; updatedAt: number }
|
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_BURST = 20
|
||||||
const RATE_REFILL_PER_SECOND = 2
|
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 now = Date.now()
|
||||||
const rate = (ws.data.rate ??= { tokens: RATE_BURST, updatedAt: now })
|
const refill = ((now - bucket.updatedAt) / 1000) * refillPerSecond
|
||||||
const refill = ((now - rate.updatedAt) / 1000) * RATE_REFILL_PER_SECOND
|
bucket.tokens = Math.min(burst, bucket.tokens + refill)
|
||||||
rate.tokens = Math.min(RATE_BURST, rate.tokens + refill)
|
bucket.updatedAt = now
|
||||||
rate.updatedAt = now
|
if (bucket.tokens < 1) return false
|
||||||
if (rate.tokens < 1) return false
|
bucket.tokens -= 1
|
||||||
rate.tokens -= 1
|
|
||||||
return true
|
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 不提供遍历连接的接口,要定期巡检就得自己登记。
|
* 当前挂着的连接。Bun 不提供遍历连接的接口,要定期巡检就得自己登记。
|
||||||
* open 时加入、close 时移除,见 sweepSessions。
|
* open 时加入、close 时移除,见 sweepSessions。
|
||||||
@@ -165,6 +212,11 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
|
|||||||
open(ws) {
|
open(ws) {
|
||||||
liveSockets.add(ws)
|
liveSockets.add(ws)
|
||||||
ws.data.rate = { tokens: RATE_BURST, updatedAt: Date.now() }
|
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") {
|
if (ws.data.kind === "config") {
|
||||||
ws.subscribe(configTopic)
|
ws.subscribe(configTopic)
|
||||||
return
|
return
|
||||||
@@ -173,6 +225,25 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
|
|||||||
ws.subscribe(userEventTopic(ws.data.userId))
|
ws.subscribe(userEventTopic(ws.data.userId))
|
||||||
},
|
},
|
||||||
message(ws, message) {
|
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)) {
|
if (!allowMessage(ws)) {
|
||||||
ws.close(1008, "Too many messages")
|
ws.close(1008, "Too many messages")
|
||||||
return
|
return
|
||||||
@@ -187,6 +258,10 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
|
|||||||
},
|
},
|
||||||
close(ws) {
|
close(ws) {
|
||||||
liveSockets.delete(ws)
|
liveSockets.delete(ws)
|
||||||
|
if (ws.data.kind === "collab") {
|
||||||
|
handleCollabClose(ws)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (ws.data.kind === "config") {
|
if (ws.data.kind === "config") {
|
||||||
ws.unsubscribe(configTopic)
|
ws.unsubscribe(configTopic)
|
||||||
return
|
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_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_CODE_URL=https://code.xuyue.cc
|
||||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||||
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
|
|
||||||
PUBLIC_ICONIFY_URL=https://icon.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_CODE_URL=http://10.13.114.114:82
|
||||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
||||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
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_CODE_URL=http://10.13.114.114:82
|
||||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
||||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||||
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
|
|
||||||
|
|||||||
+6
-4
@@ -41,8 +41,8 @@ Each feature module (under `oj/` or `admin/`) typically has:
|
|||||||
- `api.ts` — API calls specific to the feature
|
- `api.ts` — API calls specific to the feature
|
||||||
|
|
||||||
Shared logic lives in `shared/`:
|
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)
|
- `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), `sync` (Yjs/y-webrtc for collaborative editing), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `breakpoints`, `maxkb`
|
- `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
|
- `layout/` — `default.vue` and `admin.vue` layout wrappers
|
||||||
- `api.ts` — shared API calls (auth, profile, tags, captcha)
|
- `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_CODE_URL` | Code execution service |
|
||||||
| `PUBLIC_JUDGE0_URL` | Judge0 API |
|
| `PUBLIC_JUDGE0_URL` | Judge0 API |
|
||||||
| `PUBLIC_MAXKB_URL` | Knowledge base service |
|
| `PUBLIC_MAXKB_URL` | Knowledge base service |
|
||||||
| `PUBLIC_SIGNALING_URL` | WebRTC signaling server |
|
|
||||||
| `PUBLIC_ICONIFY_URL` | Iconify icon CDN |
|
| `PUBLIC_ICONIFY_URL` | Iconify icon CDN |
|
||||||
|
|
||||||
### Routing
|
### Routing
|
||||||
@@ -107,7 +106,10 @@ Routes are defined in `src/routes.ts` with two root routes: `ojs` (user-facing)
|
|||||||
### Real-time Features
|
### Real-time Features
|
||||||
|
|
||||||
- WebSocket via composable in `shared/composables/` for submission status updates
|
- 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
|
## Related Repository
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"fflate": "^0.8.3",
|
"fflate": "^0.8.3",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
|
"lib0": "0.2.117",
|
||||||
"md-editor-v3": "^6.5.6",
|
"md-editor-v3": "^6.5.6",
|
||||||
"mermaid": "^11.17.2",
|
"mermaid": "^11.17.2",
|
||||||
"mermaid-legacy": "npm:mermaid@^9.4.3",
|
"mermaid-legacy": "npm:mermaid@^9.4.3",
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
"vue-codemirror": "^6.1.1",
|
"vue-codemirror": "^6.1.1",
|
||||||
"vue-router": "^5.2.0",
|
"vue-router": "^5.2.0",
|
||||||
"y-codemirror.next": "^0.3.6",
|
"y-codemirror.next": "^0.3.6",
|
||||||
"y-webrtc": "^10.3.0",
|
"y-protocols": "1.0.7",
|
||||||
"yjs": "^13.6.32"
|
"yjs": "^13.6.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { useConfigStore } from "shared/store/config"
|
|||||||
import { useConfigUpdate } from "shared/composables/configUpdate"
|
import { useConfigUpdate } from "shared/composables/configUpdate"
|
||||||
import { useMaxKB } from "shared/composables/maxkb"
|
import { useMaxKB } from "shared/composables/maxkb"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
|
import { useCollabStore } from "shared/store/collab"
|
||||||
|
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const collabStore = useCollabStore()
|
||||||
|
|
||||||
// 初始化配置和实时更新
|
// 初始化配置和实时更新
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -23,6 +25,23 @@ onMounted(() => {
|
|||||||
useConfigUpdate()
|
useConfigUpdate()
|
||||||
useMaxKB()
|
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,避免阻塞首屏
|
// 延迟加载 highlight.js,避免阻塞首屏
|
||||||
const hljsInstance = ref<any>(null)
|
const hljsInstance = ref<any>(null)
|
||||||
const loadHighlightJS = async () => {
|
const loadHighlightJS = async () => {
|
||||||
|
|||||||
Vendored
-1
@@ -6,7 +6,6 @@ interface ImportMetaEnv {
|
|||||||
readonly PUBLIC_CODE_URL: string
|
readonly PUBLIC_CODE_URL: string
|
||||||
readonly PUBLIC_JUDGE0_URL: string
|
readonly PUBLIC_JUDGE0_URL: string
|
||||||
readonly PUBLIC_ICONIFY_URL: string
|
readonly PUBLIC_ICONIFY_URL: string
|
||||||
readonly PUBLIC_SIGNALING_URL: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
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 { SOURCES } from "utils/constants"
|
||||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { provideSyncStatus } from "oj/composables/syncStatus"
|
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
import type { LANGUAGE } from "utils/types"
|
import type { LANGUAGE } from "utils/types"
|
||||||
import Form from "./Form.vue"
|
import Form from "./Form.vue"
|
||||||
@@ -18,10 +17,6 @@ const { problem } = storeToRefs(problemStore)
|
|||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
// 提供空的同步状态,避免 Form 组件注入错误
|
|
||||||
// 在竞赛模式下,同步功能会被 showSyncFeature 自动禁用
|
|
||||||
provideSyncStatus()
|
|
||||||
|
|
||||||
const contestID = route.params.contestID || null
|
const contestID = route.params.contestID || null
|
||||||
const storageKey = computed(
|
const storageKey = computed(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { storeToRefs } from "pinia"
|
|||||||
import { copyToClipboard, utoa } from "utils/functions"
|
import { copyToClipboard, utoa } from "utils/functions"
|
||||||
import { useCodeStore } from "oj/store/code"
|
import { useCodeStore } from "oj/store/code"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { injectSyncStatus } from "oj/composables/syncStatus"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
import { SYNC_MESSAGES } from "shared/composables/sync"
|
|
||||||
import {
|
import {
|
||||||
ICON_SET,
|
ICON_SET,
|
||||||
LANGUAGE_FORMAT_VALUE,
|
LANGUAGE_FORMAT_VALUE,
|
||||||
@@ -27,17 +26,14 @@ const SubmitFlowchart = defineAsyncComponent(
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
storageKey: string
|
storageKey: string
|
||||||
isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { storageKey, isConnected = false } = defineProps<Props>()
|
const { storageKey } = defineProps<Props>()
|
||||||
|
|
||||||
// 注入同步状态
|
const collabStore = useCollabStore()
|
||||||
const syncStatus = injectSyncStatus()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
changeLanguage: [v: LANGUAGE]
|
changeLanguage: [v: LANGUAGE]
|
||||||
toggleSync: [v: boolean]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -50,20 +46,36 @@ const { problem, languages } = storeToRefs(problemStore)
|
|||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
|
|
||||||
const statisticPanel = ref(false)
|
const statisticPanel = ref(false)
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const isContestMode = computed(() => route.name === "contest problem")
|
const isContestMode = computed(() => route.name === "contest problem")
|
||||||
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
|
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
|
||||||
const showSyncFeature = computed(
|
// 可见条件沿用原来的 showSyncFeature,再加上「不是教师」——
|
||||||
|
// 教师端的入口在顶栏,不在题目页
|
||||||
|
const showHelpButton = computed(
|
||||||
() =>
|
() =>
|
||||||
isDesktop.value &&
|
isDesktop.value &&
|
||||||
userStore.isAuthed &&
|
userStore.isAuthed &&
|
||||||
|
!userStore.isTeacherOrAbove &&
|
||||||
|
// 演示模式下协作通道是断开的(见 App.vue),按钮点了也没人收
|
||||||
|
!userStore.demoMode &&
|
||||||
codeStore.code.language !== "Flowchart" &&
|
codeStore.code.language !== "Flowchart" &&
|
||||||
!isContestMode.value,
|
!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(() => {
|
const showGoSubmissionButton = computed(() => {
|
||||||
if (isContestMode.value) return true
|
if (isContestMode.value) return true
|
||||||
else if (userStore.isAdminRole) return true
|
else if (userStore.isAdminRole) return true
|
||||||
@@ -191,17 +203,6 @@ const goEdit = () => {
|
|||||||
window.open(router.resolve(url).href, "_blank")
|
window.open(router.resolve(url).href, "_blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleSync = () => {
|
|
||||||
syncEnabled.value = !syncEnabled.value
|
|
||||||
emit("toggleSync", syncEnabled.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
resetSyncStatus: () => {
|
|
||||||
syncEnabled.value = false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!languages.value.includes(codeStore.code.language)) {
|
if (!languages.value.includes(codeStore.code.language)) {
|
||||||
// 回退到题目支持的第一种语言(如 SQL 题只有 "SQL",硬编码 Python3 会被后端拒绝)
|
// 回退到题目支持的第一种语言(如 SQL 题只有 "SQL",硬编码 Python3 会被后端拒绝)
|
||||||
@@ -250,31 +251,22 @@ onMounted(() => {
|
|||||||
<n-button :size="buttonSize">更多操作</n-button>
|
<n-button :size="buttonSize">更多操作</n-button>
|
||||||
</n-dropdown>
|
</n-dropdown>
|
||||||
|
|
||||||
<template v-if="showSyncFeature">
|
<template v-if="showHelpButton">
|
||||||
<n-button
|
<n-button
|
||||||
:size="buttonSize"
|
:size="buttonSize"
|
||||||
:type="syncEnabled ? 'warning' : 'default'"
|
:type="collabStore.helpStatus === 'idle' ? 'default' : 'warning'"
|
||||||
@click="toggleSync"
|
:disabled="collabStore.helpStatus === 'active'"
|
||||||
|
@click="toggleHelp"
|
||||||
>
|
>
|
||||||
{{ syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF }}
|
{{ helpButtonText }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
<!-- 同步状态标签 -->
|
<n-tag v-if="collabStore.helpStatus === 'pending'" type="info">
|
||||||
<template v-if="isConnected">
|
已求助{{ collabStore.queueAhead > 0 ? `,前面还有 ${collabStore.queueAhead} 人` : ",等待老师接入" }}
|
||||||
<n-tag v-if="syncStatus.otherUser.value" type="info">
|
</n-tag>
|
||||||
{{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }}
|
<n-tag v-else-if="collabStore.helpStatus === 'active'" type="success">
|
||||||
</n-tag>
|
{{ collabStore.teacherName }} 老师正在帮你
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import { useCodeStore } from "oj/store/code"
|
import { useCodeStore } from "oj/store/code"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { provideSyncStatus } from "oj/composables/syncStatus"
|
|
||||||
import { SOURCES } from "utils/constants"
|
import { SOURCES } from "utils/constants"
|
||||||
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
|
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
@@ -15,7 +14,6 @@ const FlowchartEditor = defineAsyncComponent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const formRef = useTemplateRef<InstanceType<typeof Form>>("formRef")
|
|
||||||
const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
|
const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
|
||||||
|
|
||||||
const codeStore = useCodeStore()
|
const codeStore = useCodeStore()
|
||||||
@@ -24,10 +22,6 @@ const { problem } = storeToRefs(problemStore)
|
|||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const sync = ref(false)
|
|
||||||
// 提供同步状态给子组件使用
|
|
||||||
const syncStatus = provideSyncStatus()
|
|
||||||
|
|
||||||
const contestID = route.params.contestID || null
|
const contestID = route.params.contestID || null
|
||||||
const storageKey = computed(
|
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给子组件
|
// 提供FlowchartEditor的ref给子组件
|
||||||
provide("flowchartEditorRef", flowchartEditorRef)
|
provide("flowchartEditorRef", flowchartEditorRef)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex vertical>
|
<n-flex vertical>
|
||||||
<Form
|
<Form :storage-key="storageKey" @change-language="changeLanguage" />
|
||||||
ref="formRef"
|
|
||||||
:storage-key="storageKey"
|
|
||||||
:is-connected="sync"
|
|
||||||
@change-language="changeLanguage"
|
|
||||||
@toggle-sync="toggleSync"
|
|
||||||
/>
|
|
||||||
<FlowchartEditor
|
<FlowchartEditor
|
||||||
v-if="codeStore.code.language === 'Flowchart'"
|
v-if="codeStore.code.language === 'Flowchart'"
|
||||||
ref="flowchartEditorRef"
|
ref="flowchartEditorRef"
|
||||||
@@ -111,13 +80,9 @@ provide("flowchartEditorRef", flowchartEditorRef)
|
|||||||
<SyncCodeEditor
|
<SyncCodeEditor
|
||||||
v-else
|
v-else
|
||||||
v-model:value="codeStore.code.value"
|
v-model:value="codeStore.code.value"
|
||||||
:sync="sync"
|
|
||||||
:problem="problem!._id"
|
|
||||||
:language="codeStore.code.language"
|
:language="codeStore.code.language"
|
||||||
:height="editorHeight"
|
:height="editorHeight"
|
||||||
@update:model-value="changeCode"
|
@update:model-value="changeCode"
|
||||||
@sync-closed="handleSyncClosed"
|
|
||||||
@sync-status-change="handleSyncStatusChange"
|
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ onMounted(() => {
|
|||||||
<n-descriptions-item label="创建时间">
|
<n-descriptions-item label="创建时间">
|
||||||
{{ parseTime(problem.createTime) }}
|
{{ parseTime(problem.createTime) }}
|
||||||
</n-descriptions-item>
|
</n-descriptions-item>
|
||||||
<n-descriptions-item label="难度">
|
<!-- 比赛进行中,难度不下发(difficulty 为 null),这一项整个不显示 -->
|
||||||
|
<n-descriptions-item v-if="problem.difficulty" label="难度">
|
||||||
<n-tag :type="getTagColor(problem.difficulty)">
|
<n-tag :type="getTagColor(problem.difficulty)">
|
||||||
{{ DIFFICULTY[problem.difficulty] }}
|
{{ DIFFICULTY[problem.difficulty] }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
|
|||||||
@@ -173,7 +173,9 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
|
|||||||
key: "difficulty",
|
key: "difficulty",
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (row) =>
|
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"),
|
title: renderTableTitle("标签", "streamline-ultimate-color:attachment"),
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ function handleProblemClick(problemId: string) {
|
|||||||
|
|
||||||
<n-flex align="center" size="small">
|
<n-flex align="center" size="small">
|
||||||
<n-tag
|
<n-tag
|
||||||
|
v-if="problemSetProblem.problem.difficulty"
|
||||||
:type="getTagColor(problemSetProblem.problem.difficulty)"
|
:type="getTagColor(problemSetProblem.problem.difficulty)"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function filterResult(result: ProblemListItem): ProblemFiltered {
|
|||||||
id: result.id,
|
id: result.id,
|
||||||
_id: result._id,
|
_id: result._id,
|
||||||
title: result.title,
|
title: result.title,
|
||||||
difficulty: DIFFICULTY[result.difficulty],
|
difficulty: result.difficulty ? DIFFICULTY[result.difficulty] : null,
|
||||||
tags: result.tags,
|
tags: result.tags,
|
||||||
submission: result.submissionNumber,
|
submission: result.submissionNumber,
|
||||||
rate: getACRate(result.acceptedNumber, result.submissionNumber),
|
rate: getACRate(result.acceptedNumber, result.submissionNumber),
|
||||||
|
|||||||
@@ -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 { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useLearnProgress } from "shared/composables/learnProgress"
|
import { useLearnProgress } from "shared/composables/learnProgress"
|
||||||
import { useAuthModalStore } from "shared/store/authModal"
|
import { useAuthModalStore } from "shared/store/authModal"
|
||||||
|
import { useCollabStore } from "shared/store/collab"
|
||||||
import { useScreenModeStore } from "shared/store/screenMode"
|
import { useScreenModeStore } from "shared/store/screenMode"
|
||||||
import { logout } from "../api"
|
import { logout } from "../api"
|
||||||
|
import CollabModal from "./CollabModal.vue"
|
||||||
|
import HelpRequestList from "./HelpRequestList.vue"
|
||||||
import { useConfigStore } from "../store/config"
|
import { useConfigStore } from "../store/config"
|
||||||
import { useUserStore } from "../store/user"
|
import { useUserStore } from "../store/user"
|
||||||
import { trickOrTreat } from "utils/functions"
|
import { trickOrTreat } from "utils/functions"
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const configStore = useConfigStore()
|
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 authStore = useAuthModalStore()
|
||||||
const screenModeStore = useScreenModeStore()
|
const screenModeStore = useScreenModeStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -328,6 +348,7 @@ function handleMenuSelect(key: string) {
|
|||||||
>
|
>
|
||||||
{{ screenMode }}
|
{{ screenMode }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<HelpRequestList v-if="isDesktop && userStore.isTeacherOrAbove" />
|
||||||
<div v-if="userStore.isFinished">
|
<div v-if="userStore.isFinished">
|
||||||
<n-dropdown v-if="userStore.isAuthed" :options="options" size="large">
|
<n-dropdown v-if="userStore.isAuthed" :options="options" size="large">
|
||||||
<n-button>
|
<n-button>
|
||||||
@@ -361,6 +382,14 @@ function handleMenuSelect(key: string) {
|
|||||||
</template>
|
</template>
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</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>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { python } from "@codemirror/lang-python"
|
||||||
import { sql, SQLite } from "@codemirror/lang-sql"
|
import { sql, SQLite } from "@codemirror/lang-sql"
|
||||||
import { bracketMatching } from "@codemirror/language"
|
import { bracketMatching } from "@codemirror/language"
|
||||||
import { EditorView } from "@codemirror/view"
|
|
||||||
import { Codemirror } from "vue-codemirror"
|
import { Codemirror } from "vue-codemirror"
|
||||||
import {
|
import {
|
||||||
autocompletion,
|
autocompletion,
|
||||||
@@ -11,25 +10,20 @@ import {
|
|||||||
completeAnyWord,
|
completeAnyWord,
|
||||||
} from "@codemirror/autocomplete"
|
} from "@codemirror/autocomplete"
|
||||||
import type { Extension } from "@codemirror/state"
|
import type { Extension } from "@codemirror/state"
|
||||||
|
import type { EditorView } from "@codemirror/view"
|
||||||
import type { LANGUAGE } from "utils/types"
|
import type { LANGUAGE } from "utils/types"
|
||||||
import { oneDark } from "../themes/oneDark"
|
import { oneDark } from "../themes/oneDark"
|
||||||
import { smoothy } from "../themes/smoothy"
|
import { smoothy } from "../themes/smoothy"
|
||||||
import { styleTheme } from "shared/extensions/baseTheme"
|
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 { enhanceCompletion } from "shared/extensions/autocompletion"
|
||||||
|
import { useCollabDoc } from "../composables/collabDoc"
|
||||||
|
import { useCollabStore } from "shared/store/collab"
|
||||||
|
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
|
const collabStore = useCollabStore()
|
||||||
interface EditorReadyPayload {
|
const { start, stop, getInitialExtension } = useCollabDoc()
|
||||||
view: EditorView
|
|
||||||
state: any
|
|
||||||
container: HTMLElement
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sync: boolean
|
|
||||||
problem: string
|
|
||||||
language?: LANGUAGE
|
language?: LANGUAGE
|
||||||
fontSize?: number
|
fontSize?: number
|
||||||
height?: string
|
height?: string
|
||||||
@@ -38,8 +32,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sync,
|
|
||||||
problem,
|
|
||||||
language = "Python3",
|
language = "Python3",
|
||||||
fontSize = 20,
|
fontSize = 20,
|
||||||
height = "100%",
|
height = "100%",
|
||||||
@@ -48,15 +40,6 @@ const {
|
|||||||
} = defineProps<Props>()
|
} = defineProps<Props>()
|
||||||
const code = defineModel<string>("value")
|
const code = defineModel<string>("value")
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
syncClosed: []
|
|
||||||
syncStatusChange: [
|
|
||||||
status: { otherUser?: { name: string; isSuperAdmin: boolean } },
|
|
||||||
]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
|
||||||
|
|
||||||
const langExtension = computed((): Extension => {
|
const langExtension = computed((): Extension => {
|
||||||
if (language === "SQL")
|
if (language === "SQL")
|
||||||
return sql({ dialect: SQLite, upperCaseKeywords: true })
|
return sql({ dialect: SQLite, upperCaseKeywords: true })
|
||||||
@@ -75,68 +58,52 @@ const extensions = computed(() => [
|
|||||||
getInitialExtension(),
|
getInitialExtension(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const { startSync, stopSync, getInitialExtension } = useCodeSync()
|
interface EditorReadyPayload {
|
||||||
const editorView = ref<EditorView | null>(null)
|
view: EditorView
|
||||||
let cleanupSync: (() => void) | null = null
|
|
||||||
|
|
||||||
const cleanupSyncResources = () => {
|
|
||||||
if (cleanupSync) {
|
|
||||||
cleanupSync()
|
|
||||||
cleanupSync = null
|
|
||||||
}
|
|
||||||
stopSync()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initSync = async () => {
|
// shallowRef,不是 ref:CodeMirror 的 EditorView 是带 getter 的类实例,
|
||||||
if (!editorView.value || !problem || !isDesktop.value) return
|
// Vue 的 UnwrapRef 深度展开会把它结构化成一个丢了原型方法的假类型,
|
||||||
|
// vue-tsc 会报 "missing dispatchTransactions/_root/..." 这类莫名其妙的错。
|
||||||
|
// 项目里旧的 sync.ts 用的是裸变量,同一个道理,这里换成 shallowRef 规避。
|
||||||
|
const editorView = shallowRef<EditorView | null>(null)
|
||||||
|
|
||||||
cleanupSyncResources()
|
const bind = (view: EditorView) => {
|
||||||
|
if (collabStore.isTeacher || !collabStore.room) return
|
||||||
cleanupSync = await startSync({
|
// 学生端:当前编辑器内容就是内容源
|
||||||
problemId: problem,
|
start({ editorView: view, seedContent: view.state.doc.toString() })
|
||||||
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 handleEditorReady = (payload: EditorReadyPayload) => {
|
const handleEditorReady = (payload: EditorReadyPayload) => {
|
||||||
editorView.value = payload.view as EditorView
|
editorView.value = payload.view
|
||||||
if (sync) {
|
// 也从这里起:学生排队时切去看提交记录、老师在这期间接了单,
|
||||||
initSync()
|
// 等他切回来时 room 早就非空了,只靠下面的 watch 是等不到的
|
||||||
}
|
bind(payload.view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 房间开了才建文档。学生点求助时什么都不做 —— 老师没来之前不该动他的编辑器。
|
||||||
|
// 只在学生端启用:这个组件挂在每个用户的题目页上(教师也不例外,
|
||||||
|
// ProblemEditor.vue 只按语言是不是 Flowchart 分支,不看角色),
|
||||||
|
// 教师接单同样会让 collabStore.room 非空 —— 如果这里不按角色收窄,
|
||||||
|
// 教师自己停在某道题的页面上接单时,这个组件会把**教师自己的编辑器内容**
|
||||||
|
// 当成种子插入文档,还会跟 CollabModal 抢 setBinaryHandler 这个单例槽位,
|
||||||
|
// 两者谁后调用谁把对方顶掉。教师端的协作只归 CollabModal 管。
|
||||||
watch(
|
watch(
|
||||||
() => sync,
|
() => collabStore.room,
|
||||||
(shouldSync) => {
|
(room) => {
|
||||||
if (shouldSync) {
|
if (room && !collabStore.isTeacher && editorView.value) bind(editorView.value)
|
||||||
initSync()
|
else stop()
|
||||||
} else {
|
|
||||||
cleanupSyncResources()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
onUnmounted(() => {
|
||||||
() => problem,
|
stop()
|
||||||
(newProblem, oldProblem) => {
|
// 这个组件卸载意味着编辑器没了(切走页面、或者把语言切成流程图),
|
||||||
if (newProblem !== oldProblem && sync) {
|
// CRDT 会话没法接着用:回来时只能新建 Y.Doc,再拿编辑器内容当种子就会和
|
||||||
initSync()
|
// 老师那份合并成重复文本。所以不是"悄悄把绑定拆了",而是明确结束协作 ——
|
||||||
}
|
// 否则老师那边模态框照开、字照敲,一个也到不了学生那里
|
||||||
},
|
if (collabStore.room && !collabStore.isTeacher) collabStore.leave()
|
||||||
)
|
})
|
||||||
|
|
||||||
onUnmounted(cleanupSyncResources)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -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 不该去改现在这条连接的状态
|
// 迟到的 onclose / onerror 不该去改现在这条连接的状态
|
||||||
const ws = new WebSocket(this.url)
|
const ws = new WebSocket(this.url)
|
||||||
this.ws = ws
|
this.ws = ws
|
||||||
|
ws.binaryType = "arraybuffer"
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
if (ws !== this.ws) return
|
if (ws !== this.ws) return
|
||||||
@@ -154,6 +155,14 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
|||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
if (ws !== this.ws) return
|
if (ws !== this.ws) return
|
||||||
|
|
||||||
|
// Yjs 这类二进制帧不是 JSON,交给子类。基类的 pong / force_logout 都是文本帧,
|
||||||
|
// 不会走到这条路径上
|
||||||
|
if (typeof event.data !== "string") {
|
||||||
|
this.onBinary(event.data as ArrayBuffer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data) as T
|
const data = JSON.parse(event.data) as T
|
||||||
|
|
||||||
@@ -330,6 +339,24 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
|||||||
return false
|
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),
|
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?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: string
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
difficulty: "简单" | "中等" | "困难"
|
// 比赛进行中难度不下发,见 contract 的 maskedProblemDifficultySchema
|
||||||
|
difficulty: "简单" | "中等" | "困难" | null
|
||||||
tags: string[]
|
tags: string[]
|
||||||
submission: number
|
submission: number
|
||||||
rate: string
|
rate: string
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"fflate": "^0.8.3",
|
"fflate": "^0.8.3",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
|
"lib0": "0.2.117",
|
||||||
"md-editor-v3": "^6.5.6",
|
"md-editor-v3": "^6.5.6",
|
||||||
"mermaid": "^11.17.2",
|
"mermaid": "^11.17.2",
|
||||||
"mermaid-legacy": "npm:mermaid@^9.4.3",
|
"mermaid-legacy": "npm:mermaid@^9.4.3",
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
"vue-codemirror": "^6.1.1",
|
"vue-codemirror": "^6.1.1",
|
||||||
"vue-router": "^5.2.0",
|
"vue-router": "^5.2.0",
|
||||||
"y-codemirror.next": "^0.3.6",
|
"y-codemirror.next": "^0.3.6",
|
||||||
"y-webrtc": "^10.3.0",
|
"y-protocols": "1.0.7",
|
||||||
"yjs": "^13.6.32",
|
"yjs": "^13.6.32",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"tsx/esbuild": ["esbuild@0.28.1", "https://registry.npmjs.com/esbuild/-/esbuild-0.28.1.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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"])
|
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`
|
* SQL 题配置与展示数据。两者都是 `problem.sql_config` / `problem.sql_display`
|
||||||
* 的 **JSONB 原文**,所以键名保持 snake_case —— 生产库 9 道 SQL 题存的就是这个形状
|
* 的 **JSONB 原文**,所以键名保持 snake_case —— 生产库 9 道 SQL 题存的就是这个形状
|
||||||
@@ -292,7 +302,7 @@ export const problemDetailSchema = z.object({
|
|||||||
lastUpdateTime: z.string().nullable(),
|
lastUpdateTime: z.string().nullable(),
|
||||||
timeLimit: z.number().int(),
|
timeLimit: z.number().int(),
|
||||||
memoryLimit: z.number().int(),
|
memoryLimit: z.number().int(),
|
||||||
difficulty: problemDifficultySchema,
|
difficulty: maskedProblemDifficultySchema,
|
||||||
source: z.string().nullable(),
|
source: z.string().nullable(),
|
||||||
prompt: z.string().nullable(),
|
prompt: z.string().nullable(),
|
||||||
submissionNumber: z.number().int(),
|
submissionNumber: z.number().int(),
|
||||||
@@ -327,7 +337,7 @@ export const problemListItemSchema = z.object({
|
|||||||
title: z.string(),
|
title: z.string(),
|
||||||
submissionNumber: z.number().int(),
|
submissionNumber: z.number().int(),
|
||||||
acceptedNumber: z.number().int(),
|
acceptedNumber: z.number().int(),
|
||||||
difficulty: problemDifficultySchema,
|
difficulty: maskedProblemDifficultySchema,
|
||||||
createdBy: sampleUserSchema,
|
createdBy: sampleUserSchema,
|
||||||
tags: z.array(z.string()),
|
tags: z.array(z.string()),
|
||||||
contestId: z.number().int().nullable(),
|
contestId: z.number().int().nullable(),
|
||||||
|
|||||||
Reference in New Issue
Block a user