Header 一直兼着「全局挂载点」:课堂求助的提示、新求助 toast、求助列表和 教师端协作弹框都寄生在里面,只因为顶栏是全局的。它其实不是 —— /admin/* 走的是 admin.vue,没有 Header,老师一进后台这四个消费者全部 卸载:求助照收,提示、角标、弹框一个都不出现,正好是 collab.ts 里写的 「老师可能正在后台改题时收到求助」那个场景。 这些东西跟着连接走,而连接在 App.vue 按登录态开关,所以搬进 CollabHost 挂在同一层。Header 因此不必再是单根组件,default.vue 那个靠 class 传 居中样式的写法也换成外层 div,连带删掉「必须挂在根 n-flex 内部」那段 补丁注释。 顺带: - 圆环扩散的暗黑切换抽成 useDarkTransition - 求助的角标/toast/列表不再限桌面端 —— 老师缩窗口也得知道有人在等; 接单确实要在电脑上写代码,那道闸挪进 HelpRequestList - 站名改 text 按钮,能 tab 到、回车能按 - logout 的两步收进 userStore.signOut() - 清掉死代码:handleMenuSelect 只认一个不存在的 key、active 里 ["user","setting"] 永远不生效的排除、两个 show:false 的菜单项 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
238 lines
7.5 KiB
TypeScript
238 lines
7.5 KiB
TypeScript
import {
|
||
CollabWebSocket,
|
||
type CollabMessage,
|
||
type CollabRequestItem,
|
||
} from "shared/composables/websocket"
|
||
import { useUserStore } from "shared/store/user"
|
||
import type { LANGUAGE } from "utils/types"
|
||
|
||
export type HelpStatus = "idle" | "pending" | "active"
|
||
|
||
export interface RoomInfo {
|
||
peerName: string
|
||
peerRole: "student" | "teacher"
|
||
problemId: string
|
||
/** 学生编辑器的语言。教师端的弹框按它选高亮和补全 */
|
||
language: LANGUAGE
|
||
}
|
||
|
||
/**
|
||
* 课堂求助的全局状态。
|
||
*
|
||
* 连接是**全局常驻**的,不跟着题目页起落 —— 老师可能正在后台改题时收到求助,
|
||
* 学生也需要在等待期间一直挂着。所以这里不用 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)
|
||
/** 一次性提示,由 CollabHost 统一消费后清空 */
|
||
const notice = ref("")
|
||
/**
|
||
* 求助列表弹框开着没有。放在 store 里而不是组件内部:打开它的入口(顶栏的
|
||
* 姓名下拉、新求助 toast)和弹框本身已经不在同一棵子树里了。
|
||
*/
|
||
const helpPanelOpen = ref(false)
|
||
/**
|
||
* 提示序号,每次设置都自增。
|
||
*
|
||
* 消费方 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 ?? ""),
|
||
language: (data.language as LANGUAGE) ?? "C",
|
||
}
|
||
return
|
||
case "room_language":
|
||
// 学生在协作期间切了语言。只有教师端收得到这条
|
||
if (room.value) room.value.language = (data.language as LANGUAGE) ?? "C"
|
||
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 = ""
|
||
helpPanelOpen.value = false
|
||
}
|
||
|
||
function requestHelp(problemId: string, language: LANGUAGE) {
|
||
ws.send({ type: "help_request", problemId, language })
|
||
}
|
||
|
||
/**
|
||
* 求助期间换了语言。老师那边的高亮和补全按这个值选,不同步过去他就只能对着
|
||
* 建房那一刻的语言给学生写代码。idle 时不发 —— 服务端压根没有这条求助记录。
|
||
*/
|
||
function updateLanguage(language: LANGUAGE) {
|
||
if (helpStatus.value === "idle") return
|
||
ws.send({ type: "help_language", language })
|
||
}
|
||
|
||
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,
|
||
helpPanelOpen,
|
||
isTeacher: computed(() => userStore.isTeacherOrAbove),
|
||
connect,
|
||
disconnect,
|
||
requestHelp,
|
||
updateLanguage,
|
||
cancelHelp,
|
||
accept,
|
||
reject,
|
||
leave,
|
||
sendBinary,
|
||
setBinaryHandler,
|
||
consumeNotice,
|
||
}
|
||
})
|