feat(举手与协作): 顶栏「N 人举手」点开按题分组带错法;看板多一列「在写」点了就能看他的代码;帮完了回看板、还有人就「下一个」

学生那边按钮写「xuyue 在帮你 / 在看你的代码」。顺带修了老师接单后编辑器里留着自己的
草稿或模板、学生的代码插在前面、两边从此不一致的问题。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 13:04:57 +08:00
1 parent 573a0f967f
commit 1fcd12f6f4
13 files changed
+621 -178

No files matched your search

+106 -12
View File
@@ -6,6 +6,7 @@ import { useTone } from "oj/submission/composables/tone"
import { groupClassOptions } from "oj/submission/utils"
import UserName from "shared/components/UserName.vue"
import { useLeaveStudents } from "shared/composables/leaveStudents"
import { useTeacherHelp } from "shared/composables/teacherHelp"
import { useCollabStore } from "shared/store/collab"
import { useConfigStore } from "shared/store/config"
import type { LessonLanguage } from "@oj2/contract"
@@ -40,9 +41,18 @@ const message = useMessage()
const theme = useThemeVars()
const tone = useTone()
const router = useRouter()
const route = useRoute()
const { help, peek } = useTeacherHelp()
const board = ref<ClassBoard | null>(null)
const className = ref<string | null>(null)
/**
* 带着 ?className= 进来的(协作条「帮完了」回来、举手面板「讲这道题」)就看那个班,
* 否则第一次由后端猜
*/
const queryText = (value: unknown) => (typeof value === "string" && value ? value : null)
const className = ref<string | null>(queryText(route.query.className))
/** ?problem= 带来的题号:加载完选中那道题,下面展开它的错法(「讲这道题」) */
let wantedProblem = queryText(route.query.problem)
const classOptions = computed(() => {
const list = configStore.config?.classList ?? []
@@ -56,6 +66,12 @@ async function load() {
board.value = data
// 第一次由后端猜,之后就固定在这个班上,别每次刷新都换
className.value = data.className
if (wantedProblem) {
const wanted = wantedProblem.toLowerCase()
const hit = data.problems.find((item) => item.problemDisplayId.toLowerCase() === wanted)
if (hit) selectedId.value = hit.problemId
wantedProblem = null
}
} catch (error) {
message.error(errorMessage(error))
}
@@ -69,6 +85,26 @@ function changeClass(value: string) {
}
onMounted(load)
// 已经停在看板上时又从举手面板点了「讲这道题」:同一条路由只换 query,组件不重建
watch(
() => route.query,
(query) => {
wantedProblem = queryText(query.problem)
const next = queryText(query.className)
if (next && next !== className.value) {
className.value = next
editing.value = false
}
load()
},
)
/** 举手多久了,每秒走一格 */
const now = useNow({ scheduler: (cb) => useIntervalFn(cb, 1000) })
function waitedText(createdAt: number) {
const seconds = Math.max(0, Math.floor((now.value.getTime() - createdAt) / 1000))
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
}
const { pause } = useIntervalFn(load, REFRESH_MS)
onUnmounted(pause)
@@ -637,6 +673,7 @@ function shortTitle(title: string, size: number) {
<thead>
<tr>
<th class="name">学生</th>
<th class="narrow-col where-col">在写</th>
<th
v-for="(stat, i) in problemStats"
:key="stat.problem.problemId"
@@ -662,7 +699,7 @@ function shortTitle(title: string, size: number) {
<tbody>
<template v-for="student in students" :key="student.userId">
<tr v-if="groupStarts.has(student.userId)" class="group-row">
<td :colspan="board.problems.length + 3">
<td :colspan="board.problems.length + 4">
{{ GROUP_LABEL[groupOf(student)] }}
<span class="muted">{{ groupCount[groupOf(student)] }}</span>
</td>
@@ -679,16 +716,34 @@ function shortTitle(title: string, size: number) {
<UserName :username="student.username" />
</a>
<UserName v-else :username="student.username" />
<n-tag
v-if="helpOf(student)"
size="small"
:bordered="false"
type="warning"
class="help-tag"
@click="collabStore.helpPanelOpen = true"
<span v-if="helpOf(student)?.status === 'pending'" class="waited">
举手 {{ waitedText(helpOf(student)!.createdAt) }}
</span>
</td>
<!-- 在写:举手的「帮他」;别人在帮的写谁在帮;开着题目的点题号直接打开他正在写的
代码(他没举手也行,他那边会显示「xxx 在看你的代码」) -->
<td class="where-col">
<n-button
v-if="helpOf(student)?.status === 'pending'"
type="primary"
size="tiny"
@click="help(student.userId)"
>
{{ helpOf(student)!.status === "active" ? "老师在帮" : "举手了" }}
</n-tag>
帮他 · {{ helpOf(student)!.problemId }}
</n-button>
<span v-else-if="helpOf(student)" class="helping">
{{ helpOf(student)!.teacherName }} 在帮
</span>
<button
v-else-if="student.editing"
type="button"
class="where"
:title="`打开 ${student.username} 正在写的 ${student.editing}`"
@click="peek(student.userId)"
>
{{ student.editing }}
</button>
<span v-else class="muted">—</span>
</td>
<td
v-for="(cell, i) in student.cells"
@@ -1202,11 +1257,50 @@ a.cell {
text-decoration: underline;
}
.help-tag {
.waited {
margin-left: 8px;
font-size: 13px;
font-variant-numeric: tabular-nums;
color: v-bind("tone('error').color");
}
.grid .where-col {
width: 100px;
text-align: left;
}
.where {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 14px;
font-variant-numeric: tabular-nums;
color: v-bind("tone('success').color");
cursor: pointer;
}
.where::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.where:hover {
text-decoration: underline;
}
.helping {
font-size: 13px;
font-weight: 600;
color: v-bind("tone('info').color");
}
.leave-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
@@ -2,44 +2,94 @@
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import UserName from "shared/components/UserName.vue"
/**
* 老师协作时右栏顶上的一条:正在帮谁、看他交过什么、结束协作。语言不写(用户说的):
* 编辑器工具栏的语言下拉就摆在下面,这条短一点,长名字也放得下。
* 老师协作时右栏顶上的一条(设计稿「举手与协作」HcPageA):正在帮谁、举手多久了、
* 看他交过什么、帮完了、下一个。语言不写(用户说的):编辑器工具栏的语言下拉就摆在下面。
*
* 原来这几样挤在工具栏里(一个标签 + 一个按钮),和老师自己的「提交」「课堂统计」混在一行,
* 一眼看不出「现在编辑器里是学生的代码」。学生那边不加横幅,省下编辑器的 40px。
* 用户选的方案 A 是「跳到题目页帮」,为了演示错误时题目和代码并排;代价是要离开看板,
* 所以「帮完了」自动回到这个班的看板,还有人举手就给「下一个」直接去下一个人的题。
*
* 老师在看板上主动打开的(学生没举手,mode = peek)写「正在看 X 的代码」,按钮叫「看完了」。
*/
const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore())
const router = useRouter()
const room = computed(() => collabStore.room)
const peek = computed(() => room.value?.mode === "peek")
/** 他举手多久了:按 id 在举手列表里找他那条(已经是「在帮」了,列表里还留着) */
const request = computed(() =>
collabStore.requests.find((item) => item.studentId === room.value?.peerId),
)
const now = useNow({ scheduler: (cb) => useIntervalFn(cb, 1000) })
const waited = computed(() => {
if (!request.value) return ""
const seconds = Math.max(0, Math.floor((now.value.getTime() - request.value.createdAt) / 1000))
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
})
/** 下一个:还在排队的里面等得最久的(不会是眼前这个,他已经是「在帮」了) */
const next = computed(() => collabStore.nextRequest)
/**
* 「看他交过什么」:只看这个学生、这道题。开新标签 —— 跳走这一页协作就断了。
* 原来工具栏的「本题提交」只带题号,老师得再手动筛一遍用户名
* exactUsername:整名匹配,不然学号相近的同学(ks24a1 / ks24a10…)会混进来
*/
function openHisSubmissions() {
const room = collabStore.room
if (!room || !problem.value) return
if (!room.value || !problem.value) return
const target = router.resolve({
name: "submissions",
// exactUsername:整名匹配,不然学号相近的同学(ks24a1 / ks24a10…)会混进来
query: { problem: problem.value._id, username: room.peerName, exactUsername: "1" },
query: { problem: problem.value._id, username: room.value.peerName, exactUsername: "1" },
})
window.open(target.href, "_blank")
}
/**
* 帮完了:结束这次(求助记录一并清掉),回到这个班的看板。
* 等 room_closed 回来、room 清空了再跳 —— 房间还开着时离开题目页,题目页的拦截
* (detail.vue 的 confirmLeavingHelp)会把跳转改成开新标签
*/
async function finish() {
const className = room.value?.peerClassName
collabStore.leave("done")
await until(() => collabStore.room).toBe(null, { timeout: 3000 })
router.push({ name: "classroom board", query: className ? { className } : {} })
}
/**
* 帮完这个,接下一个:结束、再接单。新房间开好后 CollabHost 会跳到那道题
* (同一道题就不跳,编辑器原地换成下一个人的代码)
*/
function finishAndNext() {
const target = next.value
if (!target) return
collabStore.leave("done")
collabStore.accept(target.studentId)
}
</script>
<template>
<div v-if="collabStore.room" class="collab-bar" role="status">
<div v-if="room" class="collab-bar" role="status">
<span class="dot" aria-hidden="true" />
<span class="who">正在帮 {{ collabStore.room.peerName }}</span>
<span class="note">你改的他马上能看到</span>
<span class="who">
{{ peek ? "正在看" : "正在帮" }}
<UserName :username="room.peerName" />
<template v-if="peek"> 的代码</template>
</span>
<span v-if="!peek && waited" class="note">举手 {{ waited }}</span>
<span class="spacer" />
<button type="button" class="ghost" @click="openHisSubmissions">看他交过什么</button>
<!-- 显式的「结束」:求助记录一并清掉。跳走页面发的是 leave("left"),
那边只是退回排队 —— 见 store 里 leave 的注释 -->
<button type="button" class="end" @click="collabStore.leave('done')">结束协作</button>
<button type="button" :class="next ? 'ghost' : 'end'" @click="finish">
{{ peek ? "看完了" : "帮完了" }}
</button>
<button v-if="next" type="button" class="end" @click="finishAndNext">
下一个:{{ next.studentName }} {{ next.problemId }} →
</button>
</div>
</template>
@@ -55,7 +105,7 @@ function openHisSubmissions() {
gap: 10px;
background-color: #1d4f9c;
color: #fff;
font-size: 13px;
font-size: 14px;
white-space: nowrap;
}
@@ -74,8 +124,18 @@ function openHisSubmissions() {
font-weight: 600;
}
/* UserName 按浅底配的色(前缀灰、名字近黑),深蓝底上要换 */
.who :deep(.prefix) {
color: #b9cdee;
}
.who :deep(.name) {
color: #fff;
}
.note {
color: #d5e2f6;
color: #b9cdee;
font-variant-numeric: tabular-nums;
}
.spacer {
@@ -14,7 +14,7 @@ import { NFlex, useThemeVars } from "naive-ui"
import SubmitCode from "./SubmitCode.vue"
import { useProblemPageContext } from "../composables/problemPageContext"
import { openStatistics, useEditorMenu } from "../composables/editorMenu"
import { useTeacherCollab } from "../composables/teacherCollab"
import { useCollabHere, useTeacherCollab } from "../composables/teacherCollab"
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
const FlowchartControls = defineAsyncComponent(() => import("./FlowchartControls.vue"))
@@ -89,10 +89,21 @@ const showCollabBar = useTeacherCollab()
* 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏
* (语言 / 提交 / 本题提交 / 课堂统计 / 更多 / 求助)在 1280 的机房屏上放不下。
*/
const collabHere = useCollabHere()
/**
* 老师在看板上主动打开了他的代码(他没举手)。用户定的:学生不能拒绝,但必须看得到 ——
* 按钮就写「xuyue 在看你的代码」,和「在帮你」一样亮着、点不了
*/
const peeked = computed(
() => collabHere.value && collabStore.room?.mode === "peek" && collabStore.helpStatus === "idle",
)
/** 老师在他的编辑器里:举手接上了,或者被主动打开了 */
const teacherIn = computed(() => collabStore.helpStatus === "active" || peeked.value)
const helpButtonText = computed(() => {
if (peeked.value) return `${collabStore.room?.peerName ?? "老师"} 在看你的代码`
if (collabStore.helpStatus === "active") {
const name = collabStore.teacherName
return name ? `${name} 老师在帮你` : "老师正在帮你"
return `${collabStore.teacherName || "老师"} 在帮你`
}
if (collabStore.helpStatus === "pending") {
return collabStore.queueAhead > 0
@@ -103,7 +114,7 @@ const helpButtonText = computed(() => {
})
const helpButtonType = computed(() => {
if (collabStore.helpStatus === "active") return "success"
if (teacherIn.value) return "info"
if (collabStore.helpStatus === "pending") return "warning"
return "default"
})
@@ -114,7 +125,9 @@ const helpButtonTitle = computed(() =>
collabStore.helpStatus === "pending" ? "点击取消求助" : undefined,
)
// 老师在的时候不是 disabled(灰掉就看不清了,学生得看得见老师在),点了什么也不做
const toggleHelp = () => {
if (teacherIn.value) return
if (collabStore.helpStatus === "pending") collabStore.cancelHelp()
else if (collabStore.helpStatus === "idle")
collabStore.requestHelp(problem.value!._id, codeStore.code.language)
@@ -218,13 +231,13 @@ const changeLanguage = (v: LANGUAGE) => {
v-if="showHelpButton"
:size="buttonSize"
:type="helpButtonType"
:secondary="collabStore.helpStatus !== 'idle'"
:disabled="collabStore.helpStatus === 'active'"
:secondary="teacherIn || collabStore.helpStatus !== 'idle'"
:title="helpButtonTitle"
class="help"
:class="{ busy: teacherIn }"
@click="toggleHelp"
>
<span v-if="collabStore.helpStatus === 'active'" class="dot" aria-hidden="true" />
<span v-if="teacherIn" class="dot" aria-hidden="true" />
{{ helpButtonText }}
</n-button>
@@ -303,6 +316,10 @@ const changeLanguage = (v: LANGUAGE) => {
padding: 0;
}
.help.busy {
cursor: default;
}
.help .dot {
width: 8px;
height: 8px;
+22 -6
View File
@@ -21,7 +21,7 @@ import {
useProblemPageContext,
type ProblemDrawer as DrawerKey,
} from "./composables/problemPageContext"
import { useTeacherCollab } from "./composables/teacherCollab"
import { useCollabHere, useTeacherCollab } from "./composables/teacherCollab"
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
// 题库、比赛、题单三种入口用的是同一个编辑器:原来比赛和题单用的是另一个精简版,
@@ -106,6 +106,21 @@ const splitMax = computed(() => `${Math.max(360, windowWidth.value - 460)}px`)
// 协作中的老师看的是学生的提交
const teacherCollab = useTeacherCollab()
const collabHere = useCollabHere()
const collabStore = useCollabStore()
// 老师刚接上(或下一个人):左栏先切到「结果」,那里是他最后一次交的为什么错。
// 要演示时老师自己点回「题目」,题目和代码就并排了
watch(
() => teacherCollab.value && collabStore.room,
(room) => {
if (room) {
drawer.value = null
currentTab.value = "result"
submissionStore.resultSegment = "submit"
}
},
)
const drawerLabel = (key: DrawerKey) =>
key === "submission" && teacherCollab.value ? "他的提交" : DRAWER_TITLE[key]
@@ -148,7 +163,6 @@ watch(
* (SyncCodeEditor 的 detach),排队的求助也跟着撤掉。先问一句,别让他一点「下一题」
* 就把正在帮他的老师踢出去。只拦「离开这道题」,切页签改 `?tab=` 不算
*/
const collabStore = useCollabStore()
const dialog = useDialog()
/**
* 已经同意过要去的地方。通过之后 1.5 秒内点「下一题」:这里先问一次,接着被强制点评拦下
@@ -164,17 +178,19 @@ function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNorm
window.open(router.resolve(to).href, "_blank")
return false
}
if (collabStore.isTeacher || collabStore.helpStatus === "idle") return true
// 老师在看板上主动打开了他的代码(没举手):helpStatus 是 idle,但老师在
const peeked = collabHere.value && collabStore.room?.mode === "peek"
if (collabStore.isTeacher || (collabStore.helpStatus === "idle" && !peeked)) return true
if (approvedTarget === to.fullPath) {
approvedTarget = null
return true
}
const active = collabStore.helpStatus === "active"
const active = collabStore.helpStatus === "active" || peeked
return new Promise<boolean>((resolve) => {
dialog.warning({
title: active ? "老师正在帮你看这道题" : "你正在排队等老师",
title: peeked ? "老师正在看你的代码" : active ? "老师正在帮你看这道题" : "你正在排队等老师",
content: active
? "换题之后,老师那边的协作就断了。确定要换吗?"
? "换题之后,老师那边就断了。确定要换吗?"
: "换题之后这次举手就撤掉了,要在新的题目里重新举手。确定要换吗?",
positiveText: "换题",
negativeText: "留在这道题",
+20 -5
View File
@@ -1,16 +1,15 @@
<script setup lang="ts">
import { useCollabStore } from "shared/store/collab"
import HelpRequestList from "./HelpRequestList.vue"
/**
* 课堂求助的全局界面:一次性提示、求助列表。
* 课堂求助的全局界面:一次性提示、老师接单后跳到那道题。
*
* 新求助**不弹 toast**:原来每来一个就弹一条,一节课能弹十几次,讲台电脑投着屏时还弹在
* 全班面前。现在靠顶栏(前台、后台都有)的 HelpButton:没人等不显示,冒出来就是提醒,
* 人数再变多时闪一下。
*
* 教师端的协作**没有弹框**:接单会跳到那道题的页面,在页面自带的编辑器里协作
* (见 HelpRequestList 的 handleAccept、ProblemEditor 的 collabHere)。
* 教师端的协作**没有弹框**:房间开好就跳到那道题的页面,在页面自带的编辑器里协作
* (ProblemEditor 的 collabHere)。用户选的方案 A:演示学生的错误时题目和代码要并排。
* 原来这里还异步挂一个 CollabModal,那个弹框按一下 Esc 就关、协作跟着结束。
*
* 挂在 App.vue 而不是顶栏或 default.vue 布局里。这些东西跟着**连接**走,
@@ -38,8 +37,24 @@ watch(
if (text) message.info(text)
},
)
/**
* 老师这边房间开好了(「帮他」、看板上点「在写」、协作条的「下一个」):跳到那道题。
* 等 room_open 到了才跳,不在点的那一刻跳 —— 服务端可能拒了(别的老师先接走、学生刚走),
* 那时候跳过去就是对着一个空编辑器。已经在这道题上就不跳(同一道题换下一个人)
*/
const router = useRouter()
const route = useRoute()
watch(
() => collabStore.room,
(room) => {
if (!room || !collabStore.isTeacher) return
const target = `/problem/${room.problemId}`
if (route.path !== target) router.push(target)
},
)
</script>
<template>
<HelpRequestList v-if="collabStore.isTeacher" v-model:show="collabStore.helpPanelOpen" />
<span hidden></span>
</template>
+21 -11
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useCollabStore } from "shared/store/collab"
import HelpRequestList from "./HelpRequestList.vue"
/**
* 老师顶栏上的「N 人举手」:前台顶栏和后台顶栏共用这一个。
@@ -9,7 +10,8 @@ import { useCollabStore } from "shared/store/collab"
* 按钮闪一下(约 1 秒、没声音、不挡内容),代替原来每来一个就弹一条的 toast:
* 那条 toast 一节课能弹十几次,讲台电脑投着屏的时候还弹在全班面前。
*
* 求助列表另有一个入口在课堂看板上,没人等的时候真想看「谁在处理」从那边进。
* 点开是挂在按钮下面的举手面板(HelpRequestList),不是弹框。课堂看板的表里举手的人
* 排最前、行里就有「帮他」,在看板上不用点这里。
*/
const props = withDefaults(defineProps<{ size?: "medium" | "small" }>(), { size: "medium" })
@@ -46,18 +48,26 @@ const tone = computed(() =>
</script>
<template>
<button
<n-popover
v-if="count > 0"
:key="flashKey"
type="button"
class="help"
:class="[props.size, { flash: flashKey > 0 }]"
title="打开求助列表"
@click="collabStore.helpPanelOpen = true"
v-model:show="collabStore.helpPanelOpen"
trigger="click"
placement="bottom-end"
:show-arrow="false"
>
<Icon icon="ph:hand" :width="props.size === 'small' ? 14 : 15" />
<span>{{ count }} 人举手</span>
</button>
<template #trigger>
<button
:key="flashKey"
type="button"
class="help"
:class="[props.size, { flash: flashKey > 0 }]"
>
<Icon icon="ph:hand" :width="props.size === 'small' ? 14 : 15" />
<span>{{ count }} 人举手</span>
</button>
</template>
<HelpRequestList @close="collabStore.helpPanelOpen = false" />
</n-popover>
</template>
<style scoped>
+199 -112
View File
@@ -1,140 +1,227 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { errorKind } from "oj/classroom/errorKind"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useTeacherHelp } from "shared/composables/teacherHelp"
import { useCollabStore } from "shared/store/collab"
import type { CollabRequestItem } from "shared/composables/websocket"
import { SubmissionStatus } from "utils/constants"
import UserName from "./UserName.vue"
/** 由顶栏的姓名下拉菜单打开 */
const show = defineModel<boolean>("show", { default: false })
/**
* 顶栏「N 人举手」点开的面板(设计稿「举手与协作」HcPanel),挂在按钮下面,不是弹框。
*
* 按题分组,每人写错法(和课堂看板同一套 errorKind)和等了多久。同一道题两人以上举手,
* 用户的做法是**停下来全班讲**:组头给「讲这道题」(去看板、选中这道题看错法)和
* 「讲完了,都放下」(这道题还在排队的手一次放下)。
*/
const emit = defineEmits<{ close: [] }>()
const collabStore = useCollabStore()
const router = useRouter()
const route = useRoute()
const message = useMessage()
// 接单之后要在题目页的编辑器里替学生写代码,那个编辑器窄屏上没法用 —— 所以窄屏
// 只让看「谁在等」(角标、toast、这张列表照常给),接单留到桌面端
const { help } = useTeacherHelp()
// 接单之后要在题目页的编辑器里替学生写代码,那个编辑器窄屏上没法用 —— 窄屏只让看
const { isDesktop } = useBreakpoints()
// 等待时长要每秒走一格,所以自己转一个 now。
// 只在弹框开着时转 —— 这个组件跟着顶栏常驻,关着的时候没人看这个数。
const now = ref(Date.now())
let timer: number | null = null
function stopTimer() {
if (timer === null) return
window.clearInterval(timer)
timer = null
// 等待时长每秒走一格。面板只在打开时挂着,关着不转
const now = useNow({ scheduler: (cb) => useIntervalFn(cb, 1000) })
function waited(createdAt: number) {
const seconds = Math.max(0, Math.floor((now.value.getTime() - createdAt) / 1000))
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
}
/** 等了三分钟以上标红 */
function waitedLong(createdAt: number) {
return now.value.getTime() - createdAt >= 180_000
}
watch(show, (opened) => {
if (!opened) {
stopTimer()
return
}
now.value = Date.now()
if (timer === null) {
timer = window.setInterval(() => (now.value = Date.now()), 1000)
}
})
onUnmounted(stopTimer)
const waited = (createdAt: number) => {
const seconds = Math.max(0, Math.floor((now.value - createdAt) / 1000))
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${String(s).padStart(2, "0")}`
function kindOf(item: CollabRequestItem) {
if (!item.latest) return "还没交过"
if (item.latest.result === SubmissionStatus.accepted) return "已经做对了"
return errorKind(item.latest).label
}
/**
* 接单 = 跳到那道题的页面,在页面自带的编辑器里协作。
*
* 原来是接单后弹一个 CollabModal(弹框里再挂一个 CodeMirror + 一份只读题面),
* 那个弹框按一下 Esc 就关、协作跟着结束,上课时太容易误触;题面也只能看不能用。
* 直接跳题目页之后,老师看到的就是学生看到的那一页。
*
* 求助入口在比赛里是关掉的(EditorToolbar.vue 的 showHelpButton),problemId 一定是公开
* 题目的展示 ID,走 /problem/:id 就行。
*/
const handleAccept = (studentId: number, problemId: string, status: string) => {
// 已被别的老师接走的不能点
if (status === "active" || !isDesktop.value) return
// 一个老师同时只能在一个房间里(服务端也拦,见 handler 的「请先退出当前协作」)。
// 这里先拦一道:不拦的话服务端拒了、前端却已经跳到新那道题上,
// 反而把手上正在进行的那场协作断掉(对老师来说「跳走」就等于结束协作)
if (collabStore.room) {
message.warning("请先结束当前协作")
return
}
collabStore.accept(studentId)
show.value = false
// 已经在这道题的页面上就不跳了(老师正投影着这道题时就是这种)。
// 重复导航在 vue-router 里是一个 rejected promise,不拦一下控制台会报
const target = `/problem/${problemId}`
if (route.path !== target) router.push(target)
function pendingOf(items: CollabRequestItem[]) {
return items.filter((item) => item.status === "pending")
}
function onHelp(item: CollabRequestItem) {
if (help(item.studentId)) emit("close")
}
/** 「讲这道题」:去这个班的看板、选中这道题,下面展开错着的人按错法归堆,点人就是讲错误弹层 */
function explain(items: CollabRequestItem[], problemId: string) {
const className = items.find((item) => item.className)?.className
emit("close")
router.push({
name: "classroom board",
query: { ...(className ? { className } : {}), problem: problemId },
})
}
</script>
<template>
<n-modal v-model:show="show" preset="card" title="课堂求助" :style="{ width: '420px' }">
<div style="max-height: 60vh; overflow: auto">
<n-alert v-if="!isDesktop" type="info" :bordered="false" style="margin-bottom: 8px">
接单要在电脑上打开
</n-alert>
<n-empty v-if="collabStore.groupedRequests.length === 0" description="暂无求助" />
<div v-for="group in collabStore.groupedRequests" :key="group.problemId">
<!-- 同题多人是个教学信号:该停下来全班讲,而不是挨个救 -->
<n-flex align="center" justify="space-between" style="padding: 6px 4px">
<n-text depth="3" style="font-size: 12px">
{{ group.problemId }} · {{ group.problemTitle }}
</n-text>
<n-tag v-if="group.items.length > 1" size="small" type="warning">
{{ group.items.length }} 人
</n-tag>
</n-flex>
<n-flex
v-for="item in group.items"
:key="item.studentId"
align="center"
justify="space-between"
:style="{
padding: '6px 8px',
borderRadius: '4px',
opacity: item.status === 'active' ? 0.5 : 1,
cursor: item.status === 'active' || !isDesktop ? 'default' : 'pointer',
}"
@click="handleAccept(item.studentId, group.problemId, item.status)"
>
<n-flex vertical :size="2">
<n-text>
{{ item.studentName }}
<n-text depth="3" v-if="item.className">({{ item.className }})</n-text>
</n-text>
<n-text depth="3" style="font-size: 12px">
{{
item.status === "active"
? `${item.teacherName} 处理中`
: `等了 ${waited(item.createdAt)}`
}}
</n-text>
</n-flex>
<div class="panel">
<div class="title">
<b>举手 {{ collabStore.requests.length }} 人</b>
</div>
<n-alert v-if="!isDesktop" type="info" :bordered="false">接单要在电脑上打开</n-alert>
<n-empty v-if="collabStore.groupedRequests.length === 0" description="没有人举手" />
<div
v-for="group in collabStore.groupedRequests"
:key="group.problemId"
class="group"
:class="{ many: pendingOf(group.items).length >= 2 }"
>
<div class="group-head">
<span class="group-title">
<b class="num">{{ group.problemId }}</b>
<span class="muted">{{ group.problemTitle }}</span>
</span>
<b class="group-count">{{ group.items.length }} 人</b>
<template v-if="pendingOf(group.items).length >= 2">
<n-button size="small" @click="explain(group.items, group.problemId)">讲这道题</n-button>
<n-button size="small" @click="collabStore.dismiss(group.problemId)">
讲完了,都放下
</n-button>
</template>
</div>
<div v-for="item in group.items" :key="item.studentId" class="row">
<div class="who">
<UserName :username="item.studentName" />
<span class="kind">{{ kindOf(item) }}</span>
</div>
<template v-if="item.status === 'active'">
<n-text type="info" strong class="helping">{{ item.teacherName }} 在帮</n-text>
</template>
<template v-else>
<span class="waited" :class="{ long: waitedLong(item.createdAt) }">
等了 {{ waited(item.createdAt) }}
</span>
<n-button v-if="isDesktop" type="primary" size="small" @click="onHelp(item)">
帮他
</n-button>
<n-button
v-if="item.status === 'pending'"
quaternary
circle
size="small"
@click.stop="collabStore.reject(item.studentId)"
aria-label="放下他的手"
title="放下他的手"
@click="collabStore.reject(item.studentId)"
>
<Icon icon="mdi:close" height="16" />
</n-button>
</n-flex>
<n-divider style="margin: 4px 0" />
</template>
</div>
</div>
</n-modal>
</div>
</template>
<style scoped>
.panel {
width: 440px;
max-width: calc(100vw - 32px);
max-height: 70vh;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.title {
font-size: 15px;
}
.group {
border: 1px solid var(--n-divider-color, #e6e7eb);
border-radius: 8px;
overflow: hidden;
}
.group.many {
border-color: #f3d6dd;
}
.group-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(128, 128, 128, 0.08);
white-space: nowrap;
}
.group.many .group-head {
background: rgba(208, 48, 80, 0.07);
}
.group-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
}
.group-title .muted {
margin-left: 6px;
}
.group-count {
font-size: 14px;
}
.group.many .group-count {
color: #c0304e;
}
.row {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 0 8px 0 12px;
border-top: 1px solid rgba(128, 128, 128, 0.12);
}
.who {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
font-size: 15px;
}
.kind {
font-size: 12px;
line-height: 16px;
opacity: 0.65;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.waited {
font-size: 13px;
font-variant-numeric: tabular-nums;
opacity: 0.75;
white-space: nowrap;
}
.waited.long {
color: #c0304e;
opacity: 1;
}
.helping {
font-size: 13px;
white-space: nowrap;
}
.muted {
opacity: 0.6;
}
.num {
font-variant-numeric: tabular-nums;
}
</style>
@@ -191,7 +191,19 @@ watch(
},
)
/**
* 学生开着这道题的编辑器:报给服务端,老师看板「在写」那一列就是它,点了能直接打开他的
* 代码(不用等他举手)。换题、换语言重报一次,编辑器卸载(切走、切成流程图)报 null。
* 比赛入口不报(collab = false):比赛题号和公开题撞号,老师打开的会是另一道题
*/
watch(
() => [collab ? problemId : "", language] as const,
([id, lang]) => collabStore.reportPresence(id || null, lang),
{ immediate: true },
)
onUnmounted(() => {
collabStore.reportPresence(null)
cancelPendingIfLeaving()
// 卸载意味着编辑器没了(切走整个页面、或者把语言切成流程图),CRDT 会话没法
// 接着用:回来时只能新建 Y.Doc,再拿编辑器内容当种子就会和对方那份合并成重复
+10 -2
View File
@@ -66,9 +66,17 @@ export function useCollabDoc() {
const ytext = doc.getText("codemirror")
awareness = new awarenessProtocol.Awareness(doc)
// ★ 顺序不能反:先把内容写进 ytext,再挂 yCollab。
// yCollab 挂上去时会用 ytext 覆盖编辑器内容,先挂就会把学生的代码清空。
// ★ 先把内容写进 ytext,再挂 yCollab。yCollab 挂上去**不会**拿 ytext 覆盖编辑器,
// 只同步此后的改动 —— 所以两边挂之前编辑器里的内容必须和 ytext 一致:
// 学生端 ytext = 编辑器内容(种子);教师端 ytext 是空的,编辑器也得清空。
if (seedContent) ytext.insert(0, seedContent)
// 教师端编辑器里原本是老师自己这道题的草稿或模板。不清的话它一直留在老师那边,学生的代码
// 同步过来插在它前面,两边从此不一致(实测:老师看到两行一样的 input(),学生只有一行)。
// 清空会顺着 v-model 写回 codeStore,但协作中的老师不存草稿(ProblemEditor 的 persistCode),
// 结束后 loadCode 会把他的草稿读回来
if (seedContent === null && editorView.state.doc.length) {
editorView.dispatch({ changes: { from: 0, to: editorView.state.doc.length, insert: "" } })
}
const send = (build: (encoder: any) => void) => {
const encoder = encoding.createEncoder()
@@ -0,0 +1,34 @@
import { useCollabStore } from "shared/store/collab"
/**
* 老师接单 / 主动打开学生的编辑器。两处入口(顶栏举手面板、课堂看板)共用。
*
* 只发消息,不跳页面:房间开好(room_open 到了)才跳到那道题,跳转统一在 CollabHost 里做
* —— 原来是点完就跳,服务端拒了(别的老师先接走、学生刚走)前端也已经跳过去了。
*
* 一个老师同时只在一个房间(服务端也拦)。这里先拦一道:不拦的话等新房间开好跳过去,
* 等于把手上这场协作断掉。返回 false = 没发出去
*/
export function useTeacherHelp() {
const collabStore = useCollabStore()
const message = useMessage()
function busy() {
if (!collabStore.room) return false
message.warning("请先结束当前协作")
return true
}
return {
help(studentId: number) {
if (busy()) return false
collabStore.accept(studentId)
return true
},
peek(studentId: number) {
if (busy()) return false
collabStore.peek(studentId)
return true
},
}
}
@@ -5,6 +5,9 @@ import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import type { ClassBoardCell } from "utils/types"
type BoardLatest = NonNullable<ClassBoardCell["latest"]>
// 全站唯一一处,脱离 n-message-provider 也能弹 —— 强制登出跟当前挂着哪个组件无关。
// utils/api.ts 里也是这么做的
@@ -564,6 +567,8 @@ export interface CollabRequestItem {
className: string | null
problemId: string
problemTitle: string
/** 他这道题最后一次代码提交(和看板格子的 latest 同形),面板按它写错法;没交过为 null */
latest: BoardLatest | null
createdAt: number
status: "pending" | "active"
teacherName: string | null
+70 -8
View File
@@ -9,8 +9,16 @@ import type { LANGUAGE } from "utils/types"
export type HelpStatus = "idle" | "pending" | "active"
export interface RoomInfo {
/**
* help = 学生举手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。
* 学生那边按钮写「在帮你」还是「在看你的代码」、结束时说「帮完了」还是「看完了」看它
*/
mode: "help" | "peek"
peerName: string
peerRole: "student" | "teacher"
/** 教师端才有:学生的 id(协作条按它在列表里找他举手多久了)和班级(「帮完了」回哪个班的看板) */
peerId: number | null
peerClassName: string | null
problemId: string
/** 学生编辑器的语言。教师端的弹框按它选高亮和补全 */
language: LANGUAGE
@@ -38,8 +46,7 @@ export const useCollabStore = defineStore("collab", () => {
/** 一次性提示,由 CollabHost 统一消费后清空 */
const notice = ref("")
/**
* 求助列表弹框开着没有。放在 store 里而不是组件内部:打开它的入口(顶栏的
* 姓名下拉、新求助 toast)和弹框本身已经不在同一棵子树里了。
* 顶栏「N 人举手」的面板开着没有。放在 store 里:面板里点了「帮他」「讲这道题」要把它收起来
*/
const helpPanelOpen = ref(false)
/**
@@ -58,6 +65,14 @@ export const useCollabStore = defineStore("collab", () => {
const pendingCount = computed(() => requests.value.filter((it) => it.status === "pending").length)
/** 下一个该帮的:还在排队的里面等得最久的 */
const nextRequest = computed(
() =>
requests.value
.filter((it) => it.status === "pending")
.sort((a, b) => a.createdAt - b.createdAt)[0] ?? null,
)
/** 按题目聚合,同题多人时老师能一眼看出该停下来全班讲 */
const groupedRequests = computed(() => {
const groups = new Map<
@@ -89,8 +104,13 @@ export const useCollabStore = defineStore("collab", () => {
* 自己干的是 `done` / `self_left`,对面才是 `peer_left` / `peer_offline`。
* 所以这里既要看 reason 也要看角色。
*/
function closedNotice(reason: unknown) {
function closedNotice(reason: unknown, closed: RoomInfo | null) {
const teacher = userStore.isTeacherOrAbove
const peek = closed?.mode === "peek"
// 学生那边:老师点了「帮完了 / 看完了」,或者老师主动看完走开了
if (!teacher && closed && (reason === "done" || (peek && reason === "peer_left"))) {
return `${closed.peerName} ${peek ? "看完了" : "帮完了"}`
}
switch (reason) {
case "peer_offline":
return "对方已断开连接"
@@ -101,9 +121,9 @@ export const useCollabStore = defineStore("collab", () => {
// 自己离开了这道题(跳走页面、把语言切成流程图)
case "self_left":
return teacher ? "你已离开这道题,求助退回队列了" : "你已离开这道题,协作结束"
// done:有人点了「结束协作」
// done:老师自己点的「帮完了」,页面跟着回看板或者去下一个,不用再说一句
default:
return teacher ? "已结束这次协作" : "老师已结束这次帮忙"
return teacher ? "" : "老师已结束这次帮忙"
}
}
@@ -125,12 +145,21 @@ export const useCollabStore = defineStore("collab", () => {
} else if (data.status === "no_teacher") {
helpStatus.value = "idle"
setNotice("当前没有老师在线")
} else if (data.status === "dismissed") {
// 同一道题好几个人举手,老师全班讲完了,一次放下
helpStatus.value = "idle"
setNotice(
`${String(data.teacherName ?? "老师")} 讲了 ${String(data.problemId ?? "这道题")},还不会就再举手`,
)
}
return
case "room_open":
room.value = {
mode: data.mode === "peek" ? "peek" : "help",
peerName: String(data.peer?.name ?? ""),
peerRole: data.peer?.role === "teacher" ? "teacher" : "student",
peerId: typeof data.peer?.id === "number" ? data.peer.id : null,
peerClassName: typeof data.peer?.className === "string" ? data.peer.className : null,
problemId: String(data.problemId ?? ""),
language: (data.language as LANGUAGE) ?? "C",
}
@@ -147,9 +176,13 @@ export const useCollabStore = defineStore("collab", () => {
// help_status:pending——teardownRoom 里 room_closed 先发、
// requeueAfterTeacherGone 后发,同一条连接上消息严格按发送顺序到达,
// 这里先归零,那条 pending 补发会立刻把它纠正回来,不会被这次重置盖掉
room.value = null
helpStatus.value = "idle"
setNotice(closedNotice(data.reason))
{
const closed = room.value
room.value = null
helpStatus.value = "idle"
const text = closedNotice(data.reason, closed)
if (text) setNotice(text)
}
return
case "error":
setNotice(String(data.message ?? ""))
@@ -170,8 +203,13 @@ export const useCollabStore = defineStore("collab", () => {
queueAhead.value = 0
teacherName.value = ""
room.value = null
// 服务端的「在写」随连接清掉了,重连后补报一次
if (presence) ws.send({ type: "presence", ...presence })
})
/** 学生此刻开着哪道题的编辑器,重连时补报用 */
let presence: { problemId: string; language: LANGUAGE } | null = null
function connect() {
ws.connect()
}
@@ -209,6 +247,26 @@ export const useCollabStore = defineStore("collab", () => {
ws.send({ type: "accept", studentId })
}
/** 老师在看板上主动打开一个学生正在写的代码(他没举手也行;举着手就等于接单) */
function peek(studentId: number) {
ws.send({ type: "peek", studentId })
}
/** 「讲完了,都放下」:这道题还在排队的手一次放下 */
function dismiss(problemId: string) {
ws.send({ type: "dismiss", problemId })
}
/**
* 学生报告他开着哪道题的编辑器(null = 离开了),老师看板「在写」那一列用。
* 老师不报:老师的编辑器不会被别人打开
*/
function reportPresence(problemId: string | null, language: LANGUAGE = "Python") {
if (userStore.isTeacherOrAbove) return
presence = problemId ? { problemId, language } : null
ws.send({ type: "presence", problemId, language })
}
function reject(studentId: number) {
ws.send({ type: "reject", studentId })
}
@@ -242,6 +300,7 @@ export const useCollabStore = defineStore("collab", () => {
return {
requests,
pendingCount,
nextRequest,
groupedRequests,
helpStatus,
queueAhead,
@@ -257,6 +316,9 @@ export const useCollabStore = defineStore("collab", () => {
updateLanguage,
cancelHelp,
accept,
peek,
dismiss,
reportPresence,
reject,
leave,
sendBinary,
@@ -384,3 +384,26 @@ teacherSockets: Set<socket> // 在线老师,用于推 requests 与判断 no_
`self_left` 是这一轮新加的:发起方原来跟对面收同一条 `done`,于是学生自己切走了
却看到「老师已结束这次帮忙」—— 一句假话。发起方收到的 reason 由 `teardownRoom`
的 `initiator` 决定。
### 2026-10-13:举手与协作重设计(画布「举手与协作」HcPanel / HcPageA / HcTable)
用户说的用法:**协作为主**(老师在教师电脑上点进去远程改,不是走过去看);同一道题好几个人
举手就**停下来全班讲**;协作用来帮个别同学的个别问题。方案选的是 A「跳到题目页帮」——
演示学生的错误时题目和代码要并排,弹层里做不到(B 在看板弹层里帮,被否)。
- **老师主动打开**(原第 2 节的非目标,现在要了):学生的编辑器挂上 / 换题 / 卸载时报
`{type:"presence", problemId, language}`,服务端记在内存(`state.ts` 的 presences),
课堂看板的 `editing` 字段读它,表里多一列「在写」。老师点它发 `{type:"peek", studentId}`,
开一间 `mode: "peek"` 的房间,没有求助记录。用户定的:**学生不能拒绝,但必须看得见**
(按钮写「xuyue 在看你的代码」),**老师可以改**。学生正举着手的话 peek 就是接单;
被看着时举手,直接算这位老师接了。
- **「讲完了,都放下」**:`{type:"dismiss", problemId}`,这道题还在排队的手一次放下,学生收到
`help_status: dismissed`(「xuyue 讲了 2020,还不会就再举手」)。
- **举手面板写错法**:求助记录带 `latest`(这道题最后一次代码提交,和看板格子同形,前端走同一个
`errorKind`)。举手之后又判完一次会刷新(`websocket.ts` 的判题事件桥调 `refreshRequestLatest`)。
- 顶栏「N 人举手」点开是挂在按钮下面的面板,不再是弹框。
- **跳转等房间开好再跳**(`CollabHost`),不在点的那一刻跳:服务端可能拒了。
- 协作条:「帮完了」回到这个班的看板,「下一个:xx 2020 →」直接接等得最久的那个。
- 修了一个从 09-16 起就在的 bug:老师编辑器里原有的草稿 / 模板在协作开始时没清,学生的代码
同步过来插在它前面,两边从此不一致(`collabDoc.ts` 的注释原来说 yCollab 会覆盖编辑器,
实测不会)。有模板的题每次接单都中。