feat(协作): 老师协作时右栏顶上一条协作条,结果页签先摆学生最近一次交的,抽屉变成「他的提交」
- 协作条:「正在帮 谁 · 语言 · 看他交过什么 ↗ · 结束协作」,从工具栏里挪出来,一眼看得出 编辑器里是学生的代码。「看他交过什么」带上学生用户名、开新标签(原来只带题号,还得再筛一遍) - 老师接单后「结果」页签原来是空的:现在摆学生在这道题上最近一次交的结果,可刷新; 不给 AI 提示(那是按登录的人自己的失败次数解锁的) - 抽屉「我的提交」在协作中变成「他的提交」,列的是学生的提交,只能复制、不能放回编辑器 (放回去会经协作通道直接盖掉学生正在写的代码),也不显示老师自己的排名 - 学生排队求助中、或者老师正在帮他时换题:先确认一句。换题会断开协作 / 撤掉举手, 原来一点「下一题」就把正在帮他的老师踢出去了 - 求助按钮文案:「举手求助」→「已举手 · 前面 2 人」→「某某 老师在帮你」 - 提交列表加 exactUsername=1:用户名精确匹配。原来只有「包含」,ks24a1 会把 ks24a10 的提交 也捞进来 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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,
|
* 快照实测 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) {
|
export async function usernameFilter(username: string) {
|
||||||
const like = `%${username}%`
|
const like = `%${username}%`
|
||||||
const users = await db
|
const users = await db
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ import { problemSetJoinTimes } from "../services/problemset"
|
|||||||
import { consumeToken } from "../services/throttling"
|
import { consumeToken } from "../services/throttling"
|
||||||
import { dayStart } from "../time"
|
import { dayStart } from "../time"
|
||||||
import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers"
|
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>()
|
export const submissionRoutes = new Hono<ContestEnv>()
|
||||||
|
|
||||||
@@ -489,7 +494,11 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
|||||||
filters.push(
|
filters.push(
|
||||||
...(await Promise.all([
|
...(await Promise.all([
|
||||||
displayId ? problemFilter(displayId, null) : undefined,
|
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))
|
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(() => {
|
const helpButtonText = computed(() => {
|
||||||
if (collabStore.helpStatus === "active") {
|
if (collabStore.helpStatus === "active") {
|
||||||
const name = collabStore.teacherName
|
const name = collabStore.teacherName
|
||||||
return name ? `${name} 老师帮你中` : "老师正在帮你"
|
return name ? `${name} 老师在帮你` : "老师正在帮你"
|
||||||
}
|
}
|
||||||
if (collabStore.helpStatus === "pending") {
|
if (collabStore.helpStatus === "pending") {
|
||||||
return collabStore.queueAhead > 0
|
return collabStore.queueAhead > 0
|
||||||
? `已求助 · 前面 ${collabStore.queueAhead} 人`
|
? `已举手 · 前面 ${collabStore.queueAhead} 人`
|
||||||
: "已求助 · 待接入"
|
: "已举手 · 老师马上来"
|
||||||
}
|
}
|
||||||
return "求助"
|
return "举手求助"
|
||||||
})
|
})
|
||||||
|
|
||||||
const helpButtonType = computed(() => {
|
const helpButtonType = computed(() => {
|
||||||
@@ -301,18 +301,6 @@ const goEdit = () => {
|
|||||||
<n-button :size="buttonSize">更多</n-button>
|
<n-button :size="buttonSize">更多</n-button>
|
||||||
</n-dropdown>
|
</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
|
<n-button
|
||||||
v-if="showHelpButton"
|
v-if="showHelpButton"
|
||||||
:size="buttonSize"
|
: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">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useCollabStore } from "shared/store/collab"
|
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import {
|
import {
|
||||||
DRAWER_TITLE,
|
DRAWER_TITLE,
|
||||||
useProblemPageContext,
|
useProblemPageContext,
|
||||||
type ProblemDrawer,
|
type ProblemDrawer,
|
||||||
} from "../composables/problemPageContext"
|
} from "../composables/problemPageContext"
|
||||||
|
import { useTeacherCollab } from "../composables/teacherCollab"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计 / 点评 / 我的提交。原来是左栏的三个页签,课上几乎没人点,却和「题目」挤在一排;
|
* 统计 / 点评 / 我的提交。原来是左栏的三个页签,课上几乎没人点,却和「题目」挤在一排;
|
||||||
@@ -30,7 +30,6 @@ const ctx = useProblemPageContext()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const collabStore = useCollabStore()
|
|
||||||
const { problem } = storeToRefs(useProblemStore())
|
const { problem } = storeToRefs(useProblemStore())
|
||||||
|
|
||||||
const show = computed({
|
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 =
|
if (teacherCollab.value) {
|
||||||
collabStore.room !== null &&
|
|
||||||
collabStore.room.problemId === problem.value?._id &&
|
|
||||||
userStore.isTeacherOrAbove
|
|
||||||
if (collabHere) {
|
|
||||||
window.open(router.resolve(target).href, "_blank")
|
window.open(router.resolve(target).href, "_blank")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -94,7 +97,7 @@ function goAllSubmissions() {
|
|||||||
:trap-focus="false"
|
:trap-focus="false"
|
||||||
:block-scroll="!props.to"
|
: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'" />
|
<ProblemInfo v-if="lastOpened === 'info'" />
|
||||||
<ProblemReaction v-else-if="lastOpened === 'reaction'" />
|
<ProblemReaction v-else-if="lastOpened === 'reaction'" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { LANGUAGE } from "utils/types"
|
|||||||
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
|
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
|
||||||
import { errorMarkExtensions } from "oj/problem/utils/errorMark"
|
import { errorMarkExtensions } from "oj/problem/utils/errorMark"
|
||||||
import EditorToolbar from "./EditorToolbar.vue"
|
import EditorToolbar from "./EditorToolbar.vue"
|
||||||
|
import CollabBar from "./CollabBar.vue"
|
||||||
import { useFlowchartStore } from "oj/store/flowchart"
|
import { useFlowchartStore } from "oj/store/flowchart"
|
||||||
|
|
||||||
const FlowchartEditor = defineAsyncComponent(
|
const FlowchartEditor = defineAsyncComponent(
|
||||||
@@ -97,9 +98,11 @@ const storageKey = computed(() =>
|
|||||||
: `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
: `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const editorHeight = computed(() =>
|
// 协作条 40px + 和工具栏之间的 12px 间距,要从编辑器里让出来,不然页面底下多出一截滚动条
|
||||||
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)",
|
const editorHeight = computed(() => {
|
||||||
)
|
const base = isDesktop.value ? 133 : 172
|
||||||
|
return `calc(100vh - ${base + (teacherCollab.value ? 52 : 0)}px)`
|
||||||
|
})
|
||||||
|
|
||||||
function loadCode() {
|
function loadCode() {
|
||||||
codeStore.code.language = problemStore.supportedLanguage(codeStore.code.language)
|
codeStore.code.language = problemStore.supportedLanguage(codeStore.code.language)
|
||||||
@@ -166,6 +169,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex vertical>
|
<n-flex vertical>
|
||||||
|
<CollabBar v-if="teacherCollab" />
|
||||||
<EditorToolbar :storage-key="storageKey" @change-language="changeLanguage" />
|
<EditorToolbar :storage-key="storageKey" @change-language="changeLanguage" />
|
||||||
<!--
|
<!--
|
||||||
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
|
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { storeToRefs } from "pinia"
|
|
||||||
import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api"
|
import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api"
|
||||||
import { useCodeStore } from "oj/store/code"
|
import { useCodeStore } from "oj/store/code"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { useCollabStore } from "shared/store/collab"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
@@ -12,6 +10,7 @@ import { copyToClipboard, parseTime } from "utils/functions"
|
|||||||
import type { ProblemRank } from "@oj2/contract"
|
import type { ProblemRank } from "@oj2/contract"
|
||||||
import type { LANGUAGE, SubmissionListItem } from "utils/types"
|
import type { LANGUAGE, SubmissionListItem } from "utils/types"
|
||||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||||
|
import { useTeacherCollab } from "../composables/teacherCollab"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「我的提交」抽屉:这道题自己交过的每一次。
|
* 「我的提交」抽屉:这道题自己交过的每一次。
|
||||||
@@ -24,7 +23,6 @@ import { useProblemPageContext } from "../composables/problemPageContext"
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const codeStore = useCodeStore()
|
const codeStore = useCodeStore()
|
||||||
const collabStore = useCollabStore()
|
const collabStore = useCollabStore()
|
||||||
const { problem } = storeToRefs(useProblemStore())
|
|
||||||
const ctx = useProblemPageContext()
|
const ctx = useProblemPageContext()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -36,12 +34,10 @@ const problemDisplayId = computed(() => String(route.params.problemID ?? ""))
|
|||||||
* 协作中的教师:编辑器里是学生的代码,「放回编辑器」会经 Yjs 直接盖掉学生正在写的
|
* 协作中的教师:编辑器里是学生的代码,「放回编辑器」会经 Yjs 直接盖掉学生正在写的
|
||||||
* 那一份,所以不给(设计文档第 9 节)。
|
* 那一份,所以不给(设计文档第 9 节)。
|
||||||
*/
|
*/
|
||||||
const teacherCollab = computed(
|
const teacherCollab = useTeacherCollab()
|
||||||
() =>
|
|
||||||
userStore.isTeacherOrAbove &&
|
/** 协作中看的是学生的提交(设计文档第 9 节):房间里的 peerName 就是学生的用户名 */
|
||||||
collabStore.room !== null &&
|
const peer = computed(() => (teacherCollab.value ? (collabStore.room?.peerName ?? null) : null))
|
||||||
collabStore.room.problemId === problem.value?._id,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ==================== 班上第几个做对的 ====================
|
// ==================== 班上第几个做对的 ====================
|
||||||
// 只在题库入口给:比赛有自己的排名,题单里整个抽屉都不出现
|
// 只在题库入口给:比赛有自己的排名,题单里整个抽屉都不出现
|
||||||
@@ -98,7 +94,9 @@ const blocked = computed(() => {
|
|||||||
async function listSubmissions() {
|
async function listSubmissions() {
|
||||||
const res = await getSubmissions({
|
const res = await getSubmissions({
|
||||||
...query,
|
...query,
|
||||||
myself: "1",
|
...(peer.value
|
||||||
|
? { username: peer.value, exactUsername: "1" as const }
|
||||||
|
: { myself: "1" as const }),
|
||||||
offset: query.limit * (query.page - 1),
|
offset: query.limit * (query.page - 1),
|
||||||
problemDisplayId: problemDisplayId.value,
|
problemDisplayId: problemDisplayId.value,
|
||||||
contestId: ctx.value.contestId,
|
contestId: ctx.value.contestId,
|
||||||
@@ -165,7 +163,8 @@ function openDetail(id: string) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (blocked.value) return
|
if (blocked.value) return
|
||||||
listSubmissions()
|
listSubmissions()
|
||||||
if (ctx.value.entry === "problem") {
|
// 排名是按当前登录的人算的,协作中那是老师自己的,不给
|
||||||
|
if (ctx.value.entry === "problem" && !peer.value) {
|
||||||
getRankOfProblem(problemDisplayId.value)
|
getRankOfProblem(problemDisplayId.value)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
rank.value = res
|
rank.value = res
|
||||||
@@ -190,7 +189,11 @@ watch(query, () => {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</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">
|
<ul v-else class="list">
|
||||||
<li v-for="(row, index) in submissions" :key="row.id" class="item">
|
<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 { useProblemStore } from "oj/store/problem"
|
||||||
import { useSubmissionStore } from "oj/store/submission"
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
import { useCodeStore } from "oj/store/code"
|
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 SampleRunResult = defineAsyncComponent(() => import("./SampleRunResult.vue"))
|
||||||
const CustomRun = defineAsyncComponent(() => import("./CustomRun.vue"))
|
const CustomRun = defineAsyncComponent(() => import("./CustomRun.vue"))
|
||||||
const FlowchartResult = defineAsyncComponent(() => import("./FlowchartResult.vue"))
|
const FlowchartResult = defineAsyncComponent(() => import("./FlowchartResult.vue"))
|
||||||
|
const PeerLatest = defineAsyncComponent(() => import("./PeerLatest.vue"))
|
||||||
|
|
||||||
const submissionStore = useSubmissionStore()
|
const submissionStore = useSubmissionStore()
|
||||||
const { submission, syntaxErrorInfo, resultSegment } = storeToRefs(submissionStore)
|
const { submission, syntaxErrorInfo, resultSegment } = storeToRefs(submissionStore)
|
||||||
const { problem } = storeToRefs(useProblemStore())
|
const { problem } = storeToRefs(useProblemStore())
|
||||||
const codeStore = useCodeStore()
|
const codeStore = useCodeStore()
|
||||||
|
|
||||||
|
/** 协作中的老师自己还没交过:先摆学生最近一次交的 */
|
||||||
|
const teacherCollab = useTeacherCollab()
|
||||||
|
|
||||||
/** 正在画流程图:结果就是 AI 的点评,没有例子可跑 */
|
/** 正在画流程图:结果就是 AI 的点评,没有例子可跑 */
|
||||||
const drawing = computed(() => codeStore.code.language === "Flowchart")
|
const drawing = computed(() => codeStore.code.language === "Flowchart")
|
||||||
|
|
||||||
@@ -51,6 +56,7 @@ const canTrial = computed(() => {
|
|||||||
<PythonErrorExplain :err-info="syntaxErrorInfo" />
|
<PythonErrorExplain :err-info="syntaxErrorInfo" />
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<SubmissionResult v-else-if="submission" :submission="submission" />
|
<SubmissionResult v-else-if="submission" :submission="submission" />
|
||||||
|
<PeerLatest v-else-if="teacherCollab" />
|
||||||
<n-empty
|
<n-empty
|
||||||
v-else
|
v-else
|
||||||
class="empty"
|
class="empty"
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import { useDark } from "@vueuse/core"
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
submission?: Submission
|
submission?: Submission
|
||||||
|
/**
|
||||||
|
* 看的是别人的提交(协作中的老师看学生最近一次交的):不给 AI 提示 —— 那是按登录的人
|
||||||
|
* 自己的失败次数解锁的 —— 也不给「这节课的下一题」
|
||||||
|
*/
|
||||||
|
peer?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
@@ -89,7 +94,8 @@ const sampleCheck = computed(() => {
|
|||||||
*/
|
*/
|
||||||
const ctx = useProblemPageContext()
|
const ctx = useProblemPageContext()
|
||||||
const showLessonNext = computed(
|
const showLessonNext = computed(
|
||||||
() => props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext,
|
() =>
|
||||||
|
!props.peer && props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext,
|
||||||
)
|
)
|
||||||
|
|
||||||
const msg = computed(() => {
|
const msg = computed(() => {
|
||||||
@@ -130,7 +136,7 @@ const partialCases = computed(() => submissionPartialCases(props.submission))
|
|||||||
// system_error 也要排掉:那是判题机自己崩了,学生代码没毛病,让 AI 去分析
|
// system_error 也要排掉:那是判题机自己崩了,学生代码没毛病,让 AI 去分析
|
||||||
// 只会瞎编一通,后端的失败计数同样不认这个状态。
|
// 只会瞎编一通,后端的失败计数同样不认这个状态。
|
||||||
const showAIHint = computed(() => {
|
const showAIHint = computed(() => {
|
||||||
if (!props.submission) return false
|
if (!props.submission || props.peer) return false
|
||||||
// 比赛题不给提示,和「求助」按钮一致。用 problem.contestId 而不是路由参数:
|
// 比赛题不给提示,和「求助」按钮一致。用 problem.contestId 而不是路由参数:
|
||||||
// 带 contestId 的题目只可能从比赛入口进来(题库列表按 contest_id is null 过滤)。
|
// 带 contestId 的题目只可能从比赛入口进来(题库列表按 contest_id is null 过滤)。
|
||||||
if (problemStore.problem?.contestId != null) return false
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
|||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { useSubmissionStore } from "oj/store/submission"
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
import { useFlowchartStore } from "oj/store/flowchart"
|
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 SubmissionEffects from "./components/SubmissionEffects.vue"
|
||||||
import ResultTabLabel from "./components/ResultTabLabel.vue"
|
import ResultTabLabel from "./components/ResultTabLabel.vue"
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
useProblemPageContext,
|
useProblemPageContext,
|
||||||
type ProblemDrawer as DrawerKey,
|
type ProblemDrawer as DrawerKey,
|
||||||
} from "./composables/problemPageContext"
|
} from "./composables/problemPageContext"
|
||||||
|
import { useTeacherCollab } from "./composables/teacherCollab"
|
||||||
|
|
||||||
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
|
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
|
||||||
// 题库、比赛、题单三种入口用的是同一个编辑器:原来比赛和题单用的是另一个精简版,
|
// 题库、比赛、题单三种入口用的是同一个编辑器:原来比赛和题单用的是另一个精简版,
|
||||||
@@ -88,12 +91,17 @@ watch(
|
|||||||
|
|
||||||
const drawer = ref<DrawerKey | null>(null)
|
const drawer = ref<DrawerKey | null>(null)
|
||||||
|
|
||||||
|
// 协作中的老师看的是学生的提交
|
||||||
|
const teacherCollab = useTeacherCollab()
|
||||||
|
const drawerLabel = (key: DrawerKey) =>
|
||||||
|
key === "submission" && teacherCollab.value ? "他的提交" : DRAWER_TITLE[key]
|
||||||
|
|
||||||
function toggleDrawer(key: DrawerKey) {
|
function toggleDrawer(key: DrawerKey) {
|
||||||
drawer.value = drawer.value === key ? null : key
|
drawer.value = drawer.value === key ? null : key
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawerMenu = computed<DropdownOption[]>(() =>
|
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 接受渲染函数
|
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
|
||||||
const resultTab = () => h(ResultTabLabel)
|
const resultTab = () => h(ResultTabLabel)
|
||||||
|
|
||||||
@@ -187,7 +224,7 @@ onBeforeUnmount(() => {
|
|||||||
:secondary="drawer === key"
|
:secondary="drawer === key"
|
||||||
@click="toggleDrawer(key)"
|
@click="toggleDrawer(key)"
|
||||||
>
|
>
|
||||||
{{ DRAWER_TITLE[key] }}
|
{{ drawerLabel(key) }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -266,6 +266,8 @@ export interface SubmissionListPayload {
|
|||||||
myself?: "1" | "0"
|
myself?: "1" | "0"
|
||||||
result?: string
|
result?: string
|
||||||
username?: string
|
username?: string
|
||||||
|
/** "1" = username 精确匹配(默认是「包含」)。协作中老师看某个学生的提交用 */
|
||||||
|
exactUsername?: "1"
|
||||||
contestId?: string
|
contestId?: string
|
||||||
problemDisplayId?: string
|
problemDisplayId?: string
|
||||||
language: LANGUAGE | ""
|
language: LANGUAGE | ""
|
||||||
|
|||||||
Reference in new issue
Block a user