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
@@ -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;
|
||||
|
||||
@@ -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: "留在这道题",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,再拿编辑器内容当种子就会和对方那份合并成重复
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 会覆盖编辑器,
|
||||
实测不会)。有模板的题每次接单都中。
|
||||
Reference in new issue
Block a user