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:
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
/**
|
||||
* 课堂求助 / 协作通道。和另外两条的区别是它**双向**且**收发二进制** ——
|
||||
* 控制面是 JSON,Yjs 的 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)
|
||||
}
|
||||
}
|
||||
|
||||
172
apps/web/src/shared/store/collab.ts
Normal file
172
apps/web/src/shared/store/collab.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user