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

@@ -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;