feat(web): 课堂求助 store 与全局 collab 连接

连接全局常驻,不跟题目页起落 —— 老师在任何页面都要能收到求助。
列表按题目聚合,同题多人时能一眼看出该全班讲。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
2026-08-28 05:36:51 -06:00
parent aef687bcfc
commit 17aa69f8a7
3 changed files with 226 additions and 0 deletions

View File

@@ -6,10 +6,12 @@ import { useConfigStore } from "shared/store/config"
import { useConfigUpdate } from "shared/composables/configUpdate"
import { useMaxKB } from "shared/composables/maxkb"
import { useUserStore } from "shared/store/user"
import { useCollabStore } from "shared/store/collab"
const isDark = useDark()
const configStore = useConfigStore()
const userStore = useUserStore()
const collabStore = useCollabStore()
// 初始化配置和实时更新
onMounted(() => {
@@ -23,6 +25,17 @@ onMounted(() => {
useConfigUpdate()
useMaxKB()
// 课堂求助通道。和 /ws/config 一样是全局常驻的:老师可能正在后台改题时
// 收到求助,学生也要在排队期间一直挂着,所以不放在题目页里起落
watch(
() => userStore.isAuthed,
(isAuthed) => {
if (isAuthed) collabStore.connect()
else collabStore.disconnect()
},
{ immediate: true },
)
// 延迟加载 highlight.js避免阻塞首屏
const hljsInstance = ref<any>(null)
const loadHighlightJS = async () => {

View File

@@ -709,3 +709,44 @@ export function useConfigWebSocket(handler?: MessageHandler<ConfigUpdate>) {
removeHandler: (h: MessageHandler<ConfigUpdate>) => ws.removeHandler(h),
}
}
export interface CollabRequestItem {
studentId: number
studentName: string
className: string | null
problemId: string
problemTitle: string
createdAt: number
status: "pending" | "active"
teacherName: string | null
}
export interface CollabMessage extends WebSocketMessage {
type:
| "requests"
| "help_status"
| "room_open"
| "room_closed"
| "error"
}
/**
* 课堂求助 / 协作通道。和另外两条的区别是它**双向**且**收发二进制** ——
* 控制面是 JSONYjs 的 update / awareness 走 sendRaw 与 onBinary。
*/
export class CollabWebSocket extends BaseWebSocket<CollabMessage> {
private binaryHandler: ((data: ArrayBuffer) => void) | null = null
constructor() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
super({ url: `${protocol}//${window.location.host}/ws/collab` })
}
setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) {
this.binaryHandler = handler
}
protected override onBinary(data: ArrayBuffer) {
this.binaryHandler?.(data)
}
}

View File

@@ -0,0 +1,172 @@
import {
CollabWebSocket,
type CollabMessage,
type CollabRequestItem,
} from "shared/composables/websocket"
import { useUserStore } from "shared/store/user"
export type HelpStatus = "idle" | "pending" | "active"
export interface RoomInfo {
peerName: string
peerRole: "student" | "teacher"
problemId: string
}
/**
* 课堂求助的全局状态。
*
* 连接是**全局常驻**的,不跟着题目页起落 —— 老师可能正在后台改题时收到求助,
* 学生也需要在等待期间一直挂着。所以这里不用 onUnmounted由 App.vue 按登录态开关。
*/
export const useCollabStore = defineStore("collab", () => {
const userStore = useUserStore()
const ws = new CollabWebSocket()
/** 老师端:待处理列表 */
const requests = ref<CollabRequestItem[]>([])
/** 学生端:自己的求助状态 */
const helpStatus = ref<HelpStatus>("idle")
const queueAhead = ref(0)
const teacherName = ref("")
/** 双方当前房间。null 表示不在协作中 */
const room = ref<RoomInfo | null>(null)
/** 一次性提示,由组件消费后清空 */
const notice = ref("")
const pendingCount = computed(
() => requests.value.filter((it) => it.status === "pending").length,
)
/** 按题目聚合,同题多人时老师能一眼看出该停下来全班讲 */
const groupedRequests = computed(() => {
const groups = new Map<string, { problemId: string; problemTitle: string; items: CollabRequestItem[] }>()
for (const item of requests.value) {
const group = groups.get(item.problemId)
if (group) group.items.push(item)
else
groups.set(item.problemId, {
problemId: item.problemId,
problemTitle: item.problemTitle,
items: [item],
})
}
// 人多的题排前面;人数相同按最久等待排
return Array.from(groups.values()).sort(
(a, b) =>
b.items.length - a.items.length ||
a.items[0].createdAt - b.items[0].createdAt,
)
})
const handleMessage = (data: CollabMessage) => {
switch (data.type) {
case "requests":
requests.value = (data.list ?? []) as CollabRequestItem[]
return
case "help_status":
if (data.status === "pending") {
helpStatus.value = "pending"
queueAhead.value = Number(data.queueAhead ?? 0)
} else if (data.status === "active") {
helpStatus.value = "active"
teacherName.value = String(data.teacherName ?? "")
} else if (data.status === "cancelled") {
helpStatus.value = "idle"
notice.value = "老师已取消你的求助"
} else if (data.status === "no_teacher") {
helpStatus.value = "idle"
notice.value = "当前没有老师在线"
}
return
case "room_open":
room.value = {
peerName: String(data.peer?.name ?? ""),
peerRole: data.peer?.role === "teacher" ? "teacher" : "student",
problemId: String(data.problemId ?? ""),
}
return
case "room_closed":
room.value = null
// 老师掉线时服务端会另发一条 help_status:pending这里不抢着改学生状态
if (data.reason === "done") helpStatus.value = "idle"
notice.value =
data.reason === "peer_offline" ? "对方已断开连接" : "协作已结束"
return
case "error":
notice.value = String(data.message ?? "")
return
}
}
ws.addHandler(handleMessage)
function connect() {
ws.connect()
}
function disconnect() {
ws.disconnect()
requests.value = []
helpStatus.value = "idle"
room.value = null
}
function requestHelp(problemId: string) {
ws.send({ type: "help_request", problemId })
}
function cancelHelp() {
ws.send({ type: "help_cancel" })
helpStatus.value = "idle"
}
function accept(studentId: number) {
ws.send({ type: "accept", studentId })
}
function reject(studentId: number) {
ws.send({ type: "reject", studentId })
}
function leave() {
ws.send({ type: "leave" })
}
function sendBinary(data: Uint8Array) {
ws.sendRaw(data)
}
function setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) {
ws.setBinaryHandler(handler)
}
function consumeNotice() {
const value = notice.value
notice.value = ""
return value
}
return {
requests,
pendingCount,
groupedRequests,
helpStatus,
queueAhead,
teacherName,
room,
notice,
isTeacher: computed(() => userStore.isTeacherOrAbove),
connect,
disconnect,
requestHelp,
cancelHelp,
accept,
reject,
leave,
sendBinary,
setBinaryHandler,
consumeNotice,
}
})