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
@@ -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,再拿编辑器内容当种子就会和对方那份合并成重复
|
||||
|
||||
Reference in new issue
Block a user