From 45505e83843cdfde565e8f33fceca2502bf6e81f Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 28 Sep 2026 14:35:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=A2=98=E7=9B=AE):=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=A0=8F=E5=8A=A0=E3=80=8C=E8=BF=90=E8=A1=8C=E4=BE=8B=E5=AD=90?= =?UTF-8?q?=E3=80=8D=E3=80=81=E7=BB=93=E6=9E=9C=E9=A1=B5=E7=AD=BE=E5=8A=A0?= =?UTF-8?q?=E3=80=8C=E8=87=AA=E5=B7=B1=E8=BE=93=E5=85=A5=E3=80=8D=EF=BC=8C?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E6=AF=8F=E4=B8=AA=E4=BE=8B=E5=AD=90=E6=97=81?= =?UTF-8?q?=E7=9A=84=E3=80=8C=E6=B5=8B=E8=AF=95=E3=80=8D=E5=92=8C=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E7=9A=84=E5=88=86=E6=A0=8F=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工具栏「运行例子」:一键用全部例子试跑(Judge0,不算提交),结果进「结果 › 运行例子」, 每个例子 ✓ / ✕,没对上的沿用答案错误那套中文说明;比赛里只摆输出、不给提示句 (后端在比赛里故意不做例子对比);SQL 题不给 - 「结果」页签分成提交结果 / 运行例子 / 自己输入三段;「自己输入」顶替顶栏「自测」模式 那个单独的编辑器 - 试跑出错(编译、运行、超时)给中文说明,Judge0 的英文原文折叠起来 - 例子比对两边都去掉末尾空白:原来只 trim 了运行输出,样例末尾带空格的题永远「不通过」 - 去掉题面里每个例子旁的「测试」、顶栏的「双栏 / 自测 / 题目」切换、EditorForTest 和 screenMode store Co-Authored-By: Claude Opus 5.5 --- apps/web/CLAUDE.md | 2 +- .../src/oj/problem/components/CustomRun.vue | 95 +++++++++++ .../oj/problem/components/EditorForTest.vue | 145 ---------------- .../oj/problem/components/EditorToolbar.vue | 25 +++ .../oj/problem/components/ProblemContent.vue | 116 ++----------- .../src/oj/problem/components/ResultPane.vue | 59 +++++-- .../oj/problem/components/SampleRunResult.vue | 161 ++++++++++++++++++ .../oj/problem/components/TrialErrorNote.vue | 49 ++++++ .../problem/components/WrongAnswerExplain.vue | 9 +- .../src/oj/problem/composables/useTrialRun.ts | 137 +++++++++++++++ apps/web/src/oj/problem/detail.vue | 58 +------ apps/web/src/oj/store/submission.ts | 29 +++- apps/web/src/shared/components/Header.vue | 9 - .../src/shared/components/HelpRequestList.vue | 7 - apps/web/src/shared/store/screenMode.ts | 29 ---- apps/web/src/utils/constants.ts | 6 - 16 files changed, 562 insertions(+), 374 deletions(-) create mode 100644 apps/web/src/oj/problem/components/CustomRun.vue delete mode 100644 apps/web/src/oj/problem/components/EditorForTest.vue create mode 100644 apps/web/src/oj/problem/components/SampleRunResult.vue create mode 100644 apps/web/src/oj/problem/components/TrialErrorNote.vue create mode 100644 apps/web/src/oj/problem/composables/useTrialRun.ts delete mode 100644 apps/web/src/shared/store/screenMode.ts diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index d7a7359..42ba7e1 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -58,7 +58,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap 跨端的(登录、资料、标签、验证码)在 `shared/api.ts`。 Shared logic lives in `shared/`: -- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `screenMode` (problem split-screen layout), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (流程图弹窗的 mermaid 源码) +- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (流程图弹窗的 mermaid 源码) - `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `hiddenStudents` (统计面板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity` - `layout/` — `default.vue` and `admin.vue` layout wrappers - `api.ts` — shared API calls (auth, profile, tags, captcha) diff --git a/apps/web/src/oj/problem/components/CustomRun.vue b/apps/web/src/oj/problem/components/CustomRun.vue new file mode 100644 index 0000000..41d8c1e --- /dev/null +++ b/apps/web/src/oj/problem/components/CustomRun.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/apps/web/src/oj/problem/components/EditorForTest.vue b/apps/web/src/oj/problem/components/EditorForTest.vue deleted file mode 100644 index d3aaba4..0000000 --- a/apps/web/src/oj/problem/components/EditorForTest.vue +++ /dev/null @@ -1,145 +0,0 @@ - - - - - diff --git a/apps/web/src/oj/problem/components/EditorToolbar.vue b/apps/web/src/oj/problem/components/EditorToolbar.vue index 625ef84..22fbc47 100644 --- a/apps/web/src/oj/problem/components/EditorToolbar.vue +++ b/apps/web/src/oj/problem/components/EditorToolbar.vue @@ -4,6 +4,7 @@ import { copyToClipboard, compressToBase64 } from "utils/functions" import { useCodeStore } from "oj/store/code" import { useProblemStore } from "oj/store/problem" import { useCollabStore } from "shared/store/collab" +import { useSubmissionStore } from "oj/store/submission" import { ICON_SET, LANGUAGE_FORMAT_VALUE, @@ -48,6 +49,20 @@ const { isDesktop } = useBreakpoints() const statisticPanel = ref(false) +/** + * 「运行例子」:用题目里的例子试跑(Judge0),结果在左栏「结果」页签。顶替原来题面里 + * 每个例子旁的「测试」按钮 —— 那个在左栏、离编辑器远,只给通过 / 不通过、2 秒后复位。 + * Judge0 跑不了 SQL,流程图也没得跑;没有例子的题不给。 + */ +const submissionStore = useSubmissionStore() +const { samplesRunning } = submissionStore.trial +const canRunSamples = computed( + () => + !!problem.value?.samples.length && + codeStore.code.language !== "Flowchart" && + codeStore.code.language !== "SQL", +) + // 计算属性 const isContestMode = computed(() => route.name === "contest problem") const buttonSize = computed(() => (isDesktop.value ? "medium" : "small")) @@ -265,6 +280,16 @@ const goEdit = () => { @update:value="changeLanguage" /> + + 运行例子 + + diff --git a/apps/web/src/oj/problem/components/ProblemContent.vue b/apps/web/src/oj/problem/components/ProblemContent.vue index 8214a61..eaba017 100644 --- a/apps/web/src/oj/problem/components/ProblemContent.vue +++ b/apps/web/src/oj/problem/components/ProblemContent.vue @@ -1,12 +1,11 @@ @@ -521,10 +433,6 @@ function type(status: ProblemStatus) { padding: 6px 10px 10px; } -.sample-result { - margin-top: 8px; -} - .sample-label { font-size: 12px; opacity: 0.7; diff --git a/apps/web/src/oj/problem/components/ResultPane.vue b/apps/web/src/oj/problem/components/ResultPane.vue index 978f896..77a174d 100644 --- a/apps/web/src/oj/problem/components/ResultPane.vue +++ b/apps/web/src/oj/problem/components/ResultPane.vue @@ -1,34 +1,59 @@ @@ -37,6 +62,10 @@ const { submission, syntaxErrorInfo } = storeToRefs(useSubmissionStore()) padding: 4px 0 16px; } +.segments { + margin-bottom: 14px; +} + .empty { margin-top: 48px; } diff --git a/apps/web/src/oj/problem/components/SampleRunResult.vue b/apps/web/src/oj/problem/components/SampleRunResult.vue new file mode 100644 index 0000000..f542d0e --- /dev/null +++ b/apps/web/src/oj/problem/components/SampleRunResult.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/apps/web/src/oj/problem/components/TrialErrorNote.vue b/apps/web/src/oj/problem/components/TrialErrorNote.vue new file mode 100644 index 0000000..1c35f92 --- /dev/null +++ b/apps/web/src/oj/problem/components/TrialErrorNote.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/apps/web/src/oj/problem/components/WrongAnswerExplain.vue b/apps/web/src/oj/problem/components/WrongAnswerExplain.vue index 80797e9..98eb764 100644 --- a/apps/web/src/oj/problem/components/WrongAnswerExplain.vue +++ b/apps/web/src/oj/problem/components/WrongAnswerExplain.vue @@ -15,6 +15,12 @@ const props = defineProps<{ check: SampleCheck code: string language: string + /** + * 只摆输入、正确输出、你的输出,不给中文提示句。比赛里的「运行例子」用:后端在比赛里 + * 故意不做例子对比(judge/run.ts:比赛里有期末考试,格式提示在那里不该给), + * 前端试跑也不能绕过去 + */ + plain?: boolean }>() /** 后端每段截到 2000 字,到了上限就是被截过 */ @@ -61,7 +67,8 @@ function lines(text: string) {
- 例子 {{ (check.index ?? 0) + 1 }} 没有通过:{{ hint }} + 例子 {{ (check.index ?? 0) + 1 }} 没有通过{{ plain ? "。" : ":" }}
diff --git a/apps/web/src/oj/problem/composables/useTrialRun.ts b/apps/web/src/oj/problem/composables/useTrialRun.ts new file mode 100644 index 0000000..d2ca8b3 --- /dev/null +++ b/apps/web/src/oj/problem/composables/useTrialRun.ts @@ -0,0 +1,137 @@ +import { createTestSubmission } from "utils/judge" +import { SubmissionStatus } from "utils/constants" +import type { Code } from "utils/types" + +/** + * 试跑:「运行例子」和「自己输入」。走 Judge0(utils/judge.ts),不经过判题机、不算提交。 + * + * 编译器版本和判题机不一样(Judge0 的 C 是 GCC 9,判题机是 gcc-14 加宽松参数), + * 所以这里的结果偶尔会和提交不一致 —— 这是拍板过的取舍(设计文档第 3 节决定 3)。 + */ + +/** Judge0 的状态码(见 Judge0 的 /statuses)。同步调用(wait=true)只会拿到终态 */ +const JUDGE0_ACCEPTED = 3 +const JUDGE0_TIME_LIMIT = 5 +const JUDGE0_COMPILE_ERROR = 6 +const isJudge0RuntimeError = (status: number) => status >= 7 && status <= 12 + +/** + * 把 Judge0 的状态换成判题机那套码,好直接交给 WrongAnswerExplain 和 JUDGE_STATUS。 + * 跑完、只是输出对不上的,按答案错误算。 + */ +export function trialStatus(status: number | null, passed: boolean) { + if (status === JUDGE0_ACCEPTED) { + return passed ? SubmissionStatus.accepted : SubmissionStatus.wrong_answer + } + if (status === JUDGE0_TIME_LIMIT) return SubmissionStatus.real_time_limit_exceeded + if (status === JUDGE0_COMPILE_ERROR) return SubmissionStatus.compile_error + if (status !== null && isJudge0RuntimeError(status)) return SubmissionStatus.runtime_error + return SubmissionStatus.system_error +} + +/** + * 判题机比的是去掉末尾空白之后的输出(JudgeServer 的 stripped_output_md5), + * 这里两边都去。原来题目页的「测试」按钮只 trim 了运行输出,样例输出末尾带个 + * 空格或换行,就永远显示「不通过」。 + */ +const sameOutput = (output: string, expected: string) => output.trimEnd() === expected.trimEnd() + +export interface SampleRun { + /** 第几个例子,0 起 */ + index: number + input: string + expected: string + output: string + /** 换算成判题机那套码之后的结果,见 trialStatus */ + result: SubmissionStatus +} + +export function useTrialRun() { + const sampleRuns = ref([]) + const samplesRunning = ref(false) + /** 最近一次运行例子是什么时候(ISO),显示在结果页签里 */ + const samplesRunAt = ref(null) + /** 跑例子时用的那份代码:错因说明要看代码(比如有没有 input("提示文字")),编辑器里可能已经改了 */ + const samplesCode = ref(null) + + const customInput = ref("") + const customOutput = ref("") + const customResult = ref(null) + const customRunning = ref(false) + + /** + * 每次开跑 +1。跑的这一会儿换了题、或者又点了一次,旧的那批回来就扔掉。 + * 两边各记各的:一边在跑的时候点另一边,不能把这边的结果也扔了、转圈转个没完 + */ + let samplesGeneration = 0 + let customGeneration = 0 + + async function runOne(code: Code, input: string) { + try { + return await createTestSubmission(code, input) + } catch { + return { status: null, output: "" } + } + } + + async function runSamples(code: Code, samples: { input: string; output: string }[]) { + const mine = ++samplesGeneration + samplesRunning.value = true + // 例子一般两三个,一起发 + const outputs = await Promise.all(samples.map((sample) => runOne(code, sample.input))) + if (mine !== samplesGeneration) return + sampleRuns.value = samples.map((sample, index) => { + const run = outputs[index]! + const passed = run.status === JUDGE0_ACCEPTED && sameOutput(run.output, sample.output) + return { + index, + input: sample.input, + expected: sample.output, + output: run.output, + result: trialStatus(run.status, passed), + } + }) + samplesRunAt.value = new Date().toISOString() + samplesCode.value = code + samplesRunning.value = false + } + + async function runCustom(code: Code) { + const mine = ++customGeneration + customRunning.value = true + const run = await runOne(code, customInput.value) + if (mine !== customGeneration) return + customOutput.value = run.output + // 自己输入的没有标准答案,跑完就算「对」,只看有没有出错 + customResult.value = trialStatus(run.status, true) + customRunning.value = false + } + + /** 换题:两边都清掉。输入框也清 —— 那是给上一道题编的数据 */ + function resetTrial() { + samplesGeneration++ + customGeneration++ + sampleRuns.value = [] + samplesRunning.value = false + samplesRunAt.value = null + samplesCode.value = null + customInput.value = "" + customOutput.value = "" + customResult.value = null + customRunning.value = false + } + + return { + sampleRuns, + samplesRunning, + samplesRunAt, + samplesCode, + customInput, + customOutput, + customResult, + customRunning, + runSamples, + runCustom, + resetTrial, + } +} diff --git a/apps/web/src/oj/problem/detail.vue b/apps/web/src/oj/problem/detail.vue index 68cb644..46e5718 100644 --- a/apps/web/src/oj/problem/detail.vue +++ b/apps/web/src/oj/problem/detail.vue @@ -4,7 +4,6 @@ import { getProblem } from "oj/api" import { useBreakpoints } from "shared/composables/breakpoints" import { storeToRefs } from "pinia" import { useProblemStore } from "oj/store/problem" -import { useScreenModeStore } from "shared/store/screenMode" import { useMyFlowchartStore } from "shared/store/myFlowchart" import { useUserStore } from "shared/store/user" import { useSubmissionStore } from "oj/store/submission" @@ -19,7 +18,6 @@ import ResultTabLabel from "./components/ResultTabLabel.vue" // 点了没反应(见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 10 节) const loadProblemEditor = () => import("./components/ProblemEditor.vue") const ProblemEditor = defineAsyncComponent(loadProblemEditor) -const EditorForTest = defineAsyncComponent(() => import("./components/EditorForTest.vue")) const ProblemContent = defineAsyncComponent(() => import("./components/ProblemContent.vue")) const ProblemInfo = defineAsyncComponent(() => import("./components/ProblemInfo.vue")) const ProblemSubmission = defineAsyncComponent(() => import("./components/ProblemSubmission.vue")) @@ -41,10 +39,8 @@ const route = useRoute() const router = useRouter() const problemStore = useProblemStore() -const screenModeStore = useScreenModeStore() const myFlowchartStore = useMyFlowchartStore() const { problem } = storeToRefs(problemStore) -const { shouldShowProblem } = storeToRefs(screenModeStore) const { isMobile, isDesktop } = useBreakpoints() @@ -117,7 +113,6 @@ watch( ) async function init() { - screenModeStore.resetScreenMode() // 「我的流程图」是上一道题的。这道题也画到了 A/S 的话,SubmitFlowchart 查完会再亮出来 myFlowchartStore.hide() // 并行预取右侧编辑器 chunk(CodeMirror ~370K+), @@ -138,7 +133,7 @@ watch(() => problemID, init) // 题目详情里的 myStatus / myFailedCount 是按当前用户算的,而登录不重新挂载这个页面 —— // 会话过期后直接在题目页登录的(机房里最常见的那条路)不补拉一次,AI 提示的解锁进度 -// 就还是匿名时的 0,等于白改。只换 problem,不走 init:那里还会重置分栏模式。 +// 就还是匿名时的 0,等于白改。只换 problem,不走 init:那里会把整页重来一遍。 watch( () => useUserStore().isAuthed, async (authed) => { @@ -158,30 +153,15 @@ onBeforeUnmount(() => { useFlowchartStore().reset() problem.value = null errMsg.value = "无数据" - screenModeStore.resetScreenMode() myFlowchartStore.hide() }) - -watch(isMobile, (value) => { - if (value) screenModeStore.resetScreenMode() -}) - -// SQL 题不支持"自测"模式(外部代码运行器无法执行 SQL),切到该屏时自动跳到下一模式 -watch( - () => screenModeStore.isCodeOnlyMode, - (codeOnly) => { - if (codeOnly && problem.value?.languages.includes("SQL")) { - screenModeStore.switchScreenMode() - } - }, -)