From 8cd4459964b27c13cf6840feaef16e4fa20cff82 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 2 Sep 2026 01:11:41 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E9=A2=98=E7=9B=AE):=20=E6=A0=B7=E4=BE=8B?= =?UTF-8?q?=E8=AF=95=E8=BF=90=E8=A1=8C=E6=94=BE=E5=87=BA=E3=80=8C=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E4=BC=9A=E8=AF=9D=E3=80=8D=EF=BC=8C=E6=8A=8A=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=98=AF=E6=80=8E=E4=B9=88=E5=96=82=E8=BF=9B=E5=8E=BB?= =?UTF-8?q?=E7=9A=84=E7=94=BB=E7=BB=99=E5=AD=A6=E7=94=9F=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 学生在自己电脑上跑,是「程序停下来等我敲、敲完回车再往下走」;判题狗这边输入 是提前备好、一口气喂进去的,屏幕上只剩对和错。这个落差是入门阶段最常见的困惑, 最痛的落地形态是:照着教程写了 printf("请输入温度:"),算得明明对却一直判错。 试运行时给代码套一层前导,程序每读到输入就原样回显到 stdout,用  包成标记。 回来的输出拆两次用:抠掉标记段是程序真正的输出(判定用,和不套前导时一字不差), 带标记渲染就是一份终端会话。支持 C / C++ / Python3,其余语言照旧直接跑。 只走样例试运行这条路,正式提交判题一个字都不加。 界面上蓝色是喂进去的输入,带虚线下划线的灰字是学生自己打的提示语(一段输出紧 跟着一段输入就是提示语)。把提示语抠掉正好等于期望输出时,把话说死:答案是对 的,删掉提示语就通过。 顺带:样例「测试」的结果不再 2 秒自动复位,按钮固定叫「测试」,通过 / 不通过挪 到旁边的 tag 上。 几个踩出来的坑记在代码注释里:C 不能攒够一行再回显(样例输入不带结尾换行,那个 换行永远等不到,回显会拖到退出时才发生,变成「答案在前、输入在后」);也不能按 字节回显(中文多字节会被标记撕碎);行号回退只能对编译错误全量做,stdout 里混着 学生自己打印的东西,printf("%4d | %s") 这种表格题一改就把人家的输出改错了。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013rpSKCNpVcMhTFhw21YiL7 --- .../oj/problem/components/ProblemContent.vue | 158 +++++++++++---- apps/web/src/utils/judge.ts | 180 +++++++++++++++++- 2 files changed, 296 insertions(+), 42 deletions(-) diff --git a/apps/web/src/oj/problem/components/ProblemContent.vue b/apps/web/src/oj/problem/components/ProblemContent.vue index 3df5476..82cfb5b 100644 --- a/apps/web/src/oj/problem/components/ProblemContent.vue +++ b/apps/web/src/oj/problem/components/ProblemContent.vue @@ -5,6 +5,7 @@ import { storeToRefs } from "pinia" import { useCodeStore } from "oj/store/code" import { useProblemStore } from "oj/store/problem" import { createTestSubmission } from "utils/judge" +import type { TranscriptSegment } from "utils/judge" import { DIFFICULTY } from "utils/constants" import type { Problem, ProblemStatus } from "utils/types" import Copy from "shared/components/Copy.vue" @@ -17,10 +18,19 @@ import SQLDataTable from "./SQLDataTable.vue" type Sample = Problem["samples"][number] & { id: number msg: string + // 终端会话;不支持回显的语言(Java / Go / JS)拿不到,退回只显示 msg + segments: TranscriptSegment[] | null status: ProblemStatus loading: boolean } +/** 会话里的一段在界面上怎么显示 */ +type ShownSegment = { + text: string + // out = 程序真正的输出,in = 喂进去的输入,prompt = 学生自己打的提示语 + kind: "out" | "in" | "prompt" +} + const theme = useThemeVars() const style = computed(() => "color: " + theme.value.primaryColor) const isDark = useDark() @@ -92,6 +102,7 @@ const samples = ref( ...sample, id: index, msg: "", + segments: null, status: "not_test", loading: false, })), @@ -127,6 +138,7 @@ async function test(sample: Sample, index: number) { return { ...sample, msg: res.output, + segments: res.segments, status: status, loading: false, } @@ -134,39 +146,79 @@ async function test(sample: Sample, index: number) { return sample } }) - - const id = setTimeout(() => { - clearTimeout(id) - samples.value = samples.value.map((sample) => { - if (sample.id === index) { - return { - ...sample, - msg: res.output, - status: "not_test", - loading: false, - } - } else { - return sample - } - }) - }, 2000) } -function label(status: ProblemStatus, loading: boolean) { - if (loading) return "测试中" - return { - not_test: "测试", - failed: "不通过", - passed: "通过", - }[status] +// 运行结果不再自动复位后,按钮固定叫「测试」—— +// 一个绿色的「通过」按钮既当状态又当「再跑一次」的入口,容易误读, +// 通过 / 不通过挪到旁边的 tag 上 +const STATUS_TAG = { + not_test: null, + failed: { label: "不通过", type: "error" }, + passed: { label: "通过", type: "success" }, +} as const satisfies Record< + ProblemStatus, + { label: string; type: string } | null +> + +/** + * 一段输出后面紧跟着一段输入,说明它是「请输入半径:」这类提示语。学生在自己电脑上 + * 跑,这句是打给自己看的;到了判题狗这里它一样算进 stdout,是最常见的 WA 原因, + * 所以单独标出来。 + */ +function shownSegments(sample: Sample): ShownSegment[] { + const segments = sample.segments + if (!segments) return [{ text: sample.msg, kind: "out" }] + return segments.map((seg, index) => ({ + // 样例的输入大多不带结尾换行(库里就是 `"700"`),照原样贴出来,下一行输出会 + // 和它挤在一起变成 `700` `7` → `7007`。学生真在终端里敲的话这里有个回车, + // 补上它才是他电脑上看到的样子 —— 只影响显示,判定用的是 msg。 + text: + seg.kind === "input" && !seg.text.endsWith("\n") + ? seg.text + "\n" + : seg.text, + kind: + seg.kind === "input" + ? "in" + : segments[index + 1]?.kind === "input" + ? "prompt" + : "out", + })) } -function type(status: ProblemStatus) { - return { - not_test: "", - failed: "error", - passed: "success", - }[status] as "warning" | "error" | "success" +function hasFedInput(sample: Sample) { + return !!sample.segments?.some((seg) => seg.kind === "input") +} + +/** + * 把提示语抠掉之后正好等于期望输出 —— 这时候能把话说死:答案是对的,删掉就通过。 + * 抠掉还是对不上,就只说提示语也算输出,不误导。 + */ +function hint(sample: Sample) { + if (sample.status !== "failed") return "" + const shown = shownSegments(sample) + if (!shown.some((seg) => seg.kind === "prompt")) return "" + const withoutPrompt = shown + .filter((seg) => seg.kind === "out") + .map((seg) => seg.text) + .join("") + if (withoutPrompt.trim() === sample.output.trim()) { + return "答案本身是对的,只是输出里多了带下划线的提示语 —— 判题狗只对比程序打印的结果,把 input() / printf() 里的提示语删掉就通过了。" + } + return "带下划线的是你自己打印的提示语,判题狗也会把它算进你的输出里。" +} + +function segStyle(kind: ShownSegment["kind"]) { + if (kind === "in") { + return { color: theme.value.infoColor, fontWeight: 600 } + } + if (kind === "prompt") { + return { + color: theme.value.textColor3, + textDecoration: "underline dotted", + textUnderlineOffset: "3px", + } + } + return {} } @@ -311,7 +363,9 @@ function type(status: ProblemStatus) {

- {{ rule.description }} + + {{ rule.description }} + @@ -328,11 +382,18 @@ function type(status: ProblemStatus) {

- {{ label(sample.status, sample.loading) }} + 测试 + + {{ STATUS_TAG[sample.status]!.label }} +
{{ sample.output }}
- -
{{ sample.msg }}
+ +
+ {{ seg.text }} +
+

+ 蓝色那几段是判题狗提前准备好、自动喂给程序的输入 —— + 所以这里不用你敲键盘,程序也不会停下来等。 +

+

{{ hint(sample) }}

@@ -442,6 +518,20 @@ function type(status: ProblemStatus) { font-family: "Monaco"; } +.terminal { + font-size: 14px; + white-space: pre-wrap; + word-break: break-all; + line-height: 1.7; + font-family: Monaco, Consolas, monospace; +} + +.terminalNote { + font-size: 13px; + opacity: 0.75; + margin: 8px 0 0; +} + .status-alert { margin-bottom: 16px; } diff --git a/apps/web/src/utils/judge.ts b/apps/web/src/utils/judge.ts index 614ad85..265168d 100644 --- a/apps/web/src/utils/judge.ts +++ b/apps/web/src/utils/judge.ts @@ -16,16 +16,169 @@ const JUDGE0_LANGUAGE_ID: Partial> = { Python3: 71, } +/** + * 学生在自己电脑上跑,是「程序停下来等我敲、敲完回车再往下走」;判题狗这边输入是 + * 提前备好、一口气喂进去的,屏幕上只剩对和错 —— 这个落差是入门阶段最常见的困惑。 + * + * 所以试运行时给代码套一层前导:程序每读到一段输入,就把它原样回显到 stdout, + * 用 \u001e 包起来当标记。回来的输出拆两次用 —— 抠掉标记段是程序真正的输出(拿去和 + * 期望比对,和不套前导时一模一样),带着标记渲染就是一份终端会话。 + * + * 只走「样例试运行」这条路,正式提交判题一个字都不加。 + */ +const ECHO_MARK = "\u001e" + +// C / C++ 共用:把 stdin 换成逐字节读的流,读到一个字符就立刻回显。 +// +// 两条都是踩出来的: +// - 不能按**字节**回显 —— 中文是多字节,标记插进字节中间会把 UTF-8 撕碎, +// 所以攒够一个完整的 UTF-8 字符再吐。 +// - 也不能攒够一**行**再吐。样例的输入不带结尾换行(库里就是 `"700"`), +// scanf 读完最后一个数字就撞上 EOF,那个 '\\n' 永远等不到,回显只能拖到 +// 程序退出时才发生 —— 而那时 stdout 缓冲区已经先落地了,屏幕上就变成 +// 「答案在前、输入在后」。 +// +// cookie_io_functions_t 要 _GNU_SOURCE,见下面的 compilerOptions。 +const C_PREAMBLE = `#include +#include +static unsigned char _oj_buf[8]; static size_t _oj_len = 0, _oj_need = 0; +static void _oj_emit(void) { + if (!_oj_len) return; + fflush(stdout); + write(1, "\\x1e", 1); write(1, _oj_buf, _oj_len); write(1, "\\x1e", 1); + _oj_len = 0; _oj_need = 0; +} +static ssize_t _oj_read(void *_oj_c, char *_oj_b, size_t _oj_n) { + (void)_oj_c; (void)_oj_n; + ssize_t _oj_k = read(0, _oj_b, 1); + if (_oj_k > 0) { + unsigned char _oj_ch = (unsigned char)_oj_b[0]; + if (_oj_len == 0) { + _oj_need = _oj_ch < 0x80 ? 1 + : (_oj_ch & 0xE0) == 0xC0 ? 2 + : (_oj_ch & 0xF0) == 0xE0 ? 3 + : (_oj_ch & 0xF8) == 0xF0 ? 4 : 1; + } + _oj_buf[_oj_len++] = _oj_ch; + if (_oj_len >= _oj_need || _oj_len >= sizeof(_oj_buf)) _oj_emit(); + } + return _oj_k; +} +__attribute__((constructor)) static void _oj_init(void) { + cookie_io_functions_t _oj_f = {0}; _oj_f.read = _oj_read; + stdin = fopencookie(NULL, "r", _oj_f); + setvbuf(stdin, NULL, _IONBF, 0); +} +__attribute__((destructor)) static void _oj_fini(void) { _oj_emit(); } +` + +const PYTHON_PREAMBLE = `import builtins as _oj_b +_oj_input = _oj_b.input +def _oj_echo(_oj_prompt=""): + _oj_value = _oj_input(_oj_prompt) + _oj_b.print("\\x1e" + _oj_value + "\\n\\x1e", end="") + return _oj_value +_oj_b.input = _oj_echo +` + +// 没列在这里的语言照旧直接跑,只是拿不到终端会话 +const PREAMBLE: Partial> = { + C: C_PREAMBLE, + "C++": C_PREAMBLE, + Python3: PYTHON_PREAMBLE, +} + +/** 终端会话的一段:程序打印的,或是喂进去的输入 */ +export type TranscriptSegment = { kind: "output" | "input"; text: string } + +/** + * 按标记把回来的 stdout 拆成会话片段,同时还原出「程序真正的输出」。 + * 标记不成对时按普通输出处理 —— 学生代码自己打印了 \u001e 也不会把界面搞乱。 + */ +function parseTranscript(raw: string) { + const segments: TranscriptSegment[] = [] + let output = "" + let cursor = 0 + // C 那边是一个字符一对标记(见 C_PREAMBLE 的注释),拆出来会是一长串单字符 + // 片段,合并成整段再交给界面 + const push = (kind: TranscriptSegment["kind"], text: string) => { + const last = segments[segments.length - 1] + if (last && last.kind === kind) last.text += text + else segments.push({ kind, text }) + } + while (cursor < raw.length) { + const open = raw.indexOf(ECHO_MARK, cursor) + if (open === -1) break + const close = raw.indexOf(ECHO_MARK, open + 1) + if (close === -1) break + if (open > cursor) { + const text = raw.slice(cursor, open) + push("output", text) + output += text + } + push("input", raw.slice(open + 1, close)) + cursor = close + 1 + } + if (cursor < raw.length) { + const text = raw.slice(cursor) + push("output", text) + output += text + } + return { segments, output } +} + +/** + * 前导代码把学生代码整体往下推了几行,报错里的行号得减回来,不然学生照着行号 + * 去找,指到的是一段他没写过的代码。 + * + * 分成两个函数是因为**改错地方的代价不一样**:编译错误整段都是编译器说的话, + * 怎么改都安全;stdout 里混着学生程序自己打印的东西,`printf("%4d | %s")` + * 这种表格题一改就把人家的输出改错了,所以那边只认 Python traceback 那一种 + * 极窄的写法。 + */ +function shiftCompileError(text: string, offset: number) { + if (!text || offset <= 0) return text + const back = (n: string) => String(Math.max(1, Number(n) - offset)) + return ( + text + .replace( + /(\.(?:c|cpp|cc|cxx):)(\d+)/g, + (_, head, line) => head + back(line), + ) + // gcc 引用源码那一栏。补空格保持原来的宽度,否则下面那行 ^ 会指歪 + .replace(/^(\s*)(\d+)(\s*\|)/gm, (_, pad, line: string, tail) => { + return pad + back(line).padStart(line.length, " ") + tail + }) + ) +} + +function shiftTraceback(text: string, offset: number) { + if (!text || offset <= 0) return text + return text.replace( + /(File "script\.py", line )(\d+)/g, + (_, head, line) => head + String(Math.max(1, Number(line) - offset)), + ) +} + export async function createTestSubmission(code: Code, input: string) { - const encodedCode = encode(code.value) const id = JUDGE0_LANGUAGE_ID[code.language] if (id === undefined) { - return { status: null, output: `${code.language} 不支持在线试运行` } + return { + status: null, + output: `${code.language} 不支持在线试运行`, + segments: null as TranscriptSegment[] | null, + } } - let compilerOptions = "" - if (id === 50) compilerOptions = "-lm" // 解决 GCC 的链接问题 + const preamble = PREAMBLE[code.language] ?? "" + const offset = preamble ? preamble.split("\n").length - 1 : 0 + const compilerOptions = [ + id === 50 ? "-lm" : "", // 解决 GCC 的链接问题 + preamble && (id === 50 || id === 54) ? "-D_GNU_SOURCE" : "", + ] + .filter(Boolean) + .join(" ") const payload = { - source_code: encodedCode, + source_code: encode(preamble + code.value), language_id: id, stdin: encode(input), redirect_stderr_to_stdout: true, @@ -35,10 +188,21 @@ export async function createTestSubmission(code: Code, input: string) { params: { base64_encoded: true, wait: true }, }) const data = response.data + const compileOutput = shiftCompileError(decode(data.compile_output), offset) + const { segments, output } = parseTranscript( + shiftTraceback(decode(data.stdout), offset), + ) return { status: data.status && data.status.id, - output: [decode(data.compile_output), decode(data.stdout)] - .join("\n") - .trim(), + // 判定用这个:和不套前导时跑出来的一模一样 + output: [compileOutput, output].join("\n").trim(), + // 渲染终端会话用这个;不支持回显的语言给 null。 + // 编译错误当成一段普通输出排在最前面 —— 否则编译不过的时候 stdout 是空的, + // 会话里什么都没有,报错反倒看不见了。 + segments: preamble + ? compileOutput + ? [{ kind: "output" as const, text: compileOutput + "\n" }, ...segments] + : segments + : null, } }