feat(协作): 老师协作时右栏顶上一条协作条,结果页签先摆学生最近一次交的,抽屉变成「他的提交」

- 协作条:「正在帮 谁 · 语言 · 看他交过什么 ↗ · 结束协作」,从工具栏里挪出来,一眼看得出
  编辑器里是学生的代码。「看他交过什么」带上学生用户名、开新标签(原来只带题号,还得再筛一遍)
- 老师接单后「结果」页签原来是空的:现在摆学生在这道题上最近一次交的结果,可刷新;
  不给 AI 提示(那是按登录的人自己的失败次数解锁的)
- 抽屉「我的提交」在协作中变成「他的提交」,列的是学生的提交,只能复制、不能放回编辑器
  (放回去会经协作通道直接盖掉学生正在写的代码),也不显示老师自己的排名
- 学生排队求助中、或者老师正在帮他时换题:先确认一句。换题会断开协作 / 撤掉举手,
  原来一点「下一题」就把正在帮他的老师踢出去了
- 求助按钮文案:「举手求助」→「已举手 · 前面 2 人」→「某某 老师在帮你」
- 提交列表加 exactUsername=1:用户名精确匹配。原来只有「包含」,ks24a1 会把 ks24a10 的提交
  也捞进来

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-28 18:28:53 +08:00
1 parent 11368813cc
commit 0c2ff68e9b
13 files changed
+287 -45

No files matched your search

@@ -296,6 +296,19 @@ async function astOnlyByUser(where: SQL | undefined, userIds: number[]) {
* 快照实测 count 65ms → 0.6ms。`ks2` 这种匹配上千个账号的宽前缀退回扫表,30~50ms,
* 和原来持平。
*/
/**
* 用户名**精确**匹配。usernameFilter 是「包含」,学号互相包含是常态(ks24a1 包含在 ks24a10 里),
* 协作中老师看「这个学生」的提交时不能把别人的也捞进来。查无此人留恒假条件
*/
export async function exactUsernameFilter(username: string) {
const [row] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
return row ? eq(schema.submission.userId, row.id) : sql`false`
}
export async function usernameFilter(username: string) {
const like = `%${username}%`
const users = await db
+11 -2
View File
@@ -32,7 +32,12 @@ import { problemSetJoinTimes } from "../services/problemset"
import { consumeToken } from "../services/throttling"
import { dayStart } from "../time"
import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers"
import { problemFilter, submissionStatisticsRoutes, usernameFilter } from "./submission-statistics"
import {
exactUsernameFilter,
problemFilter,
submissionStatisticsRoutes,
usernameFilter,
} from "./submission-statistics"
export const submissionRoutes = new Hono<ContestEnv>()
@@ -489,7 +494,11 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
filters.push(
...(await Promise.all([
displayId ? problemFilter(displayId, null) : undefined,
username ? usernameFilter(username) : undefined,
username
? c.req.query("exactUsername") === "1"
? exactUsernameFilter(username)
: usernameFilter(username)
: undefined,
])),
)
if (myself) filters.push(eq(schema.submission.userId, myself.id))
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
/**
* 老师协作时右栏顶上的一条:正在帮谁、他用什么语言、看他交过什么、结束协作。
*
* 原来这几样挤在工具栏里(一个标签 + 一个按钮),和老师自己的「提交」「课堂统计」混在一行,
* 一眼看不出「现在编辑器里是学生的代码」。学生那边不加横幅,省下编辑器的 40px。
*/
const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore())
const router = useRouter()
/**
* 「看他交过什么」:只看这个学生、这道题。开新标签 —— 跳走这一页协作就断了。
* 原来工具栏的「本题提交」只带题号,老师得再手动筛一遍用户名
*/
function openHisSubmissions() {
const room = collabStore.room
if (!room || !problem.value) return
const target = router.resolve({
name: "submissions",
query: { problem: problem.value._id, username: room.peerName },
})
window.open(target.href, "_blank")
}
</script>
<template>
<div v-if="collabStore.room" class="collab-bar" role="status">
<span class="who">
正在帮 <b>{{ collabStore.room.peerName }}</b> ·
{{ LANGUAGE_SHOW_VALUE[collabStore.room.language] }}
</span>
<n-button text class="link" @click="openHisSubmissions">看他交过什么 ↗</n-button>
<!-- 显式的「结束」:求助记录一并清掉。跳走页面发的是 leave("left"),
那边只是退回排队 —— 见 store 里 leave 的注释 -->
<n-button size="small" class="end" @click="collabStore.leave('done')">结束协作</n-button>
</div>
</template>
<style scoped>
.collab-bar {
height: 40px;
flex: none;
display: flex;
align-items: center;
gap: 14px;
padding: 0 12px;
border-radius: 6px;
background-color: #2080f0;
color: #fff;
font-size: 14px;
white-space: nowrap;
}
.who {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.link {
--n-text-color: #fff !important;
--n-text-color-hover: rgba(255, 255, 255, 0.85) !important;
--n-text-color-pressed: rgba(255, 255, 255, 0.7) !important;
--n-text-color-focus: #fff !important;
text-decoration: underline;
}
.end {
margin-left: auto;
}
</style>
@@ -108,14 +108,14 @@ const showCollabBar = computed(() => collabHere.value && userStore.isTeacherOrAb
const helpButtonText = computed(() => {
if (collabStore.helpStatus === "active") {
const name = collabStore.teacherName
return name ? `${name} 老师帮你中` : "老师正在帮你"
return name ? `${name} 老师在帮你` : "老师正在帮你"
}
if (collabStore.helpStatus === "pending") {
return collabStore.queueAhead > 0
? `已求助 · 前面 ${collabStore.queueAhead} 人`
: "已求助 · 待接入"
? `已举手 · 前面 ${collabStore.queueAhead} 人`
: "已举手 · 老师马上来"
}
return "求助"
return "举手求助"
})
const helpButtonType = computed(() => {
@@ -301,18 +301,6 @@ const goEdit = () => {
<n-button :size="buttonSize">更多</n-button>
</n-dropdown>
<template v-if="showCollabBar">
<n-tag type="success" :size="buttonSize">
正在帮 {{ collabStore.room!.peerName }} ·
{{ LANGUAGE_SHOW_VALUE[collabStore.room!.language] }}
</n-tag>
<!-- 显式的「结束」:求助记录一并清掉。跳走页面发的是 leave("left"),
那边只是退回排队 —— 见 store 里 leave 的注释 -->
<n-button :size="buttonSize" type="primary" @click="collabStore.leave('done')">
结束协作
</n-button>
</template>
<n-button
v-if="showHelpButton"
:size="buttonSize"
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { getSubmission, getSubmissions } from "oj/api"
import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import { parseTime } from "utils/functions"
import type { Submission } from "utils/types"
/**
* 老师接单之后落在题目页,「结果」页签原来是空的 —— 学生多半是交了几次没过才举的手,
* 老师最先想知道的就是「他交上去是什么结果」。这里摆出学生最近一次交的(设计文档第 9 节)。
* 管理员角色本来就能看任何人的提交,不需要新接口。
*/
const SubmissionResult = defineAsyncComponent(() => import("./SubmissionResult.vue"))
const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore())
const loading = ref(false)
const failed = ref(false)
const latest = ref<Submission | null>(null)
const checked = ref(false)
const peer = computed(() => collabStore.room?.peerName ?? "")
async function load() {
if (!peer.value || !problem.value) return
loading.value = true
failed.value = false
try {
const list = await getSubmissions({
username: peer.value,
exactUsername: "1",
problemDisplayId: problem.value._id,
limit: 1,
offset: 0,
})
const row = list.results[0]
latest.value = row ? await getSubmission(row.id) : null
checked.value = true
} catch {
failed.value = true
} finally {
loading.value = false
}
}
watch([peer, () => problem.value?._id], load, { immediate: true })
</script>
<template>
<div class="peer-latest">
<n-flex align="center" justify="space-between" class="head">
<span>
<b>{{ peer }}</b> 最近一次交的
<n-text v-if="latest" depth="3">· {{ parseTime(latest.createTime, "HH:mm:ss") }}</n-text>
</span>
<n-button size="small" :loading="loading" @click="load">刷新</n-button>
</n-flex>
<n-text v-if="failed" type="error">读不出他的提交,点「刷新」再试</n-text>
<SubmissionResult v-else-if="latest" :submission="latest" peer />
<n-empty v-else-if="checked" class="empty" description="他还没交过这道题" />
</div>
</template>
<style scoped>
.head {
margin-bottom: 12px;
}
.empty {
margin-top: 32px;
}
</style>
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import { useUserStore } from "shared/store/user"
import {
DRAWER_TITLE,
useProblemPageContext,
type ProblemDrawer,
} from "../composables/problemPageContext"
import { useTeacherCollab } from "../composables/teacherCollab"
/**
* 统计 / 点评 / 我的提交。原来是左栏的三个页签,课上几乎没人点,却和「题目」挤在一排;
@@ -30,7 +30,6 @@ const ctx = useProblemPageContext()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore())
const show = computed({
@@ -55,6 +54,14 @@ watch(
},
)
// 协作中的老师看的是学生的提交(ProblemSubmission 里按学生用户名查)
const teacherCollab = useTeacherCollab()
const drawerTitle = computed(() =>
lastOpened.value === "submission" && teacherCollab.value
? "他的提交"
: DRAWER_TITLE[lastOpened.value],
)
/**
* 「看这道题所有人的提交」,原来是工具栏上的「本题提交」。可见条件照旧:
* 比赛里总给(看的是这场比赛的提交),题库里要管理员或者开着提交列表。
@@ -72,11 +79,7 @@ function goAllSubmissions() {
// 协作中走新标签:教师端「页面即协作现场」,跳走这一页协作就结束了
// (求助会退回排队,但老师还得再接一次)。而「看看这学生都交了什么」恰好是
// 协作时最常点的一个
const collabHere =
collabStore.room !== null &&
collabStore.room.problemId === problem.value?._id &&
userStore.isTeacherOrAbove
if (collabHere) {
if (teacherCollab.value) {
window.open(router.resolve(target).href, "_blank")
return
}
@@ -94,7 +97,7 @@ function goAllSubmissions() {
:trap-focus="false"
:block-scroll="!props.to"
>
<n-drawer-content :title="DRAWER_TITLE[lastOpened]" closable :native-scrollbar="false">
<n-drawer-content :title="drawerTitle" closable :native-scrollbar="false">
<ProblemInfo v-if="lastOpened === 'info'" />
<ProblemReaction v-else-if="lastOpened === 'reaction'" />
<template v-else>
@@ -11,6 +11,7 @@ import type { LANGUAGE } from "utils/types"
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
import { errorMarkExtensions } from "oj/problem/utils/errorMark"
import EditorToolbar from "./EditorToolbar.vue"
import CollabBar from "./CollabBar.vue"
import { useFlowchartStore } from "oj/store/flowchart"
const FlowchartEditor = defineAsyncComponent(
@@ -97,9 +98,11 @@ const storageKey = computed(() =>
: `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
)
const editorHeight = computed(() =>
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)",
)
// 协作条 40px + 和工具栏之间的 12px 间距,要从编辑器里让出来,不然页面底下多出一截滚动条
const editorHeight = computed(() => {
const base = isDesktop.value ? 133 : 172
return `calc(100vh - ${base + (teacherCollab.value ? 52 : 0)}px)`
})
function loadCode() {
codeStore.code.language = problemStore.supportedLanguage(codeStore.code.language)
@@ -166,6 +169,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
<template>
<n-flex vertical>
<CollabBar v-if="teacherCollab" />
<EditorToolbar :storage-key="storageKey" @change-language="changeLanguage" />
<!--
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
@@ -1,8 +1,6 @@
<script lang="ts" setup>
import { storeToRefs } from "pinia"
import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import Pagination from "shared/components/Pagination.vue"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useCollabStore } from "shared/store/collab"
@@ -12,6 +10,7 @@ import { copyToClipboard, parseTime } from "utils/functions"
import type { ProblemRank } from "@oj2/contract"
import type { LANGUAGE, SubmissionListItem } from "utils/types"
import { useProblemPageContext } from "../composables/problemPageContext"
import { useTeacherCollab } from "../composables/teacherCollab"
/**
* 「我的提交」抽屉:这道题自己交过的每一次。
@@ -24,7 +23,6 @@ import { useProblemPageContext } from "../composables/problemPageContext"
const userStore = useUserStore()
const codeStore = useCodeStore()
const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore())
const ctx = useProblemPageContext()
const route = useRoute()
const router = useRouter()
@@ -36,12 +34,10 @@ const problemDisplayId = computed(() => String(route.params.problemID ?? ""))
* 协作中的教师:编辑器里是学生的代码,「放回编辑器」会经 Yjs 直接盖掉学生正在写的
* 那一份,所以不给(设计文档第 9 节)。
*/
const teacherCollab = computed(
() =>
userStore.isTeacherOrAbove &&
collabStore.room !== null &&
collabStore.room.problemId === problem.value?._id,
)
const teacherCollab = useTeacherCollab()
/** 协作中看的是学生的提交(设计文档第 9 节):房间里的 peerName 就是学生的用户名 */
const peer = computed(() => (teacherCollab.value ? (collabStore.room?.peerName ?? null) : null))
// ==================== 班上第几个做对的 ====================
// 只在题库入口给:比赛有自己的排名,题单里整个抽屉都不出现
@@ -98,7 +94,9 @@ const blocked = computed(() => {
async function listSubmissions() {
const res = await getSubmissions({
...query,
myself: "1",
...(peer.value
? { username: peer.value, exactUsername: "1" as const }
: { myself: "1" as const }),
offset: query.limit * (query.page - 1),
problemDisplayId: problemDisplayId.value,
contestId: ctx.value.contestId,
@@ -165,7 +163,8 @@ function openDetail(id: string) {
onMounted(() => {
if (blocked.value) return
listSubmissions()
if (ctx.value.entry === "problem") {
// 排名是按当前登录的人算的,协作中那是老师自己的,不给
if (ctx.value.entry === "problem" && !peer.value) {
getRankOfProblem(problemDisplayId.value)
.then((res) => {
rank.value = res
@@ -190,7 +189,11 @@ watch(query, () => {
</n-button>
</div>
<n-empty v-if="listed && !submissions.length" class="empty" description="这道题你还没有交过" />
<n-empty
v-if="listed && !submissions.length"
class="empty"
:description="peer ? '他还没有交过这道题' : '这道题你还没有交过'"
/>
<ul v-else class="list">
<li v-for="(row, index) in submissions" :key="row.id" class="item">
@@ -3,6 +3,7 @@ import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission"
import { useCodeStore } from "oj/store/code"
import { useTeacherCollab } from "../composables/teacherCollab"
/**
* 左栏「结果」页签:三段 —— 提交结果 / 运行例子 / 自己输入。
@@ -19,12 +20,16 @@ const PythonErrorExplain = defineAsyncComponent(() => import("./PythonErrorExpla
const SampleRunResult = defineAsyncComponent(() => import("./SampleRunResult.vue"))
const CustomRun = defineAsyncComponent(() => import("./CustomRun.vue"))
const FlowchartResult = defineAsyncComponent(() => import("./FlowchartResult.vue"))
const PeerLatest = defineAsyncComponent(() => import("./PeerLatest.vue"))
const submissionStore = useSubmissionStore()
const { submission, syntaxErrorInfo, resultSegment } = storeToRefs(submissionStore)
const { problem } = storeToRefs(useProblemStore())
const codeStore = useCodeStore()
/** 协作中的老师自己还没交过:先摆学生最近一次交的 */
const teacherCollab = useTeacherCollab()
/** 正在画流程图:结果就是 AI 的点评,没有例子可跑 */
const drawing = computed(() => codeStore.code.language === "Flowchart")
@@ -51,6 +56,7 @@ const canTrial = computed(() => {
<PythonErrorExplain :err-info="syntaxErrorInfo" />
</n-flex>
<SubmissionResult v-else-if="submission" :submission="submission" />
<PeerLatest v-else-if="teacherCollab" />
<n-empty
v-else
class="empty"
@@ -26,6 +26,11 @@ import { useDark } from "@vueuse/core"
const props = defineProps<{
submission?: Submission
/**
* 看的是别人的提交(协作中的老师看学生最近一次交的):不给 AI 提示 —— 那是按登录的人
* 自己的失败次数解锁的 —— 也不给「这节课的下一题」
*/
peer?: boolean
}>()
const isDark = useDark()
@@ -89,7 +94,8 @@ const sampleCheck = computed(() => {
*/
const ctx = useProblemPageContext()
const showLessonNext = computed(
() => props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext,
() =>
!props.peer && props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext,
)
const msg = computed(() => {
@@ -130,7 +136,7 @@ const partialCases = computed(() => submissionPartialCases(props.submission))
// system_error 也要排掉:那是判题机自己崩了,学生代码没毛病,让 AI 去分析
// 只会瞎编一通,后端的失败计数同样不认这个状态。
const showAIHint = computed(() => {
if (!props.submission) return false
if (!props.submission || props.peer) return false
// 比赛题不给提示,和「求助」按钮一致。用 problem.contestId 而不是路由参数:
// 带 contestId 的题目只可能从比赛入口进来(题库列表按 contest_id is null 过滤)。
if (problemStore.problem?.contestId != null) return false
@@ -0,0 +1,19 @@
import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import { useUserStore } from "shared/store/user"
/**
* 老师正在这道题上协作:编辑器里是**学生的**代码,「我的提交」看的是学生的,
* 跳走这一页协作就断了(所以跳转都开新标签,设计文档第 9 节)。
*/
export function useTeacherCollab() {
const userStore = useUserStore()
const collabStore = useCollabStore()
const problemStore = useProblemStore()
return computed(
() =>
userStore.isTeacherOrAbove &&
collabStore.room !== null &&
collabStore.room.problemId === problemStore.problem?._id,
)
}
+39 -2
View File
@@ -8,6 +8,8 @@ import { useMyFlowchartStore } from "shared/store/myFlowchart"
import { useUserStore } from "shared/store/user"
import { useSubmissionStore } from "oj/store/submission"
import { useFlowchartStore } from "oj/store/flowchart"
import { useCollabStore } from "shared/store/collab"
import type { RouteLocationNormalized } from "vue-router"
// 判完之后该发生的事(烟花、点评、回题单)。静态引入:它得在第一次判完之前就挂好
import SubmissionEffects from "./components/SubmissionEffects.vue"
import ResultTabLabel from "./components/ResultTabLabel.vue"
@@ -17,6 +19,7 @@ import {
useProblemPageContext,
type ProblemDrawer as DrawerKey,
} from "./composables/problemPageContext"
import { useTeacherCollab } from "./composables/teacherCollab"
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
// 题库、比赛、题单三种入口用的是同一个编辑器:原来比赛和题单用的是另一个精简版,
@@ -88,12 +91,17 @@ watch(
const drawer = ref<DrawerKey | null>(null)
// 协作中的老师看的是学生的提交
const teacherCollab = useTeacherCollab()
const drawerLabel = (key: DrawerKey) =>
key === "submission" && teacherCollab.value ? "他的提交" : DRAWER_TITLE[key]
function toggleDrawer(key: DrawerKey) {
drawer.value = drawer.value === key ? null : key
}
const drawerMenu = computed<DropdownOption[]>(() =>
ctx.value.drawers.map((key) => ({ label: DRAWER_TITLE[key], key })),
ctx.value.drawers.map((key) => ({ label: drawerLabel(key), key })),
)
// 交上去、或者语法检查没过:切到「结果」,抽屉开着就先关掉。提交按钮在编辑器那边,页签归这一页管
@@ -106,6 +114,35 @@ watch(
},
)
/**
* 学生正在排队求助、或者老师正在帮他时换题:协作是开在这道题上的,换题就断了
* (SyncCodeEditor 的 detach),排队的求助也跟着撤掉。先问一句,别让他一点「下一题」
* 就把正在帮他的老师踢出去。只拦「离开这道题」,切页签改 `?tab=` 不算
*/
const collabStore = useCollabStore()
const dialog = useDialog()
function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNormalized) {
if (collabStore.isTeacher || collabStore.helpStatus === "idle") return true
if (to.name === from.name && to.params.problemID === from.params.problemID) return true
const active = collabStore.helpStatus === "active"
return new Promise<boolean>((resolve) => {
dialog.warning({
title: active ? "老师正在帮你看这道题" : "你正在排队等老师",
content: active
? "换题之后,老师那边的协作就断了。确定要换吗?"
: "换题之后这次举手就撤掉了,要在新的题目里重新举手。确定要换吗?",
positiveText: "换题",
negativeText: "留在这道题",
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
onMaskClick: () => resolve(false),
})
})
}
onBeforeRouteUpdate(confirmLeavingHelp)
onBeforeRouteLeave(confirmLeavingHelp)
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
const resultTab = () => h(ResultTabLabel)
@@ -187,7 +224,7 @@ onBeforeUnmount(() => {
:secondary="drawer === key"
@click="toggleDrawer(key)"
>
{{ DRAWER_TITLE[key] }}
{{ drawerLabel(key) }}
</n-button>
</n-flex>
</div>
+2
View File
@@ -266,6 +266,8 @@ export interface SubmissionListPayload {
myself?: "1" | "0"
result?: string
username?: string
/** "1" = username 精确匹配(默认是「包含」)。协作中老师看某个学生的提交用 */
exactUsername?: "1"
contestId?: string
problemDisplayId?: string
language: LANGUAGE | ""