feat(举手与协作): 顶栏「N 人举手」点开按题分组带错法;看板多一列「在写」点了就能看他的代码;帮完了回看板、还有人就「下一个」
学生那边按钮写「xuyue 在帮你 / 在看你的代码」。顺带修了老师接单后编辑器里留着自己的 草稿或模板、学生的代码插在前面、两边从此不一致的问题。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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;
|
||||
|
||||
@@ -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: "留在这道题",
|
||||
|
||||
Reference in new issue
Block a user