From 116b0a2c956af593c16c5a2ec2e0ff7e60ef74d0 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 28 Sep 2026 16:02:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=A2=98=E7=9B=AE):=20=E5=B7=A6=E6=A0=8F?= =?UTF-8?q?=E9=A1=B6=E4=B8=8A=E6=A0=87=E5=87=BA=E3=80=8C=E8=BF=99=E8=8A=82?= =?UTF-8?q?=E8=AF=BE=E5=81=9A=E5=AE=8C=E5=87=A0=E9=81=93=E3=80=8D=E3=80=81?= =?UTF-8?q?=E9=A2=98=E5=8D=95=E8=BF=9B=E5=BA=A6=E6=88=96=E6=AF=94=E8=B5=9B?= =?UTF-8?q?=E5=80=92=E8=AE=A1=E6=97=B6=EF=BC=8C=E7=BB=9F=E8=AE=A1=20/=20?= =?UTF-8?q?=E7=82=B9=E8=AF=84=20/=20=E6=88=91=E7=9A=84=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E6=94=B6=E8=BF=9B=E6=8A=BD=E5=B1=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 上下文条(按入口三选一):课堂 = 「这节课 做完 3/5」+ 每道题号 + 上一题 / 下一题; 题单 = 题单名 + 进度 +「第 N 题」+ 上一题 / 下一题;比赛 = 比赛名 + 每道题号 +「还剩 38:12」+ 排名。 题号只放号码,状态用 ✓ / ! 字符区分,不只靠颜色;多于 5 道横向滚动,当前题保持可见。 通过之后当场 +1,不用刷新 - 左栏只剩「题目 / 结果」两个页签;统计 / 点评 / 我的提交变成页签行右侧的按钮,点开是抽屉, 只盖题面、编辑器不动,交完判出结果时自动收起。手机上收进页签行的「⋯」 - 流程图并进「题目」页签:参考流程图默认折叠;自己画到 A/S 的那张缩略显示、可放大, 一进题就能看到(原来要先切到「流程图」这门「语言」),也不再强行切页签 - 「本题已经被你解决啦」两条整宽提示改成标题下的「✓ 已解决 / ! 尝试过」标签; 时间内存限制只写「限时 1 秒」 - 入口差异收成一张表(problemPageContext):题单里不给抽屉、状态标签、相似题; 比赛里没有点评和求助。工具栏的「本题提交」挪到「我的提交」抽屉底部 - 顺手修:没有例子的题点「去自测猫」会抛错;「我的提交」没交过时一片空白 Co-Authored-By: Claude Opus 5.5 --- .../src/oj/problem/components/ContextBar.vue | 309 ++++++++++++++++++ .../oj/problem/components/EditorToolbar.vue | 59 +--- .../src/oj/problem/components/LessonNext.vue | 20 +- .../src/oj/problem/components/MyFlowchart.vue | 66 ++++ .../oj/problem/components/MyFlowchartTab.vue | 42 --- .../oj/problem/components/ProblemChips.vue | 124 +++++++ .../oj/problem/components/ProblemContent.vue | 142 +++++--- .../oj/problem/components/ProblemDrawer.vue | 120 +++++++ .../problem/components/ProblemSubmission.vue | 4 + .../problem/components/SubmissionEffects.vue | 12 +- .../problem/components/SubmissionResult.vue | 12 +- .../problem/composables/problemPageContext.ts | 92 ++++++ apps/web/src/oj/problem/detail.vue | 235 +++++++------ apps/web/src/oj/store/contest.ts | 11 +- apps/web/src/oj/store/lesson.ts | 58 ++++ ...2026-09-28-problem-page-redesign-design.md | 2 +- 16 files changed, 1057 insertions(+), 251 deletions(-) create mode 100644 apps/web/src/oj/problem/components/ContextBar.vue create mode 100644 apps/web/src/oj/problem/components/MyFlowchart.vue delete mode 100644 apps/web/src/oj/problem/components/MyFlowchartTab.vue create mode 100644 apps/web/src/oj/problem/components/ProblemChips.vue create mode 100644 apps/web/src/oj/problem/components/ProblemDrawer.vue create mode 100644 apps/web/src/oj/problem/composables/problemPageContext.ts create mode 100644 apps/web/src/oj/store/lesson.ts diff --git a/apps/web/src/oj/problem/components/ContextBar.vue b/apps/web/src/oj/problem/components/ContextBar.vue new file mode 100644 index 0000000..2d6b29f --- /dev/null +++ b/apps/web/src/oj/problem/components/ContextBar.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/apps/web/src/oj/problem/components/EditorToolbar.vue b/apps/web/src/oj/problem/components/EditorToolbar.vue index 22fbc47..edfd939 100644 --- a/apps/web/src/oj/problem/components/EditorToolbar.vue +++ b/apps/web/src/oj/problem/components/EditorToolbar.vue @@ -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,29 +123,14 @@ 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(() => { const options: DropdownOption[] = [] - // 移动端额外收纳桌面端常驻的两项 - if (!isDesktop.value) { - if (showGoSubmissionButton.value) { - options.push({ - label: "本题提交", - key: "submissions", - }) - } - if (userStore.isTeacherOrAbove) { - options.push({ - label: "课堂统计", - key: "statistics", - }) - } + // 移动端额外收纳桌面端常驻的「课堂统计」。「本题提交」挪到了「我的提交」抽屉的底部 + if (!isDesktop.value && userStore.isTeacherOrAbove) { + options.push({ + label: "课堂统计", + key: "statistics", + }) } if (codeStore.code.language !== "Flowchart") { if (codeStore.code.language !== "SQL") { @@ -179,9 +163,6 @@ const menuOptions = computed(() => { 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 = () => { - - 本题提交 - - { 课堂统计 - + -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(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) - = { + 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 = { + 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], + } + }) +} diff --git a/apps/web/src/oj/problem/detail.vue b/apps/web/src/oj/problem/detail.vue index 46e5718..067936e 100644 --- a/apps/web/src/oj/problem/detail.vue +++ b/apps/web/src/oj/problem/detail.vue @@ -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() +const { problemID, contestID = "" } = defineProps() 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(null) + +function toggleDrawer(key: DrawerKey) { + drawer.value = drawer.value === key ? null : key +} + +const drawerMenu = computed(() => + 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)" > + + diff --git a/apps/web/src/oj/store/contest.ts b/apps/web/src/oj/store/contest.ts index 6951312..1c700e6 100644 --- a/apps/web/src/oj/store/contest.ts +++ b/apps/web/src/oj/store/contest.ts @@ -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, diff --git a/apps/web/src/oj/store/lesson.ts b/apps/web/src/oj/store/lesson.ts new file mode 100644 index 0000000..0b2ea45 --- /dev/null +++ b/apps/web/src/oj/store/lesson.ts @@ -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(null) + let loadedAt = 0 + let loading: Promise | 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 } +}) diff --git a/docs/specs/2026-09-28-problem-page-redesign-design.md b/docs/specs/2026-09-28-problem-page-redesign-design.md index 3be3f6c..a8aa3d9 100644 --- a/docs/specs/2026-09-28-problem-page-redesign-design.md +++ b/docs/specs/2026-09-28-problem-page-redesign-design.md @@ -1,7 +1,7 @@ # OJ2 设计文档:题目详情页重设计 日期:2026-09-28 -状态:**设计已定,未实施**。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」 +状态:**设计已定,第 14 节的 1–4 步已实施**(还没做:5.5 的「我的提交」行内展开与「放回编辑器」、5.2 的例子表格、第 7 节的点评文案与防误触)。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」 那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。效果图表达不了的规则都写在这里, **实施时以本文为准**。