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: "留在这道题",