feat(举手与协作): 老师能在看板上直接打开学生正在写的代码,同一道题好几个人举手能「讲完了,都放下」,举手面板写着每人错在哪
学生编辑器报「在写哪道题」,看板接口带 editing;新增 peek / dismiss 两条协作消息, 求助记录带这道题最后一次提交(判完新的一次会刷新)。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c9d0ca501
commit
573a0f967f
5 files changed
+303
-40
No files matched your search
+243
-40
@@ -1,13 +1,14 @@
|
|||||||
import { and, eq, isNull } from "drizzle-orm"
|
import { and, desc, eq, isNull, sql } from "drizzle-orm"
|
||||||
|
|
||||||
import { touchSession } from "../auth/session"
|
import { touchSession } from "../auth/session"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { toAdminType } from "@oj2/contract"
|
import { toAdminType, type StatisticInfo } from "@oj2/contract"
|
||||||
|
|
||||||
import { TEACHER_ROLES } from "../routes/helpers"
|
import { TEACHER_ROLES } from "../routes/helpers"
|
||||||
import {
|
import {
|
||||||
addRequest,
|
addRequest,
|
||||||
addTeacher,
|
addTeacher,
|
||||||
|
clearPresence,
|
||||||
closeRoom,
|
closeRoom,
|
||||||
getRequest,
|
getRequest,
|
||||||
getRoom,
|
getRoom,
|
||||||
@@ -15,11 +16,14 @@ import {
|
|||||||
listRequests,
|
listRequests,
|
||||||
normalizeCollabLanguage,
|
normalizeCollabLanguage,
|
||||||
openRoom,
|
openRoom,
|
||||||
|
presenceOf,
|
||||||
queueAheadOf,
|
queueAheadOf,
|
||||||
removeRequest,
|
removeRequest,
|
||||||
removeTeacher,
|
removeTeacher,
|
||||||
roomOf,
|
roomOf,
|
||||||
|
setPresence,
|
||||||
teacherSockets,
|
teacherSockets,
|
||||||
|
type CollabLatest,
|
||||||
type CollabSocket,
|
type CollabSocket,
|
||||||
type HelpRequest,
|
type HelpRequest,
|
||||||
type Room,
|
type Room,
|
||||||
@@ -43,6 +47,7 @@ function serializeRequest(request: HelpRequest) {
|
|||||||
className: request.className,
|
className: request.className,
|
||||||
problemId: request.problemId,
|
problemId: request.problemId,
|
||||||
problemTitle: request.problemTitle,
|
problemTitle: request.problemTitle,
|
||||||
|
latest: request.latest,
|
||||||
createdAt: request.createdAt,
|
createdAt: request.createdAt,
|
||||||
status: request.status,
|
status: request.status,
|
||||||
teacherName: request.teacherName ?? null,
|
teacherName: request.teacherName ?? null,
|
||||||
@@ -81,7 +86,7 @@ export function broadcastRequests() {
|
|||||||
|
|
||||||
function sendHelpStatus(
|
function sendHelpStatus(
|
||||||
ws: CollabSocket,
|
ws: CollabSocket,
|
||||||
status: "pending" | "active" | "cancelled" | "no_teacher",
|
status: "pending" | "active" | "cancelled" | "no_teacher" | "dismissed",
|
||||||
extra: Record<string, unknown> = {},
|
extra: Record<string, unknown> = {},
|
||||||
) {
|
) {
|
||||||
ws.send(JSON.stringify({ type: "help_status", status, ...extra }))
|
ws.send(JSON.stringify({ type: "help_status", status, ...extra }))
|
||||||
@@ -160,6 +165,7 @@ function requeueAfterTeacherGone(studentId: number) {
|
|||||||
|
|
||||||
export function handleCollabClose(ws: CollabSocket) {
|
export function handleCollabClose(ws: CollabSocket) {
|
||||||
if (isTeacher(ws)) removeTeacher(ws)
|
if (isTeacher(ws)) removeTeacher(ws)
|
||||||
|
clearPresence(ws.data.userId, ws)
|
||||||
|
|
||||||
const room = roomOf(ws)
|
const room = roomOf(ws)
|
||||||
if (room) {
|
if (room) {
|
||||||
@@ -237,6 +243,15 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
|
|||||||
case "reject":
|
case "reject":
|
||||||
await handleReject(ws, message.studentId)
|
await handleReject(ws, message.studentId)
|
||||||
return
|
return
|
||||||
|
case "presence":
|
||||||
|
handlePresence(ws, message.problemId, message.language)
|
||||||
|
return
|
||||||
|
case "peek":
|
||||||
|
await handlePeek(ws, message.studentId)
|
||||||
|
return
|
||||||
|
case "dismiss":
|
||||||
|
await handleDismiss(ws, message.problemId)
|
||||||
|
return
|
||||||
case "leave":
|
case "leave":
|
||||||
handleLeave(ws, message.reason)
|
handleLeave(ws, message.reason)
|
||||||
return
|
return
|
||||||
@@ -266,7 +281,7 @@ async function handleHelpRequest(ws: CollabSocket, problemId: unknown, language:
|
|||||||
|
|
||||||
// 只认非比赛题:contest_id 为空的那条。比赛题不提供求助
|
// 只认非比赛题:contest_id 为空的那条。比赛题不提供求助
|
||||||
const [problem] = await db
|
const [problem] = await db
|
||||||
.select({ title: schema.problem.title })
|
.select({ id: schema.problem.id, title: schema.problem.title })
|
||||||
.from(schema.problem)
|
.from(schema.problem)
|
||||||
.where(and(eq(schema.problem.displayId, problemId), isNull(schema.problem.contestId)))
|
.where(and(eq(schema.problem.displayId, problemId), isNull(schema.problem.contestId)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -279,23 +294,42 @@ async function handleHelpRequest(ws: CollabSocket, problemId: unknown, language:
|
|||||||
// 已经在协作中就不重复登记,否则会把正在进行的房间挤掉
|
// 已经在协作中就不重复登记,否则会把正在进行的房间挤掉
|
||||||
if (existing?.status === "active") return
|
if (existing?.status === "active") return
|
||||||
|
|
||||||
const [student] = await db
|
const [[student], latest] = await Promise.all([
|
||||||
|
db
|
||||||
.select({ className: schema.user.className })
|
.select({ className: schema.user.className })
|
||||||
.from(schema.user)
|
.from(schema.user)
|
||||||
.where(eq(schema.user.id, ws.data.userId))
|
.where(eq(schema.user.id, ws.data.userId))
|
||||||
.limit(1)
|
.limit(1),
|
||||||
|
latestSubmission(ws.data.userId, problem.id),
|
||||||
|
])
|
||||||
|
|
||||||
addRequest({
|
const request: HelpRequest = {
|
||||||
studentId: ws.data.userId,
|
studentId: ws.data.userId,
|
||||||
studentName: ws.data.username ?? "",
|
studentName: ws.data.username ?? "",
|
||||||
className: student?.className ?? null,
|
className: student?.className ?? null,
|
||||||
problemId,
|
problemId,
|
||||||
|
problemPk: problem.id,
|
||||||
problemTitle: problem.title,
|
problemTitle: problem.title,
|
||||||
|
latest,
|
||||||
language: normalizeCollabLanguage(language),
|
language: normalizeCollabLanguage(language),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
status: "pending",
|
status: "pending",
|
||||||
socket: ws,
|
socket: ws,
|
||||||
})
|
}
|
||||||
|
addRequest(request)
|
||||||
|
|
||||||
|
// 老师正主动看着他的代码时他举了手:不用再排队,就当这位老师接了
|
||||||
|
const room = getRoom(ws.data.userId)
|
||||||
|
if (room?.mode === "peek" && room.studentSocket === ws && room.problemId === problemId) {
|
||||||
|
room.mode = "help"
|
||||||
|
request.status = "active"
|
||||||
|
request.teacherId = room.teacherId
|
||||||
|
request.teacherName = room.teacherSocket.data.username ?? ""
|
||||||
|
sendHelpStatus(ws, "active", { teacherName: request.teacherName })
|
||||||
|
broadcastRequests()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sendHelpStatus(ws, "pending", { queueAhead: queueAheadOf(ws.data.userId) })
|
sendHelpStatus(ws, "pending", { queueAhead: queueAheadOf(ws.data.userId) })
|
||||||
broadcastRequests()
|
broadcastRequests()
|
||||||
}
|
}
|
||||||
@@ -347,17 +381,7 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 握手时的 adminType 是那一刻的快照,接单前按库里的真实身份复核一次。
|
if (!(await stillTeacher(ws))) return
|
||||||
// 注意读的是库,不是前端传的任何东西 —— 前端的演示模式在这里没有意义
|
|
||||||
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(toAdminType(teacher.adminType))) {
|
|
||||||
ws.close(1008, "Permission revoked")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上面这次查询是个 await 点,等待期间这条连接可能已经断开——断线时
|
// 上面这次查询是个 await 点,等待期间这条连接可能已经断开——断线时
|
||||||
// handleCollabClose 已经把它从 teacherSockets 摘掉了,用它来判断这次 accept
|
// handleCollabClose 已经把它从 teacherSockets 摘掉了,用它来判断这次 accept
|
||||||
@@ -383,49 +407,63 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
acceptRequest(ws, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 接单:建房、两边发 room_open。调用方已经核过老师身份、老师不在别的房间、这条还在排队 */
|
||||||
|
function acceptRequest(ws: CollabSocket, request: HelpRequest) {
|
||||||
|
const studentId = request.studentId
|
||||||
request.status = "active"
|
request.status = "active"
|
||||||
request.teacherId = ws.data.userId
|
request.teacherId = ws.data.userId
|
||||||
request.teacherName = ws.data.username ?? ""
|
request.teacherName = ws.data.username ?? ""
|
||||||
|
|
||||||
ws.data.roomOwnerId = studentId
|
ws.data.roomOwnerId = studentId
|
||||||
request.socket.data.roomOwnerId = studentId
|
request.socket.data.roomOwnerId = studentId
|
||||||
openRoom({
|
const room: Room = {
|
||||||
studentId,
|
studentId,
|
||||||
|
mode: "help",
|
||||||
teacherId: ws.data.userId,
|
teacherId: ws.data.userId,
|
||||||
studentSocket: request.socket,
|
studentSocket: request.socket,
|
||||||
teacherSocket: ws,
|
teacherSocket: ws,
|
||||||
problemId: request.problemId,
|
problemId: request.problemId,
|
||||||
language: request.language,
|
language: request.language,
|
||||||
})
|
}
|
||||||
|
openRoom(room)
|
||||||
const openFrame = (peerName: string, peerRole: "student" | "teacher") =>
|
sendRoomOpen(room, request.studentName, request.teacherName, request.className)
|
||||||
JSON.stringify({
|
|
||||||
type: "room_open",
|
|
||||||
peer: { name: peerName, role: peerRole },
|
|
||||||
problemId: request.problemId,
|
|
||||||
language: request.language,
|
|
||||||
})
|
|
||||||
request.socket.send(openFrame(request.teacherName, "teacher"))
|
|
||||||
ws.send(openFrame(request.studentName, "student"))
|
|
||||||
sendHelpStatus(request.socket, "active", { teacherName: request.teacherName })
|
sendHelpStatus(request.socket, "active", { teacherName: request.teacherName })
|
||||||
broadcastRequests()
|
broadcastRequests()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找),
|
||||||
|
* 「帮完了」要回到哪个班的看板
|
||||||
|
*/
|
||||||
|
function sendRoomOpen(
|
||||||
|
room: Room,
|
||||||
|
studentName: string,
|
||||||
|
teacherName: string,
|
||||||
|
className: string | null,
|
||||||
|
) {
|
||||||
|
const frame = (peer: Record<string, unknown>) =>
|
||||||
|
JSON.stringify({
|
||||||
|
type: "room_open",
|
||||||
|
mode: room.mode,
|
||||||
|
peer,
|
||||||
|
problemId: room.problemId,
|
||||||
|
language: room.language,
|
||||||
|
})
|
||||||
|
room.studentSocket.send(frame({ name: teacherName, role: "teacher" }))
|
||||||
|
room.teacherSocket.send(
|
||||||
|
frame({ name: studentName, role: "student", id: room.studentId, className }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleReject(ws: CollabSocket, studentId: unknown) {
|
async function handleReject(ws: CollabSocket, studentId: unknown) {
|
||||||
if (!isTeacher(ws) || typeof studentId !== "number") return
|
if (!isTeacher(ws) || typeof studentId !== "number") return
|
||||||
|
|
||||||
// reject 很少见,多这一次查询不心疼;不然握手快照挡不住"连接活着期间被降级
|
// reject 很少见,多这一次查询不心疼;不然握手快照挡不住"连接活着期间被降级
|
||||||
// 或禁用"的老师继续掐掉排队中的求助
|
// 或禁用"的老师继续掐掉排队中的求助
|
||||||
const [teacher] = await db
|
if (!(await stillTeacher(ws))) return
|
||||||
.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(toAdminType(teacher.adminType))) {
|
|
||||||
ws.close(1008, "Permission revoked")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = getRequest(studentId)
|
const request = getRequest(studentId)
|
||||||
// 已经在协作中的不能靠 reject 掐掉,那是 leave 的事
|
// 已经在协作中的不能靠 reject 掐掉,那是 leave 的事
|
||||||
@@ -532,3 +570,168 @@ export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array)
|
|||||||
teardownRoom(room, "peer_offline", offlineSide)
|
teardownRoom(room, "peer_offline", offlineSide)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 握手时的 adminType 是那一刻的快照,动手之前按库里的真实身份复核一次。
|
||||||
|
* 注意读的是库,不是前端传的任何东西 —— 前端的演示模式在这里没有意义。
|
||||||
|
* 不是老师了就断开连接,返回 false
|
||||||
|
*/
|
||||||
|
async function stillTeacher(ws: CollabSocket) {
|
||||||
|
if (!isTeacher(ws)) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: "无权限" }))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
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(toAdminType(teacher.adminType))) {
|
||||||
|
ws.close(1008, "Permission revoked")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这个学生这道题(非比赛)最后一次代码提交,举手面板按它写错法 */
|
||||||
|
async function latestSubmission(userId: number, problemPk: number): Promise<CollabLatest | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
id: schema.submission.id,
|
||||||
|
result: schema.submission.result,
|
||||||
|
runtimeError: sql<
|
||||||
|
StatisticInfo["runtime_error"] | null
|
||||||
|
>`${schema.submission.statisticInfo}->'runtime_error'`,
|
||||||
|
sampleCheck: sql<
|
||||||
|
StatisticInfo["sample_check"] | null
|
||||||
|
>`${schema.submission.statisticInfo}->'sample_check'`,
|
||||||
|
})
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.submission.userId, userId),
|
||||||
|
eq(schema.submission.problemId, problemPk),
|
||||||
|
isNull(schema.submission.contestId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(schema.submission.createTime))
|
||||||
|
.limit(1)
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
submissionId: row.id,
|
||||||
|
result: row.result,
|
||||||
|
runtimeError: row.runtimeError ?? undefined,
|
||||||
|
sampleCheck: row.sampleCheck ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判完了一条提交:这个人要是举着手,刷新他那条的错法 —— 举手之后又交了一次很常见,
|
||||||
|
* 不刷新的话面板上一直写着举手那一刻的错。由 websocket.ts 的判题事件桥调
|
||||||
|
*/
|
||||||
|
export async function refreshRequestLatest(userId: number) {
|
||||||
|
const request = getRequest(userId)
|
||||||
|
if (!request) return
|
||||||
|
const latest = await latestSubmission(userId, request.problemPk)
|
||||||
|
// 查库期间这条可能已经被放下、或者换成了另一道题的新求助
|
||||||
|
if (getRequest(userId) !== request) return
|
||||||
|
request.latest = latest
|
||||||
|
broadcastRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生报告他此刻开着哪道题的编辑器(null = 离开了题目页 / 编辑器卸载了)。
|
||||||
|
* 只认学生:老师的编辑器不需要被别人打开。协作中换语言也走这里同步给老师
|
||||||
|
*/
|
||||||
|
function handlePresence(ws: CollabSocket, problemId: unknown, language: unknown) {
|
||||||
|
if (isTeacher(ws)) return
|
||||||
|
if (typeof problemId !== "string" || !problemId) {
|
||||||
|
clearPresence(ws.data.userId, ws)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lang = normalizeCollabLanguage(language)
|
||||||
|
setPresence(ws.data.userId, { problemId, language: lang, socket: ws })
|
||||||
|
|
||||||
|
const room = getRoom(ws.data.userId)
|
||||||
|
if (room && room.studentSocket === ws && room.language !== lang) {
|
||||||
|
room.language = lang
|
||||||
|
room.teacherSocket.send(JSON.stringify({ type: "room_language", language: lang }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 老师在看板上主动打开一个学生正在写的代码(学生没举手)。
|
||||||
|
*
|
||||||
|
* 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」;
|
||||||
|
* 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。
|
||||||
|
*/
|
||||||
|
async function handlePeek(ws: CollabSocket, studentId: unknown) {
|
||||||
|
if (typeof studentId !== "number") {
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: "学生标识不对,请刷新页面重试" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!(await stillTeacher(ws))) return
|
||||||
|
if (!teacherSockets().has(ws)) return
|
||||||
|
if (roomOf(ws)) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: "请先结束当前协作" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = getRoom(studentId)
|
||||||
|
if (busy) {
|
||||||
|
const name = busy.teacherSocket.data.username ?? "别的老师"
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: `${name} 正在看他的代码` }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const request = getRequest(studentId)
|
||||||
|
if (request?.status === "pending") {
|
||||||
|
acceptRequest(ws, request)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const presence = presenceOf(studentId)
|
||||||
|
if (!presence) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: "他现在没开着题目,打不开" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [student] = await db
|
||||||
|
.select({ username: schema.user.username, className: schema.user.className })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(eq(schema.user.id, studentId))
|
||||||
|
.limit(1)
|
||||||
|
// 查库是个 await 点:期间老师可能断了、学生可能走了、别的老师可能先打开了
|
||||||
|
if (!student || !teacherSockets().has(ws) || roomOf(ws) || getRoom(studentId)) return
|
||||||
|
if (presenceOf(studentId) !== presence) return
|
||||||
|
|
||||||
|
ws.data.roomOwnerId = studentId
|
||||||
|
presence.socket.data.roomOwnerId = studentId
|
||||||
|
const room: Room = {
|
||||||
|
studentId,
|
||||||
|
mode: "peek",
|
||||||
|
teacherId: ws.data.userId,
|
||||||
|
studentSocket: presence.socket,
|
||||||
|
teacherSocket: ws,
|
||||||
|
problemId: presence.problemId,
|
||||||
|
language: presence.language,
|
||||||
|
}
|
||||||
|
openRoom(room)
|
||||||
|
sendRoomOpen(room, student.username, ws.data.username ?? "", student.className)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「讲完了,都放下」:同一道题好几个人举手,老师停下来全班讲完了,把这道题还在排队的手
|
||||||
|
* 一次放下。正在帮的那个不动。学生收到「xuyue 讲了 2020,还不会就再举手」
|
||||||
|
*/
|
||||||
|
async function handleDismiss(ws: CollabSocket, problemId: unknown) {
|
||||||
|
if (typeof problemId !== "string" || !problemId) return
|
||||||
|
if (!(await stillTeacher(ws))) return
|
||||||
|
const teacherName = ws.data.username ?? ""
|
||||||
|
let changed = false
|
||||||
|
for (const request of listRequests()) {
|
||||||
|
if (request.problemId !== problemId || request.status !== "pending") continue
|
||||||
|
removeRequest(request.studentId)
|
||||||
|
sendHelpStatus(request.socket, "dismissed", { teacherName, problemId })
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) broadcastRequests()
|
||||||
|
}
|
||||||
@@ -39,13 +39,28 @@ export function normalizeCollabLanguage(value: unknown): CollabLanguage {
|
|||||||
: "C"
|
: "C"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂看板的格子同一份形状,
|
||||||
|
* 前端用同一个 errorKind 归类)。只带归类要用的几样,不带代码
|
||||||
|
*/
|
||||||
|
export interface CollabLatest {
|
||||||
|
submissionId: string
|
||||||
|
result: number
|
||||||
|
runtimeError?: unknown
|
||||||
|
sampleCheck?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export interface HelpRequest {
|
export interface HelpRequest {
|
||||||
studentId: number
|
studentId: number
|
||||||
studentName: string
|
studentName: string
|
||||||
className: string | null
|
className: string | null
|
||||||
/** 题目的展示号(problem._id 列,前端一路用的都是它),不是自增主键 */
|
/** 题目的展示号(problem._id 列,前端一路用的都是它),不是自增主键 */
|
||||||
problemId: string
|
problemId: string
|
||||||
|
/** problem.id,查他最后一次提交用 */
|
||||||
|
problemPk: number
|
||||||
problemTitle: string
|
problemTitle: string
|
||||||
|
/** 他这道题最后一次代码提交;没交过为 null。判完新的一次会刷新(refreshRequestLatest) */
|
||||||
|
latest: CollabLatest | null
|
||||||
/**
|
/**
|
||||||
* 学生编辑器当前的语言。决定教师端弹框用哪套高亮和补全 —— 求助时带上,
|
* 学生编辑器当前的语言。决定教师端弹框用哪套高亮和补全 —— 求助时带上,
|
||||||
* 协作期间学生切语言会用 help_language 更新这里
|
* 协作期间学生切语言会用 help_language 更新这里
|
||||||
@@ -111,6 +126,11 @@ export function teacherSockets() {
|
|||||||
export interface Room {
|
export interface Room {
|
||||||
/** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */
|
/** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */
|
||||||
studentId: number
|
studentId: number
|
||||||
|
/**
|
||||||
|
* help = 学生举了手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。
|
||||||
|
* 两边看到的字不一样(「在帮你」/「在看你的代码」),peek 没有求助记录要清
|
||||||
|
*/
|
||||||
|
mode: "help" | "peek"
|
||||||
teacherId: number
|
teacherId: number
|
||||||
studentSocket: CollabSocket
|
studentSocket: CollabSocket
|
||||||
teacherSocket: CollabSocket
|
teacherSocket: CollabSocket
|
||||||
@@ -138,3 +158,29 @@ export function roomOf(ws: CollabSocket) {
|
|||||||
const ownerId = ws.data.roomOwnerId
|
const ownerId = ws.data.roomOwnerId
|
||||||
return ownerId === undefined ? undefined : rooms.get(ownerId)
|
return ownerId === undefined ? undefined : rooms.get(ownerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在看板上「在写」那一列看它、
|
||||||
|
* 点它主动打开 —— 不用等学生举手。学生端编辑器挂上 / 换题 / 卸载时上报,
|
||||||
|
* 连接断了就清掉。同一账号开了两个标签页时,后报的算
|
||||||
|
*/
|
||||||
|
export interface Presence {
|
||||||
|
problemId: string
|
||||||
|
language: CollabLanguage
|
||||||
|
socket: CollabSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
const presences = new Map<number, Presence>()
|
||||||
|
|
||||||
|
export function setPresence(userId: number, presence: Presence) {
|
||||||
|
presences.set(userId, presence)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只清这条连接报的:同账号另一个标签页报的那份不能被这边关页面顺手清掉 */
|
||||||
|
export function clearPresence(userId: number, ws: CollabSocket) {
|
||||||
|
if (presences.get(userId)?.socket === ws) presences.delete(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function presenceOf(userId: number) {
|
||||||
|
return presences.get(userId)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { Hono } from "hono"
|
|||||||
|
|
||||||
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||||
import { getLoginWindow } from "../auth/session"
|
import { getLoginWindow } from "../auth/session"
|
||||||
|
import { presenceOf } from "../collab/state"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, parseBody, success } from "../http"
|
import { failure, parseBody, success } from "../http"
|
||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus } from "../judge/status"
|
||||||
@@ -754,6 +755,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
codeToday: codeTodayUsers.has(student.userId),
|
codeToday: codeTodayUsers.has(student.userId),
|
||||||
drawnToday: drawnTodayUsers.has(student.userId),
|
drawnToday: drawnTodayUsers.has(student.userId),
|
||||||
recentAttended: attendedByUser.get(student.userId) ?? 0,
|
recentAttended: attendedByUser.get(student.userId) ?? 0,
|
||||||
|
editing: presenceOf(student.userId)?.problemId ?? null,
|
||||||
}) satisfies ClassBoardStudent,
|
}) satisfies ClassBoardStudent,
|
||||||
),
|
),
|
||||||
recentLessons: recentDays.length,
|
recentLessons: recentDays.length,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
handleCollabClose,
|
handleCollabClose,
|
||||||
handleCollabMessage,
|
handleCollabMessage,
|
||||||
handleCollabOpen,
|
handleCollabOpen,
|
||||||
|
refreshRequestLatest,
|
||||||
} from "./collab/handler"
|
} from "./collab/handler"
|
||||||
import { config } from "./config"
|
import { config } from "./config"
|
||||||
import { db, schema } from "./db"
|
import { db, schema } from "./db"
|
||||||
@@ -423,6 +424,12 @@ export async function bridgeSubmissionEvents(server: Bun.Server<SubmissionSocket
|
|||||||
if (channel !== submissionUpdateChannel) return
|
if (channel !== submissionUpdateChannel) return
|
||||||
const event = parseSubmissionEvent(raw)
|
const event = parseSubmissionEvent(raw)
|
||||||
if (!event) return
|
if (!event) return
|
||||||
|
// 举着手的人又判完一次:老师举手面板上他那条的错法跟着刷新(没举手的直接返回,不查库)
|
||||||
|
if (event.data.status === "finished") {
|
||||||
|
refreshRequestLatest(event.userId).catch((error) => {
|
||||||
|
console.error("Failed to refresh help request", error)
|
||||||
|
})
|
||||||
|
}
|
||||||
const topic = userSubmissionTopic(event.userId)
|
const topic = userSubmissionTopic(event.userId)
|
||||||
if (server.subscriberCount(topic) === 0) return
|
if (server.subscriberCount(topic) === 0) return
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|||||||
@@ -139,6 +139,11 @@ export const classBoardStudentSchema = z.object({
|
|||||||
drawnToday: z.boolean(),
|
drawnToday: z.boolean(),
|
||||||
/** 最近几节课(不含今天,个数见 ClassBoard.recentLessons)里交过题的有几节 */
|
/** 最近几节课(不含今天,个数见 ClassBoard.recentLessons)里交过题的有几节 */
|
||||||
recentAttended: z.number().int(),
|
recentAttended: z.number().int(),
|
||||||
|
/**
|
||||||
|
* 他此刻开着哪道题的代码编辑器(展示题号),没开着为 null。老师点它就能打开他正在写的
|
||||||
|
* 代码,不用等他举手。只认连着**本站**的(和课堂求助一样,在 serve 进程的内存里)
|
||||||
|
*/
|
||||||
|
editing: z.string().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user