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 { 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,17 @@ onMounted(() => {
|
|||||||
useConfigUpdate()
|
useConfigUpdate()
|
||||||
useMaxKB()
|
useMaxKB()
|
||||||
|
|
||||||
|
// 课堂求助通道。和 /ws/config 一样是全局常驻的:老师可能正在后台改题时
|
||||||
|
// 收到求助,学生也要在排队期间一直挂着,所以不放在题目页里起落
|
||||||
|
watch(
|
||||||
|
() => userStore.isAuthed,
|
||||||
|
(isAuthed) => {
|
||||||
|
if (isAuthed) 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 () => {
|
||||||
|
|||||||
@@ -709,3 +709,44 @@ 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
|
||||||
|
|
||||||
|
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