feat(提交详情): 单独打开的一条提交改成左边代码、右边为什么错 + 这道题交的每一次
Deploy / deploy (push) Canceled after 0s

- 原来不显示是哪道题、时间只到日、答案错误不说为什么,测试点表 5 行一模一样
- 右边摆题目页那块例子对比(输入 / 正确输出 / 他的输出,差的字标红),编译失败、运行时错误同提交列表右栏的说法,代码里标出那一行
- 「这道题交了 N 次」常驻右栏,← → 一次次翻,看他是怎么改对的;题单藏着的那几次点不进
- 老师:「他的全部提交」和「⋯」(复制两项、重新判题);测试点方块悬停看用时和内存;判题中自己刷新
- 别人的代码、链接不对原来一直转圈,现在说清楚是哪种;自己在题单布置期内的照样看得到是哪道题、什么结果
- 后端详情多给题目名、班级、题单,`?attempts=1` 带上同题的每一次;看不到别人的回 403 而不是 404;提交者本人也拿得到 contestId(比赛里的题号是比赛自己的)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 01:00:22 +08:00
1 parent 4240a451e4
commit df76e7f101
8 files changed
+1000 -269

No files matched your search

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import type { SubmissionExplain } from "../composables/explain"
/**
* 「第 9 行:这一行末尾少了分号」那一条,原始报错收在右边的「原始报错」里。
* 提交列表右栏和独立的提交详情页共用,说法见 composables/explain.ts
*/
const props = defineProps<{
explain: SubmissionExplain
tone: { background: string; color: string }
}>()
const theme = useThemeVars()
const rawOpen = ref(false)
// 换了一条就收起来
watch(
() => props.explain,
() => (rawOpen.value = false),
)
</script>
<template>
<div class="explain" :style="{ background: tone.background, borderColor: tone.background }">
<div class="explain-line">
<b :style="{ color: tone.color }">
<template v-if="explain.line">第 {{ explain.line }} 行:</template>{{ explain.text }}
</b>
<div class="spacer"></div>
<a v-if="explain.raw" href="#" class="raw-toggle" @click.prevent="rawOpen = !rawOpen">
原始报错
<Icon :icon="rawOpen ? 'ph:caret-up-bold' : 'ph:caret-down-bold'" :width="11" />
</a>
</div>
<pre v-if="rawOpen && explain.raw" class="raw">{{ explain.raw }}</pre>
</div>
</template>
<style scoped>
.explain {
padding: 10px 14px;
border-radius: 6px;
border: 1px solid;
display: flex;
flex-direction: column;
gap: 8px;
}
.explain-line {
display: flex;
align-items: flex-start;
gap: 8px;
line-height: 1.6;
}
.spacer {
flex: 1 1 0;
}
.raw-toggle {
flex: none;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 3px;
color: v-bind("theme.textColor3");
text-decoration: none;
}
.raw {
margin: 0;
max-height: 140px;
overflow: auto;
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -3,9 +3,6 @@ import LockNote from "oj/problemset/components/LockNote.vue"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { getSubmission } from "oj/api"
import { explainCCompileError } from "oj/problem/utils/cError"
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
import { explainRuntimeError } from "oj/problem/utils/runtimeError"
import { useUserStore } from "shared/store/user"
import {
JUDGE_STATUS,
@@ -16,9 +13,11 @@ import {
import { submissionCaseResults } from "utils/functions"
import type { Submission, SubmissionListItem } from "utils/types"
import { useCopySubmission } from "../composables/copySubmission"
import { useSubmissionExplain } from "../composables/explain"
import { useTone } from "../composables/tone"
import { classLabel, submissionTimeText } from "../utils"
import CodeView from "./CodeView.vue"
import ExplainNote from "./ExplainNote.vue"
import StatusPill from "./StatusPill.vue"
import UserName from "shared/components/UserName.vue"
@@ -118,87 +117,7 @@ const locked = computed(() => {
/** 逐个测试点。只有管理员拿得到 info(每个点带 output_md5),学生只有 caseSummary 两个数 */
const cases = computed(() => submissionCaseResults(shown.value?.info))
function stripJudgePath(text: string) {
return text.replace(/\/judger\/run\/[^/"]+\//g, "")
}
/**
* 编译失败 / 运行时错误说清楚「错在第几行、为什么」—— 翻译表和题目页结果区同一份
* (pythonError.ts / cError.ts / runtimeError.ts)。原来的看代码弹框压根不显示报错。
*/
const explain = computed(() => {
const s = shown.value
if (!s) return null
const info = s.statisticInfo
const err = info?.err_info ? stripJudgePath(info.err_info) : ""
switch (s.result) {
case SubmissionStatus.compile_error: {
const ex = !err
? null
: s.language === "Python"
? explainPythonCompileError(err, s.code)
: s.language === "C" || s.language === "C++"
? explainCCompileError(err, s.code, s.language)
: null
if (ex) return { line: ex.line, text: ex.message, raw: err }
return { line: null, text: "编译没通过,看看下面的原始报错。", raw: err }
}
case SubmissionStatus.runtime_error: {
const rt = info?.runtime_error
if (rt) return { line: rt.line ?? null, text: explainRuntimeError(rt, s.code), raw: err }
return { line: null, text: "程序运行到一半出错,停下来了。", raw: err }
}
case SubmissionStatus.cpu_time_limit_exceeded:
case SubmissionStatus.real_time_limit_exceeded:
return {
line: null,
text: "程序跑了太久还没结束:可能有死循环,或者在等一个没有给的输入。",
raw: "",
}
case SubmissionStatus.memory_limit_exceeded:
return { line: null, text: "程序占的内存太多了。", raw: "" }
case SubmissionStatus.system_error:
return { line: null, text: "判题机出错了,不是代码的问题。", raw: err }
case SubmissionStatus.wrong_answer: {
const check = info?.sample_check
if (!check) return null
// 「题目里的」不能省:上面那排格子是测试点,也从 1 编号,只写「例子 1」时老师看到
// 格子 1 是绿的,以为说明写反了(例子 1 是题面样例,和测试点 1 不是一组数据)
const text = check.passed
? "题目里的例子都对,错在没公开的测试点上。"
: `题目里的例子 ${(check.index ?? 0) + 1} 就错了。`
return { line: null, text, raw: "" }
}
case SubmissionStatus.ast_check_failed: {
const failedRules = (info?.ast_results ?? []).filter((rule) => !rule.passed)
const text = failedRules.length
? `答案对了,但没按要求写:${failedRules.map((rule) => rule.description).join(";")}`
: "答案对了,但没按题目要求的写法写。"
return { line: null, text, raw: "" }
}
default:
return null
}
})
const explainTone = computed(() => {
const r = props.row.result
return tone(
r === SubmissionStatus.compile_error ||
r === SubmissionStatus.runtime_error ||
r === SubmissionStatus.ast_check_failed
? "warning"
: r === SubmissionStatus.system_error
? "default"
: "error",
)
})
const rawOpen = ref(false)
watch(
() => props.row.id,
() => (rawOpen.value = false),
)
const { explain, explainTone } = useSubmissionExplain(shown)
function caseColor(result: number) {
if (result === SubmissionStatus.accepted) return tone("success").solid
@@ -312,7 +231,7 @@ function openStandalone() {
v-if="shown"
size="small"
quaternary
title="新页面打开(有测试点明细)"
title="新页面打开(看他这道题交的每一次)"
aria-label="新页面打开"
@click="openStandalone"
>
@@ -398,23 +317,7 @@ function openStandalone() {
</div>
<template v-else>
<div
v-if="explain"
class="explain"
:style="{ background: explainTone.background, borderColor: explainTone.background }"
>
<div class="explain-line">
<b :style="{ color: explainTone.color }">
<template v-if="explain.line">第 {{ explain.line }} 行:</template>{{ explain.text }}
</b>
<div class="spacer"></div>
<a v-if="explain.raw" href="#" class="raw-toggle" @click.prevent="rawOpen = !rawOpen">
原始报错
<Icon :icon="rawOpen ? 'ph:caret-up-bold' : 'ph:caret-down-bold'" :width="11" />
</a>
</div>
<pre v-if="rawOpen && explain.raw" class="raw">{{ explain.raw }}</pre>
</div>
<ExplainNote v-if="explain" :explain="explain" :tone="explainTone" />
<CodeView
v-if="shown"
:code="shown.code"
@@ -624,43 +527,6 @@ function openStandalone() {
color: v-bind("theme.textColor3");
}
.explain {
padding: 10px 14px;
border-radius: 6px;
border: 1px solid;
display: flex;
flex-direction: column;
gap: 8px;
}
.explain-line {
display: flex;
align-items: flex-start;
gap: 8px;
line-height: 1.6;
}
.raw-toggle {
flex: none;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 3px;
color: v-bind("theme.textColor3");
text-decoration: none;
}
.raw {
margin: 0;
max-height: 140px;
overflow: auto;
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.locked,
.placeholder {
flex: 1 1 0;