feat(题目): 左栏顶上标出「这节课做完几道」、题单进度或比赛倒计时,统计 / 点评 / 我的提交收进抽屉

- 上下文条(按入口三选一):课堂 = 「这节课 做完 3/5」+ 每道题号 + 上一题 / 下一题;
  题单 = 题单名 + 进度 +「第 N 题」+ 上一题 / 下一题;比赛 = 比赛名 + 每道题号 +「还剩 38:12」+ 排名。
  题号只放号码,状态用 ✓ / ! 字符区分,不只靠颜色;多于 5 道横向滚动,当前题保持可见。
  通过之后当场 +1,不用刷新
- 左栏只剩「题目 / 结果」两个页签;统计 / 点评 / 我的提交变成页签行右侧的按钮,点开是抽屉,
  只盖题面、编辑器不动,交完判出结果时自动收起。手机上收进页签行的「⋯」
- 流程图并进「题目」页签:参考流程图默认折叠;自己画到 A/S 的那张缩略显示、可放大,
  一进题就能看到(原来要先切到「流程图」这门「语言」),也不再强行切页签
- 「本题已经被你解决啦」两条整宽提示改成标题下的「✓ 已解决 / ! 尝试过」标签;
  时间内存限制只写「限时 1 秒」
- 入口差异收成一张表(problemPageContext):题单里不给抽屉、状态标签、相似题;
  比赛里没有点评和求助。工具栏的「本题提交」挪到「我的提交」抽屉底部
- 顺手修:没有例子的题点「去自测猫」会抛错;「我的提交」没交过时一片空白

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-28 16:02:28 +08:00
1 parent 45505e8384
commit 116b0a2c95
16 files changed
+1041 -235

No files matched your search

@@ -0,0 +1,309 @@
<script setup lang="ts">
import { storeToRefs } from "pinia"
import type { RouteLocationRaw } from "vue-router"
import { getProblemSetDetail, getProblemSetProblems } from "oj/api"
import { useContestStore } from "oj/store/contest"
import { useLessonStore } from "oj/store/lesson"
import { useProblemStore } from "oj/store/problem"
import { useUserStore } from "shared/store/user"
import type { ProblemSet, ProblemSetProblem } from "utils/types"
import { useProblemPageContext } from "../composables/problemPageContext"
import ProblemChips, { type ChipItem } from "./ProblemChips.vue"
/**
* 左栏顶上的一条:「这道题是从哪来的」。按入口路由决定是哪一种,最多一条:
*
* - 课堂:这道题在「班里在做」的列表里 —— 做完几道 + 每道的题号 + 上一题 / 下一题
* - 题单:题单名 + 进度 + 第几题 + 上一题 / 下一题
* - 比赛:比赛名 + 每道的题号 + 还剩多久 + 排名
* - 都不是:不显示
*
* 一道题既在这节课里、又是从题单进来的,按入口显示题单条。
* 换题用的都是普通链接,通过之后欠着的那次强制点评由 SubmissionEffects 的路由守卫拦下。
*/
/** 题多于这个数就横向滚动、去掉 ‹ ›:一节课一般 5 道以内,按 5 道设计 */
const CHIPS_FIT = 5
const ctx = useProblemPageContext()
const userStore = useUserStore()
const { problem } = storeToRefs(useProblemStore())
const currentId = computed(() => problem.value?._id ?? "")
/** 刚在这一页上做出来(不是本来就做过):换题时 id 跟着变,那一次不算 */
function onSolvedHere(callback: () => void) {
watch(
() => [problem.value?.id, problem.value?.myStatus] as const,
([id, status], [previousId, previousStatus]) => {
if (id !== undefined && id === previousId && status === 0 && previousStatus !== 0) callback()
},
)
}
// ==================== 课堂 ====================
const lessonStore = useLessonStore()
watch(
() => ctx.value.entry === "problem" && userStore.isAuthed,
(want) => {
// 接口要求登录;没登录就没有这一条
if (want) lessonStore.load()
},
{ immediate: true },
)
const lesson = computed(() => {
if (ctx.value.entry !== "problem" || !userStore.isAuthed) return null
const list = lessonStore.activity?.problems ?? []
const index = list.findIndex(
(item) => item.problemDisplayId.toLowerCase() === currentId.value.toLowerCase(),
)
if (index < 0) return null
const chips: ChipItem[] = list.map((item) => ({
displayId: item.problemDisplayId,
title: item.title,
status:
item.myStatus === "accepted"
? "done"
: item.myStatus === "tried"
? "tried"
: ("none" as const),
to: `/problem/${item.problemDisplayId}`,
}))
return {
chips,
done: list.filter((item) => item.myStatus === "accepted").length,
total: list.length,
previous: chips[index - 1]?.to ?? null,
next: chips[index + 1]?.to ?? null,
}
})
onSolvedHere(() => {
if (ctx.value.entry !== "problem" || !lessonStore.includes(currentId.value)) return
lessonStore.markAccepted(currentId.value)
lessonStore.load(true)
})
// ==================== 题单 ====================
const problemSet = ref<ProblemSet | null>(null)
const setProblems = ref<ProblemSetProblem[]>([])
watch(
() => ctx.value.problemSetId,
async (id) => {
problemSet.value = null
setProblems.value = []
if (!id) return
try {
const [detail, problems] = await Promise.all([
getProblemSetDetail(Number(id)),
getProblemSetProblems(Number(id)),
])
// 拉的这一会儿已经离开了这个题单
if (ctx.value.problemSetId !== id) return
problemSet.value = detail
setProblems.value = problems
} catch {
// 拉不到就没有题单条,不影响做题
}
},
{ immediate: true },
)
const setBar = computed(() => {
if (!problemSet.value) return null
const list = setProblems.value
const index = list.findIndex(
(item) => item.problem._id.toLowerCase() === currentId.value.toLowerCase(),
)
const link = (item: ProblemSetProblem | undefined): RouteLocationRaw | null =>
item
? {
name: "problemset problem",
params: { problemSetId: ctx.value.problemSetId, problemID: item.problem._id },
}
: null
const done = list.filter((item) => item.isCompleted).length
return {
title: problemSet.value.title,
home: { name: "problemset", params: { problemSetId: ctx.value.problemSetId } },
done,
total: list.length,
percentage: list.length ? (done / list.length) * 100 : 0,
position: index >= 0 ? index + 1 : null,
previous: index >= 0 ? link(list[index - 1]) : null,
next: index >= 0 ? link(list[index + 1]) : null,
}
})
// 判题那一路已经记了账(judge/run.ts),这里只是把这一条就地改掉,不用重拉
onSolvedHere(() => {
const item = setProblems.value.find(
(row) => row.problem._id.toLowerCase() === currentId.value.toLowerCase(),
)
if (item) item.isCompleted = true
})
// ==================== 比赛 ====================
const contestStore = useContestStore()
// 比赛页(contest/detail.vue)和比赛题目页是两条并列的路由,不会同时挂着:
// 从比赛页点进来时那边已经 clear 过了,这里自己再拉一遍
// 卸载时路由已经是下一页的了,ctx 里的 contestId 不作数,自己记着
let contestLoaded = false
watch(
() => ctx.value.contestId,
(id) => {
if (!id) return
contestLoaded = true
contestStore.init(id).catch(() => {})
},
{ immediate: true },
)
onBeforeUnmount(() => {
// 停掉倒计时的表
if (contestLoaded) contestStore.clear()
})
const contestBar = computed(() => {
const contest = contestStore.contest
if (ctx.value.entry !== "contest" || !contest || String(contest.id) !== ctx.value.contestId)
return null
const chips: ChipItem[] = contestStore.problems.map((row) => ({
displayId: row._id,
title: row.title,
status: row.status === "passed" ? "done" : row.status === "failed" ? "tried" : "none",
to: `/contest/${ctx.value.contestId}/problem/${row._id}`,
}))
return {
title: contest.title,
chips,
remaining: contestStore.remaining,
home: { name: "contest problems", params: { contestID: ctx.value.contestId } },
rank: { name: "contest rank", params: { contestID: ctx.value.contestId } },
}
})
onSolvedHere(() => {
const row = contestStore.problems.find(
(item) => item._id.toLowerCase() === currentId.value.toLowerCase(),
)
if (row) row.status = "passed"
})
</script>
<template>
<!-- 课堂 -->
<div v-if="lesson" class="context-bar">
<span class="lead">
这节课 <b>做完 {{ lesson.done }}/{{ lesson.total }}</b>
</span>
<ProblemChips :items="lesson.chips" :current="currentId" />
<n-button-group v-if="lesson.total <= CHIPS_FIT" size="small" class="steps">
<n-button :disabled="!lesson.previous" title="上一题" @click="$router.push(lesson.previous!)">
‹
</n-button>
<n-button :disabled="!lesson.next" title="下一题" @click="$router.push(lesson.next!)">
›
</n-button>
</n-button-group>
</div>
<!-- 题单 -->
<div v-else-if="setBar" class="context-bar">
<router-link :to="setBar.home" class="back" :title="`回到题单:${setBar.title}`">
<span aria-hidden="true">‹</span>
<span class="name">{{ setBar.title }}</span>
</router-link>
<n-progress
class="set-progress"
type="line"
:percentage="setBar.percentage"
:show-indicator="false"
:height="6"
status="success"
/>
<span class="lead">
<b>做完 {{ setBar.done }}/{{ setBar.total }}</b>
<template v-if="setBar.position"> · 第 {{ setBar.position }} 题</template>
</span>
<n-button-group size="small" class="steps">
<n-button :disabled="!setBar.previous" title="上一题" @click="$router.push(setBar.previous!)">
‹
</n-button>
<n-button :disabled="!setBar.next" title="下一题" @click="$router.push(setBar.next!)">
›
</n-button>
</n-button-group>
</div>
<!-- 比赛 -->
<div v-else-if="contestBar" class="context-bar">
<router-link :to="contestBar.home" class="back" :title="`回到比赛:${contestBar.title}`">
<span aria-hidden="true">‹</span>
<span class="name">{{ contestBar.title }}</span>
</router-link>
<ProblemChips :items="contestBar.chips" :current="currentId" />
<span v-if="contestBar.remaining" class="lead remaining">
还剩 <b>{{ contestBar.remaining }}</b>
</span>
<span v-else class="lead">已结束</span>
<router-link :to="contestBar.rank" class="steps">
<n-button size="small">排名</n-button>
</router-link>
</div>
</template>
<style scoped>
.context-bar {
height: 44px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 2px;
flex: none;
min-width: 0;
font-size: 14px;
white-space: nowrap;
}
.lead {
flex: none;
}
.remaining b {
font-variant-numeric: tabular-nums;
}
.back {
display: inline-flex;
align-items: center;
gap: 2px;
/* 名字不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */
flex: none;
max-width: 160px;
color: inherit;
text-decoration: none;
font-weight: 600;
}
.back:hover {
text-decoration: underline;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
}
.set-progress {
flex: 1 1 60px;
min-width: 40px;
}
.steps {
flex: none;
margin-left: auto;
}
</style>
@@ -19,6 +19,7 @@ import type { LANGUAGE } from "utils/types"
import { Icon } from "@iconify/vue"
import { NFlex } from "naive-ui"
import SubmitCode from "./SubmitCode.vue"
import { useProblemPageContext } from "../composables/problemPageContext"
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
// 只有老师看得见(下面的弹框挂了 isTeacherOrAbove),静态 import 的话每个学生
@@ -38,8 +39,8 @@ const emit = defineEmits<{
}>()
const message = useMessage()
const route = useRoute()
const router = useRouter()
const ctx = useProblemPageContext()
const userStore = useUserStore()
const codeStore = useCodeStore()
const problemStore = useProblemStore()
@@ -63,8 +64,6 @@ const canRunSamples = computed(
codeStore.code.language !== "SQL",
)
// 计算属性
const isContestMode = computed(() => route.name === "contest problem")
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
// 可见条件沿用原来的 showSyncFeature,再加上「不是教师」——
// 教师端的入口在顶栏,不在题目页
@@ -76,7 +75,7 @@ const showHelpButton = computed(
// 演示模式下协作通道是断开的(见 App.vue),按钮点了也没人收
!userStore.demoMode &&
codeStore.code.language !== "Flowchart" &&
!isContestMode.value,
ctx.value.help,
)
/**
@@ -124,30 +123,15 @@ const toggleHelp = () => {
collabStore.requestHelp(problem.value!._id, codeStore.code.language)
}
const showGoSubmissionButton = computed(() => {
if (isContestMode.value) return true
else if (userStore.isAdminRole) return true
else if (userStore.showSubmissions) return true
else return false
})
const menuOptions = computed<DropdownOption[]>(() => {
const options: DropdownOption[] = []
// 移动端额外收纳桌面端常驻的两项
if (!isDesktop.value) {
if (showGoSubmissionButton.value) {
options.push({
label: "本题提交",
key: "submissions",
})
}
if (userStore.isTeacherOrAbove) {
// 移动端额外收纳桌面端常驻的「课堂统计」。「本题提交」挪到了「我的提交」抽屉的底部
if (!isDesktop.value && userStore.isTeacherOrAbove) {
options.push({
label: "课堂统计",
key: "statistics",
})
}
}
if (codeStore.code.language !== "Flowchart") {
if (codeStore.code.language !== "SQL") {
options.push({
@@ -179,9 +163,6 @@ const menuOptions = computed<DropdownOption[]>(() => {
const handleMenuSelect = (key: string) => {
switch (key) {
case "submissions":
goSubmissions()
break
case "statistics":
statisticPanel.value = true
break
@@ -238,26 +219,14 @@ const goTestCat = () => {
const data = {
lang,
code: codeStore.code.value,
input: problemStore.problem?.samples[0].input,
// 没有例子的题原来在这里抛 TypeError,点了没反应
input: problemStore.problem?.samples[0]?.input ?? "",
}
const base64 = compressToBase64(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank")
}
const goSubmissions = () => {
const name = route.params.contestID ? "contest submissions" : "submissions"
const target = { name, query: { problem: problem.value!._id } }
// 协作中走新标签:教师端「页面即协作现场」,跳走这一页协作就结束了
// (求助会退回排队,但老师还得再接一次)。而「看看这学生都交了什么」恰好是
// 协作时最常点的一个按钮 —— 这是整条工具栏上唯一会跳路由的按钮
if (showCollabBar.value) {
window.open(router.resolve(target).href, "_blank")
return
}
router.push(target)
}
const goEdit = () => {
const url = problem.value!.contestId
? `/admin/contest/${problem.value!.contestId}/problem/edit/${problem.value!.id}`
@@ -294,10 +263,6 @@ const goEdit = () => {
<SubmitCode v-else />
<n-button v-if="isDesktop && showGoSubmissionButton" :size="buttonSize" @click="goSubmissions">
本题提交
</n-button>
<n-button
v-if="isDesktop && userStore.isTeacherOrAbove"
:size="buttonSize"
@@ -306,7 +271,7 @@ const goEdit = () => {
课堂统计
</n-button>
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;移动端再加上本题提交 / 课堂统计 -->
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;移动端再加上课堂统计 -->
<n-dropdown
v-if="menuOptions.length"
trigger="click"
@@ -1,17 +1,16 @@
<script setup lang="ts">
import { getClassActivity } from "oj/api"
import type { ClassActivity } from "utils/types"
import { useLessonStore } from "oj/store/lesson"
/**
* 通过之后告诉学生「这节课的下一题是哪道」。
*
* 72% 的提交一次就通过,每次通过之后学生都得回首页、或者再问一遍题号去找下一道。
* 这节课的题就是首页「班里在做」那几道(老师在课堂看板布置的,或者从同班提交推断的),
* 这里直接拿同一个接口,给出下一道没做完的。
* 这节课的题就是首页「班里在做」那几道,和左栏顶上的课堂条读同一份(lesson store);
* 通过的那一刻课堂条已经把这道标成做完、并且重拉了一次。
*
* 「下一道」是按列表顺序排在这道**后面**、还没通过的第一道,后面都做完了再从头找 ——
* 学生常常跳着做,先做完了第 3 道再回头做第 1 道。当前这道本身不看它的 myStatus:
* 接口是通过之前那一刻的状态,这时候它刚刚才通过。
* 重拉回来之前,它还是通过之前那一刻的状态。
*
* 这道题不在这节课的列表里(学生在做别的)就什么都不显示。
*/
@@ -20,14 +19,13 @@ const props = defineProps<{
problemDisplayId: string
}>()
const activity = ref<ClassActivity | null>(null)
const lessonStore = useLessonStore()
onMounted(async () => {
activity.value = await getClassActivity().catch(() => null)
})
// 一般课堂条早就拉过了,这里只是兜底(旧了才拉)
onMounted(() => lessonStore.load())
const lesson = computed(() => {
const list = activity.value?.problems ?? []
const list = lessonStore.activity?.problems ?? []
const current = props.problemDisplayId.toLowerCase()
const index = list.findIndex((item) => item.problemDisplayId.toLowerCase() === current)
if (index < 0) return null
@@ -37,7 +35,7 @@ const lesson = computed(() => {
return { total: list.length, next: rest[0] ?? null, remaining: rest.length }
})
const label = computed(() => (activity.value?.source === "teacher" ? "老师布置的题" : "这节课的题"))
const label = computed(() => lessonStore.label)
</script>
<template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useMyFlowchartStore } from "shared/store/myFlowchart"
import { useMermaid } from "shared/composables/useMermaid"
/**
* 学生自己画到 A/S 的那张流程图。题面里是缩略的一小块(compact),点「放大看」弹出整张。
* 原来是左栏单独一个「我的流程图」页签,评到 A/S 时还会把学生强行切过去。
*/
const props = defineProps<{ compact?: boolean }>()
const store = useMyFlowchartStore()
const { renderError, renderFlowchart } = useMermaid()
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
watch(
() => store.mermaidCode,
async (code) => {
if (!code) return
await nextTick()
await renderFlowchart(mermaidContainer.value, code)
},
{ immediate: true },
)
</script>
<template>
<div>
<n-alert v-if="renderError" type="error" title="流程图渲染失败" size="small">
{{ renderError }}
</n-alert>
<div
v-show="!renderError"
ref="mermaidContainer"
class="flowchart-container"
:class="{ compact: props.compact }"
></div>
</div>
</template>
<style scoped>
.flowchart-container {
width: 100%;
min-height: 500px;
display: flex;
justify-content: center;
align-items: flex-start;
}
:deep(.flowchart-container > svg) {
width: 100%;
height: auto;
}
.flowchart-container.compact {
min-height: 0;
height: 150px;
align-items: center;
}
/* 缩略图按高度缩:流程图一般是竖长的,按宽度铺满的话 150px 里只看得见开头两个框 */
:deep(.flowchart-container.compact > svg) {
width: auto;
max-width: 100%;
height: 100%;
}
</style>
@@ -1,42 +0,0 @@
<script setup lang="ts">
import { useMyFlowchartStore } from "shared/store/myFlowchart"
import { useMermaid } from "shared/composables/useMermaid"
const store = useMyFlowchartStore()
const { renderError, renderFlowchart } = useMermaid()
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
watch(
() => store.mermaidCode,
async (code) => {
if (!code) return
await nextTick()
await renderFlowchart(mermaidContainer.value, code)
},
{ immediate: true },
)
</script>
<template>
<div style="padding: 8px 0">
<n-alert v-if="renderError" type="error" title="渲染失败" size="small">
{{ renderError }}
</n-alert>
<div v-show="!renderError" ref="mermaidContainer" class="flowchart-container"></div>
</div>
</template>
<style scoped>
.flowchart-container {
width: 100%;
min-height: 500px;
display: flex;
justify-content: center;
align-items: flex-start;
}
:deep(.flowchart-container > svg) {
width: 100%;
height: auto;
}
</style>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import type { RouteLocationRaw } from "vue-router"
/**
* 上下文条里的一排题号(课堂、比赛共用)。只放题号,题目名在鼠标停上去时给。
*
* 状态用图标区分,不只靠颜色:✓ 做完(实心绿)/ ! 做过没对(橙)/ 灰底 没做;当前题描边。
* 一节课一般 5 道以内,一行放得下;多了(比赛、老师一次布置了一长串)就横向滚动,
* 当前题保持在视野里。
*/
export interface ChipItem {
displayId: string
title: string
status: "done" | "tried" | "none"
to: RouteLocationRaw
}
const props = defineProps<{
items: ChipItem[]
current: string
}>()
const theme = useThemeVars()
const STATUS_TEXT = { done: "做完了", tried: "做过,还没对", none: "还没做" } as const
const isCurrent = (item: ChipItem) => item.displayId.toLowerCase() === props.current.toLowerCase()
const scroller = useTemplateRef<HTMLElement>("scroller")
// 换题之后把当前那一颗滚进来。`nearest`:本来就看得见就不动,免得一换题整排跳一下
watch(
() => [props.current, props.items.length],
async () => {
await nextTick()
scroller.value
?.querySelector<HTMLElement>(".chip.current")
?.scrollIntoView({ block: "nearest", inline: "nearest" })
},
{ immediate: true },
)
</script>
<template>
<div ref="scroller" class="chips">
<n-tooltip v-for="item in items" :key="item.displayId" :delay="300">
<template #trigger>
<router-link
:to="item.to"
class="chip"
:class="[item.status, { current: isCurrent(item) }]"
:aria-label="`${item.displayId} ${item.title}(${STATUS_TEXT[item.status]})`"
:aria-current="isCurrent(item) ? 'page' : undefined"
>
<!-- 用字符不用图标:图标是从 Iconify 源现拉的,拉不到时状态就只剩颜色 -->
<span v-if="item.status === 'done'" aria-hidden="true">✓</span>
<span v-else-if="item.status === 'tried'" aria-hidden="true">!</span>
<span>{{ item.displayId }}</span>
</router-link>
</template>
{{ item.displayId }} {{ item.title }} · {{ STATUS_TEXT[item.status] }}
</n-tooltip>
</div>
</template>
<style scoped>
.chips {
display: flex;
align-items: center;
gap: 6px;
flex: 0 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
/* 描边的外圈别被裁掉 */
padding: 3px 2px;
}
.chip {
flex: none;
display: inline-flex;
align-items: center;
gap: 3px;
height: 26px;
padding: 0 9px;
border-radius: 13px;
font-size: 13px;
font-variant-numeric: tabular-nums;
text-decoration: none;
color: v-bind("theme.textColor2");
background-color: rgba(128, 128, 128, 0.12);
border: 1px solid transparent;
}
.chip.done {
color: #fff;
background-color: v-bind("theme.successColor");
}
.chip.tried {
color: v-bind("theme.warningColor");
background-color: rgba(240, 160, 32, 0.12);
border-color: rgba(240, 160, 32, 0.5);
}
.chip.current {
border-color: v-bind("theme.primaryColor");
box-shadow: 0 0 0 1px v-bind("theme.primaryColor");
font-weight: 600;
}
/* 当前题做完了:实心绿,外面再套一圈(中间隔一道底色) */
.chip.done.current {
border-color: v-bind("theme.successColor");
box-shadow:
0 0 0 2px v-bind("theme.bodyColor"),
0 0 0 3px v-bind("theme.successColor");
}
.chip:hover {
filter: brightness(0.95);
}
</style>
@@ -12,13 +12,18 @@ import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { getSimilarProblems } from "oj/api"
import SQLDataTable from "./SQLDataTable.vue"
import { useFlowchartStore } from "oj/store/flowchart"
import { useMyFlowchartStore } from "shared/store/myFlowchart"
import { useUserStore } from "shared/store/user"
import { useProblemPageContext } from "../composables/problemPageContext"
const ProblemFlowchart = defineAsyncComponent(() => import("./ProblemFlowchart.vue"))
const MyFlowchart = defineAsyncComponent(() => import("./MyFlowchart.vue"))
const isDark = useDark()
const route = useRoute()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const problemSetId = computed(() => route.params.problemSetId)
const { problem, languages } = storeToRefs(problemStore)
const ctx = useProblemPageContext()
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
const isSQL = computed(() => !!problem.value?.sqlConfig)
@@ -40,9 +45,8 @@ const similarLoaded = ref(false)
async function loadSimilarProblems() {
if (similarLoaded.value || !problem.value) return
// 比赛题不推荐:接口按 displayId 在**公开题库**里找,比赛题的编号默认是 1/2/3,
// 一般白跑一趟 404,撞上同号公开题时反而会在比赛中把题库列给学生。
if (problem.value.contestId !== null) return
// 比赛、题单里不推荐(理由见 problemPageContext 那张表)
if (!ctx.value.similar) return
try {
similarProblems.value = await getSimilarProblems(problem.value._id)
} catch {
@@ -71,14 +75,43 @@ watch(
{ immediate: true },
)
const hasTriedButNotPassed = computed(() => {
return (
problem.value?.myStatus !== undefined &&
problem.value?.myStatus !== null &&
problem.value?.myStatus !== 0
)
/**
* 标题下的状态标签。原来是题面最上面两条整宽的提示(「本题已经被你解决啦」「尝试过但还没有
* 通过」),占掉一屏里最值钱的那几行。题单里不给(入口差异表)。
*/
const myStatus = computed(() => {
const status = problem.value?.myStatus
if (!ctx.value.statusTag || status === undefined || status === null) return null
return status === 0 ? "solved" : "tried"
})
/** 「限时 1 秒」:学生不需要 ms / MB。1000 → 1,1500 → 1.5 */
const timeLimitText = computed(() => {
const ms = problem.value?.timeLimit ?? 0
return `限时 ${Number((ms / 1000).toFixed(2))} 秒`
})
// ==================== 流程图小节 ====================
// 同一个位置二选一:老师给的参考图(showFlowchart,默认折叠),或者学生自己画到 A/S 的
// 那张(allowFlowchart)。两个开关在后台是互斥的(admin/problem/detail.vue);后端在
// allowFlowchart 为真时会把 mermaidCode 置成 null,不能把标准答案下发给正要自己画图的学生。
const canShowReferenceFlowchart = computed(
() => !!problem.value?.showFlowchart && !!problem.value?.mermaidCode,
)
const myFlowchartStore = useMyFlowchartStore()
const flowchartStore = useFlowchartStore()
const userStore = useUserStore()
const myFlowchartZoom = ref(false)
// 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查
watch(
() => [problem.value?.id, languages.value.includes("Flowchart"), userStore.isAuthed] as const,
([id, canDraw, authed]) => {
if (id && canDraw && authed) flowchartStore.ensureLoaded()
},
{ immediate: true },
)
// 例子只是摆出来。原来每个例子旁有个「测试」按钮(结果只给通过 / 不通过、2 秒后复位),
// 现在试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
const samples = computed(() => problem.value?.samples ?? [])
@@ -98,27 +131,6 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
<template>
<div v-if="problem">
<template v-if="!problemSetId">
<!-- 已通过 -->
<n-alert
class="status-alert"
v-if="problem.myStatus === 0"
type="success"
title="🎉 本 题 已 经 被 你 解 决 啦"
>
</n-alert>
<!-- 尝试过但未通过 -->
<n-alert
class="status-alert"
v-else-if="hasTriedButNotPassed"
type="warning"
title="💪 你已经尝试过这道题,但还没有通过"
>
不要放弃!仔细检查代码逻辑,或者寻求 AI 的帮助获取灵感。
</n-alert>
</template>
<header class="problem-head">
<n-flex align="center" :size="10" :wrap="false">
<n-tag :bordered="false">{{ problem._id }}</n-tag>
@@ -133,9 +145,13 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
>
{{ DIFFICULTY[problem.difficulty] }}
</n-tag>
<n-text depth="3" v-if="!isSQL">
时间限制 {{ problem.timeLimit }} ms · 内存限制 {{ problem.memoryLimit }} MB
</n-text>
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false" round>
✓ 已解决
</n-tag>
<n-tag v-else-if="myStatus === 'tried'" size="small" type="warning" :bordered="false" round>
! 尝试过,还没通过
</n-tag>
<n-text depth="3" v-if="!isSQL">{{ timeLimitText }}</n-text>
</n-flex>
</header>
@@ -188,6 +204,41 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
/>
</template>
<n-collapse v-if="canShowReferenceFlowchart" class="flowchart-section">
<n-collapse-item name="reference">
<template #header>
<span class="title collapse-title">
<Icon icon="vscode-icons:file-type-drawio"></Icon>
参考流程图
</span>
</template>
<ProblemFlowchart />
</n-collapse-item>
</n-collapse>
<section v-else-if="myFlowchartStore.showing" class="flowchart-section">
<n-flex align="center" justify="space-between">
<h3 class="title">
<Icon icon="vscode-icons:file-type-drawio"></Icon>
你画的流程图
</h3>
<n-button text type="primary" size="small" @click="myFlowchartZoom = true">
放大看 ›
</n-button>
</n-flex>
<div class="my-flowchart" role="button" @click="myFlowchartZoom = true">
<MyFlowchart compact />
</div>
<n-modal
v-model:show="myFlowchartZoom"
preset="card"
title="你画的流程图"
:style="{ maxWidth: '900px' }"
>
<MyFlowchart />
</n-modal>
</section>
<template v-if="!isSQL">
<section v-for="(sample, index) of samples" :key="index" class="sample">
<n-flex align="center" justify="space-between" class="sample-head">
@@ -292,7 +343,7 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</div>
<!-- 相似题目推荐 -->
<div v-if="similarProblems.length > 0">
<div v-if="ctx.similar && similarProblems.length > 0">
<n-divider />
<h3 class="title">
<Icon icon="streamline-ultimate-color:like"></Icon>
@@ -447,8 +498,19 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
font-family: Monaco, Consolas, monospace;
}
.status-alert {
margin-bottom: 16px;
.flowchart-section {
margin-top: 20px;
}
.collapse-title {
margin: 0;
}
.my-flowchart {
cursor: zoom-in;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
padding: 8px;
}
.sqlTableName {
@@ -0,0 +1,120 @@
<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"
/**
* 统计 / 点评 / 我的提交。原来是左栏的三个页签,课上几乎没人点,却和「题目」挤在一排;
* 现在收进抽屉,盖住左栏、编辑器不动。
*
* 抽屉里的内容关上就卸载:再打开时「我的提交」要重新拉,刚交的那次才看得见。
*/
const ProblemInfo = defineAsyncComponent(() => import("./ProblemInfo.vue"))
const ProblemReaction = defineAsyncComponent(() => import("./ProblemReaction.vue"))
const ProblemSubmission = defineAsyncComponent(() => import("./ProblemSubmission.vue"))
const open = defineModel<ProblemDrawer | null>({ required: true })
const props = defineProps<{
/** 桌面:挂进左栏(它得是 position: relative);手机:不传,从底下拉起来盖住整屏 */
to?: string
}>()
const ctx = useProblemPageContext()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore())
const show = computed({
get: () => open.value !== null,
set: (value) => {
if (!value) open.value = null
},
})
// 内容和标题都按最后一次打开的来:关的时候有一段滑出去的动画,那时 open 已经是 null 了。
// 关完 n-drawer 自己把内容卸掉
const lastOpened = ref<ProblemDrawer>("info")
watch(open, (key) => {
if (key) lastOpened.value = key
})
// 换题就关:抽屉里是上一道题的统计和提交
watch(
() => problem.value?._id,
() => {
open.value = null
},
)
/**
* 「看这道题所有人的提交」,原来是工具栏上的「本题提交」。可见条件照旧:
* 比赛里总给(看的是这场比赛的提交),题库里要管理员或者开着提交列表。
*/
const showAllSubmissions = computed(
() => ctx.value.entry === "contest" || userStore.isAdminRole || userStore.showSubmissions,
)
function goAllSubmissions() {
const target = {
name: ctx.value.entry === "contest" ? "contest submissions" : "submissions",
params: ctx.value.entry === "contest" ? { contestID: route.params.contestID } : {},
query: { problem: problem.value!._id },
}
// 协作中走新标签:教师端「页面即协作现场」,跳走这一页协作就结束了
// (求助会退回排队,但老师还得再接一次)。而「看看这学生都交了什么」恰好是
// 协作时最常点的一个
const collabHere =
collabStore.room !== null &&
collabStore.room.problemId === problem.value?._id &&
userStore.isTeacherOrAbove
if (collabHere) {
window.open(router.resolve(target).href, "_blank")
return
}
router.push(target)
}
</script>
<template>
<n-drawer
v-model:show="show"
:to="props.to"
:placement="props.to ? 'left' : 'bottom'"
:width="props.to ? '100%' : undefined"
:height="props.to ? undefined : '85%'"
:trap-focus="false"
:block-scroll="!props.to"
>
<n-drawer-content :title="DRAWER_TITLE[lastOpened]" closable :native-scrollbar="false">
<ProblemInfo v-if="lastOpened === 'info'" />
<ProblemReaction v-else-if="lastOpened === 'reaction'" />
<template v-else>
<ProblemSubmission />
<n-button
v-if="showAllSubmissions"
class="all-submissions"
text
type="primary"
@click="goAllSubmissions"
>
看这道题所有人的提交 ›
</n-button>
</template>
</n-drawer-content>
</n-drawer>
</template>
<style scoped>
.all-submissions {
margin-top: 12px;
}
</style>
@@ -88,6 +88,8 @@ const loading = ref(false)
const submissions = ref<SubmissionListItem[]>([])
const total = ref(0)
// 拉回来之前别说「还没交过」
const listed = ref(false)
const query = reactive({
limit: 10,
page: 1,
@@ -127,6 +129,7 @@ async function listSubmissions() {
})
submissions.value = res.results
total.value = res.total
listed.value = true
}
async function getRankOfThisProblem() {
@@ -288,6 +291,7 @@ watch(query, listSubmissions)
</n-flex>
<n-data-table v-if="submissions.length > 0" striped :columns="columns" :data="submissions" />
<n-empty v-else-if="listed" class="tip" description="这道题你还没有交过" />
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template>
@@ -7,6 +7,7 @@ import { useSubmissionStore } from "oj/store/submission"
import { useFireworks } from "oj/problem/composables/useFireworks"
import { useBreakpoints } from "shared/composables/breakpoints"
import { SubmissionStatus } from "utils/constants"
import { useProblemPageContext } from "../composables/problemPageContext"
/**
* 判完之后「该发生什么」:失败计数、标成已解决、烟花、点评、回题单。
@@ -24,11 +25,8 @@ const submissionStore = useSubmissionStore()
const { problem } = storeToRefs(problemStore)
const { submission } = storeToRefs(submissionStore)
const route = useRoute()
const router = useRouter()
// 题目页在题库 / 比赛 / 题单三条路由之间是复用的,所以跟着路由算,不在 setup 时取一次
const contestID = computed(() => (route.params.contestID as string) ?? "")
const problemSetId = computed(() => (route.params.problemSetId as string) ?? "")
const ctx = useProblemPageContext()
const { isDesktop } = useBreakpoints()
const { celebrate } = useFireworks()
@@ -106,7 +104,7 @@ const { start: goToProblemSetDelayed, stop: cancelGoToProblemSet } = useTimeoutF
router.push({
name: "problemset",
params: {
problemSetId: problemSetId.value,
problemSetId: ctx.value.problemSetId,
},
})
},
@@ -176,12 +174,12 @@ watch(
celebrate()
// 3. 弹出评价框。比赛里不打扰;题单里 1.5 秒后要跳回题单页,弹了也会被冲掉
if (!contestID.value && !problemSetId.value) {
if (ctx.value.reviewAfterAccepted) {
reviewOwed.value = true
showCommentPanelDelayed()
}
if (problemSetId.value) {
if (ctx.value.backToProblemSet) {
// 延迟回到题单页面
goToProblemSetDelayed()
}
@@ -19,6 +19,7 @@ import PythonErrorExplain from "./PythonErrorExplain.vue"
import RuntimeErrorExplain from "./RuntimeErrorExplain.vue"
import WrongAnswerExplain from "./WrongAnswerExplain.vue"
import LessonNext from "./LessonNext.vue"
import { useProblemPageContext } from "../composables/problemPageContext"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { useDark } from "@vueuse/core"
@@ -84,14 +85,11 @@ const sampleCheck = computed(() => {
/**
* 通过之后给「这节课的下一题」。比赛有自己的题目列表;从题单入口进来的,通过后
* 1.5 秒会自动跳回题单(SubmitCode 的 goToProblemSetDelayed),这两种都不给
* 1.5 秒会自动跳回题单(SubmissionEffects),这两种都不给
*/
const route = useRoute()
const ctx = useProblemPageContext()
const showLessonNext = computed(
() =>
props.submission?.result === SubmissionStatus.accepted &&
problemStore.problem?.contestId == null &&
!route.params.problemSetId,
() => props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext,
)
const msg = computed(() => {
@@ -208,10 +206,8 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
/>
</template>
</n-alert>
<!-- key 用提交 id:同一道题再交一次、又通过了,要重新拉一遍「还剩几道」 -->
<LessonNext
v-if="showLessonNext && problemStore.problem"
:key="submission.id"
:problem-display-id="problemStore.problem._id"
/>
<n-flex
@@ -0,0 +1,92 @@
import type { RouteLocationNormalizedLoaded } from "vue-router"
/**
* 题目页的三种入口:题库(含课堂)、题单、比赛。同一个 detail.vue 挂在三条路由上。
*
* 入口之间的差异**只在这张表里判断**。原来散在各个组件里各查各的路由:同一件事
* 在题单里是「禁用」、在比赛里是「隐藏」,加一处功能就得把每个组件翻一遍
* (见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 8 节)。
*/
export type ProblemEntry = "problem" | "problemset" | "contest"
export type ProblemDrawer = "info" | "reaction" | "submission"
export const DRAWER_TITLE: Record<ProblemDrawer, string> = {
info: "统计",
reaction: "点评",
submission: "我的提交",
}
interface EntryRules {
/** 页签行右侧的抽屉,按显示顺序 */
drawers: readonly ProblemDrawer[]
/** 标题下的「已解决 / 尝试过」 */
statusTag: boolean
/** 题面末尾的相似题推荐 */
similar: boolean
/** 通过之后给「这节课的下一题」 */
lessonNext: boolean
/** 通过之后 1.5 秒弹强制点评 */
reviewAfterAccepted: boolean
/** 通过之后 1.5 秒回题单 */
backToProblemSet: boolean
/** 举手求助 */
help: boolean
}
const RULES: Record<ProblemEntry, EntryRules> = {
problem: {
drawers: ["info", "reaction", "submission"],
statusTag: true,
similar: true,
lessonNext: true,
reviewAfterAccepted: true,
backToProblemSet: false,
help: true,
},
// 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案
problemset: {
drawers: [],
statusTag: false,
// 点进相似题就离开了题单
similar: false,
lessonNext: false,
// 1.5 秒后要跳回题单页,弹了也会被冲掉
reviewAfterAccepted: false,
backToProblemSet: true,
help: true,
},
contest: {
drawers: ["info", "submission"],
statusTag: true,
// 相似题接口按 displayId 在公开题库里找,比赛题的编号默认是 1/2/3,
// 撞上同号公开题时反而会在比赛中把题库列给学生
similar: false,
lessonNext: false,
reviewAfterAccepted: false,
backToProblemSet: false,
help: false,
},
}
export function problemEntryOf(route: RouteLocationNormalizedLoaded): ProblemEntry {
if (route.name === "contest problem") return "contest"
if (route.name === "problemset problem") return "problemset"
return "problem"
}
/**
* 当前入口和它的规则。跟着路由算、不在 setup 时取一次:三条路由之间是复用同一个页面的。
*/
export function useProblemPageContext() {
const route = useRoute()
return computed(() => {
const entry = problemEntryOf(route)
return {
entry,
contestId: entry === "contest" ? String(route.params.contestID ?? "") : "",
problemSetId: entry === "problemset" ? String(route.params.problemSetId ?? "") : "",
...RULES[entry],
}
})
}
+129 -82
View File
@@ -11,6 +11,12 @@ import { useFlowchartStore } from "oj/store/flowchart"
// 判完之后该发生的事(烟花、点评、回题单)。静态引入:它得在第一次判完之前就挂好
import SubmissionEffects from "./components/SubmissionEffects.vue"
import ResultTabLabel from "./components/ResultTabLabel.vue"
import ContextBar from "./components/ContextBar.vue"
import {
DRAWER_TITLE,
useProblemPageContext,
type ProblemDrawer as DrawerKey,
} from "./composables/problemPageContext"
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
// 题库、比赛、题单三种入口用的是同一个编辑器:原来比赛和题单用的是另一个精简版,
@@ -19,11 +25,7 @@ import ResultTabLabel from "./components/ResultTabLabel.vue"
const loadProblemEditor = () => import("./components/ProblemEditor.vue")
const ProblemEditor = defineAsyncComponent(loadProblemEditor)
const ProblemContent = defineAsyncComponent(() => import("./components/ProblemContent.vue"))
const ProblemInfo = defineAsyncComponent(() => import("./components/ProblemInfo.vue"))
const ProblemSubmission = defineAsyncComponent(() => import("./components/ProblemSubmission.vue"))
const ProblemReaction = defineAsyncComponent(() => import("./components/ProblemReaction.vue"))
const ProblemFlowchart = defineAsyncComponent(() => import("./components/ProblemFlowchart.vue"))
const MyFlowchartTab = defineAsyncComponent(() => import("./components/MyFlowchartTab.vue"))
const ProblemDrawer = defineAsyncComponent(() => import("./components/ProblemDrawer.vue"))
const ResultPane = defineAsyncComponent(() => import("./components/ResultPane.vue"))
interface Props {
@@ -32,7 +34,7 @@ interface Props {
problemSetId?: string
}
const { problemID, contestID = "", problemSetId = "" } = defineProps<Props>()
const { problemID, contestID = "" } = defineProps<Props>()
const errMsg = ref("无数据")
const route = useRoute()
@@ -41,39 +43,19 @@ const router = useRouter()
const problemStore = useProblemStore()
const myFlowchartStore = useMyFlowchartStore()
const { problem } = storeToRefs(problemStore)
const ctx = useProblemPageContext()
const { isMobile, isDesktop } = useBreakpoints()
// tab 选项和面板必须用同一个条件。后端在 allowFlowchart 为真时会把 mermaidCode
// 置成 null(不能把标准答案下发给正要自己画图的学生),只看 showFlowchart 的话,
// 两个开关同时打开就会做出一个「选项存在、面板不存在」的 tab —— URL 里带
// ?tab=flowchart 会选中一个渲染不出任何东西的页签。
const canShowFlowchart = computed(
() => !!problem.value?.showFlowchart && !!problem.value?.mermaidCode,
/**
* 左栏只有「题目 / 结果」两个页签(手机上编辑器也是一个页签,排在中间)。
* 原来的六个页签里,流程图并进了「题目」,统计 / 点评 / 我的提交收进了抽屉 ——
* 见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 4 节。
*/
const tabOptions = computed(() =>
isMobile.value ? ["content", "editor", "result"] : ["content", "result"],
)
const tabOptions = computed(() => {
// 桌面上「结果」紧跟在「题目」后面;手机上编辑器也是一个页签,「结果」排在「代码」后面
const options: string[] = ["content"]
if (!isMobile.value) options.push("result")
if (canShowFlowchart.value) {
options.push("flowchart")
}
if (isMobile.value) {
options.push("editor", "result")
}
options.push("info")
if (!contestID) {
options.push("comment")
}
if (myFlowchartStore.showing) {
options.push("my-flowchart")
}
options.push("submission")
return options
})
const currentTab = ref("content")
watch(
@@ -93,11 +75,33 @@ watch(currentTab, (tab) => {
})
})
// 交上去、或者语法检查没过:切到「结果」。提交按钮在编辑器那边,页签归这一页管
// 「结果」第一次切过去才挂(它带着 DataTable 和 Markdown 渲染),挂上之后切走也不卸载:
// 挂着的错误说明在编辑器里标着红,AI 提示也在渲染
const resultMounted = ref(false)
watch(
currentTab,
(tab) => {
if (tab === "result") resultMounted.value = true
},
{ immediate: true },
)
const drawer = ref<DrawerKey | null>(null)
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 })),
)
// 交上去、或者语法检查没过:切到「结果」,抽屉开着就先关掉。提交按钮在编辑器那边,页签归这一页管
const submissionStore = useSubmissionStore()
watch(
() => submissionStore.resultSeq,
() => {
drawer.value = null
currentTab.value = "result"
},
)
@@ -105,13 +109,6 @@ watch(
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
const resultTab = () => h(ResultTabLabel)
watch(
() => myFlowchartStore.showing,
(showing) => {
if (showing) currentTab.value = "my-flowchart"
},
)
async function init() {
// 「我的流程图」是上一道题的。这道题也画到了 A/S 的话,SubmitFlowchart 查完会再亮出来
myFlowchartStore.hide()
@@ -169,32 +166,53 @@ onBeforeUnmount(() => {
style="height: calc(100vh - 92px)"
>
<template #1>
<n-scrollbar style="height: 100%">
<n-tabs v-model:value="currentTab" type="segment">
<n-tab-pane name="content" tab="题目">
<ProblemContent />
</n-tab-pane>
<!-- 切走也不卸载:挂着的错误说明在编辑器里标着红,AI 提示也在渲染 -->
<n-tab-pane name="result" :tab="resultTab" display-directive="show:lazy">
<ResultPane />
</n-tab-pane>
<n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程图表">
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
<ProblemInfo />
</n-tab-pane>
<n-tab-pane v-if="!contestID" name="comment" tab="题目点评" :disabled="!!problemSetId">
<ProblemReaction />
</n-tab-pane>
<n-tab-pane v-if="myFlowchartStore.showing" name="my-flowchart" tab="我的流程图">
<MyFlowchartTab />
</n-tab-pane>
<n-tab-pane name="submission" tab="我的提交" :disabled="!!problemSetId">
<ProblemSubmission />
</n-tab-pane>
<div class="left-pane">
<ContextBar />
<div class="tab-row">
<n-tabs
v-model:value="currentTab"
type="segment"
class="page-tabs"
@click="drawer = null"
>
<n-tab name="content">题目</n-tab>
<n-tab name="result"><ResultTabLabel /></n-tab>
</n-tabs>
<n-flex v-if="ctx.drawers.length" :size="6" :wrap="false">
<n-button
v-for="key in ctx.drawers"
:key="key"
size="small"
:type="drawer === key ? 'primary' : 'default'"
:secondary="drawer === key"
@click="toggleDrawer(key)"
>
{{ DRAWER_TITLE[key] }}
</n-button>
</n-flex>
</div>
<!--
抽屉挂在页签行下面这一块(position: relative),盖住题面、编辑器不动。
页签行露在外面:同一个按钮再点一下就关、点另一个就换,点「题目 / 结果」也会关
-->
<div id="problem-pane-stack" class="pane-stack">
<!--
两个页签各滚各的:读题读到底下切去看结果,结果不该也停在底下。
v-show 挂在外面这层 div 上 —— 直接挂在 n-scrollbar 上不生效,它自己管根节点的 style
-->
<div v-show="currentTab === 'content'" class="pane-body">
<n-scrollbar content-style="padding-top: 8px">
<ProblemContent />
</n-scrollbar>
</div>
<div v-if="resultMounted" v-show="currentTab === 'result'" class="pane-body">
<n-scrollbar content-style="padding-top: 8px">
<ResultPane />
</n-scrollbar>
</div>
<ProblemDrawer v-model="drawer" to="#problem-pane-stack" />
</div>
</div>
</template>
<template #2>
<ProblemEditor />
@@ -202,32 +220,61 @@ onBeforeUnmount(() => {
</n-split>
<!-- Mobile -->
<n-tabs v-else v-model:value="currentTab" type="segment">
<template v-else>
<ContextBar />
<n-tabs v-model:value="currentTab" type="segment">
<n-tab-pane name="content" tab="题目">
<ProblemContent />
</n-tab-pane>
<n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程">
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane name="editor" tab="代码">
<ProblemEditor />
</n-tab-pane>
<n-tab-pane name="result" :tab="resultTab" display-directive="show:lazy">
<ResultPane />
</n-tab-pane>
<n-tab-pane name="info" tab="统计" :disabled="!!problemSetId">
<ProblemInfo />
</n-tab-pane>
<n-tab-pane v-if="!contestID" name="comment" tab="点评" :disabled="!!problemSetId">
<ProblemReaction />
</n-tab-pane>
<n-tab-pane v-if="myFlowchartStore.showing" name="my-flowchart" tab="我的流程图">
<MyFlowchartTab />
</n-tab-pane>
<n-tab-pane name="submission" tab="提交" :disabled="!!problemSetId">
<ProblemSubmission />
</n-tab-pane>
<template v-if="drawerMenu.length" #suffix>
<n-dropdown trigger="click" :options="drawerMenu" @select="toggleDrawer">
<n-button size="small" quaternary aria-label="统计、点评、我的提交"> ⋯ </n-button>
</n-dropdown>
</template>
</n-tabs>
<ProblemDrawer v-model="drawer" />
</template>
</template>
<n-empty v-else :description="errMsg"></n-empty>
</template>
<style scoped>
.left-pane {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tab-row {
flex: none;
height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-tabs {
width: 180px;
flex: none;
}
.pane-stack {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
}
.pane-body {
height: 100%;
}
</style>
+10 -1
View File
@@ -2,7 +2,7 @@ import { errorCode } from "utils/api"
import { formatISO, getTime, parseISO } from "date-fns"
import { useUserStore } from "shared/store/user"
import { ContestStatus, ContestType } from "utils/constants"
import { duration } from "utils/functions"
import { duration, secondsToDuration } from "utils/functions"
import type { Contest, ProblemRow } from "utils/types"
import { checkContestPassword, getContest, getContestAccess, getContestProblems } from "../api"
@@ -40,6 +40,14 @@ export const useContestStore = defineStore("contest", () => {
}
})
/** 进行中还剩多久,「38:12」「1:05:00」这种;不在进行中为空串。题目页上下文条用 */
const remaining = computed(() => {
if (contestStatus.value !== ContestStatus.underway) return ""
const end = getTime(parseISO(contest.value!.endTime.toString()))
const text = secondsToDuration(Math.max(0, Math.floor((end - now.value) / 1000)))
return text.startsWith("0:") ? text.slice(2) : text
})
const isContestAdmin = computed(
() =>
userStore.isSuperAdmin ||
@@ -112,6 +120,7 @@ export const useContestStore = defineStore("contest", () => {
problems,
isPrivate,
countdown,
remaining,
init,
clear,
checkPassword,
+58
View File
@@ -0,0 +1,58 @@
import { defineStore } from "pinia"
import { getClassActivity } from "oj/api"
import type { ClassActivity, ClassActivityProblem } from "utils/types"
/** 同一节课里来回换题不用每次都重拉;过了这么久再进题目页就重拉一次 */
const STALE_MS = 2 * 60 * 1000
/**
* 「这节课的题」:首页「班里在做」那几道(老师在课堂看板布置的,或者从同班提交推断的)。
*
* 题目页有两处要用:顶部的课堂条(做完几道、每道的状态),和通过之后的「下一题」。
* 原来「下一题」每次通过都自己拉一遍,现在两处读同一份,通过之后由判题那边
* (SubmissionEffects)先把这道就地标成做完、再重拉一次。
*/
export const useLessonStore = defineStore("lesson", () => {
const activity = ref<ClassActivity | null>(null)
let loadedAt = 0
let loading: Promise<void> | null = null
/** 没拉过或者旧了才拉;force 用在刚通过一道题之后 */
function load(force = false) {
if (!force && activity.value && Date.now() - loadedAt < STALE_MS) return loading
loading = getClassActivity()
.then((res) => {
activity.value = res
loadedAt = Date.now()
})
// 拉不到就没有课堂条,不影响做题
.catch(() => {})
.finally(() => {
loading = null
})
return loading
}
/** 刚通过:不等重拉,先把这道标成做完,课堂条上的「做完 N/M」马上加一 */
function markAccepted(problemDisplayId: string) {
const item = find(problemDisplayId)
if (item) item.myStatus = "accepted"
}
function find(problemDisplayId: string): ClassActivityProblem | undefined {
const id = problemDisplayId.toLowerCase()
return activity.value?.problems.find((item) => item.problemDisplayId.toLowerCase() === id)
}
/** 这道题在不在这节课里;不在(学生在做别的)就没有课堂条、也没有「下一题」 */
function includes(problemDisplayId: string) {
return !!find(problemDisplayId)
}
/** 老师布置的叫「老师布置的题」,推断出来的叫「这节课的题」 */
const label = computed(() =>
activity.value?.source === "teacher" ? "老师布置的题" : "这节课的题",
)
return { activity, label, load, markAccepted, includes }
})
@@ -1,7 +1,7 @@
# OJ2 设计文档:题目详情页重设计
日期:2026-09-28
状态:**设计已定,未实施**。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
状态:**设计已定,第 14 节的 1–4 步已实施**(还没做:5.5 的「我的提交」行内展开与「放回编辑器」、5.2 的例子表格、第 7 节的点评文案与防误触)。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。效果图表达不了的规则都写在这里,
**实施时以本文为准**。