Files
OJ2/apps/web/src/oj/problem/components/SubmissionResult.vue
T
xuyueandClaude Opus 5.5 e7b95d211d fix(题目): 修掉题目页重构审查查出的问题——提交途中换题会把结果算到新题上、运行例子把对的菱形判错、点评完「下一题」不跳等
题目页重构之后按「功能对照 / 状态 / 结果页 / 布局与后端」四路审查了一遍,查出的问题一起修:

提交与换题
- 提交、画流程图途中换题:A 题格式化好的代码会盖掉 B 的草稿,A 判对了把 B 标成已解决、
  弹 B 的点评、题单里跳回题单。现在每个请求回来先核对题目,轮询和推送回来的也核对提交编号
- 画着流程图换到不能画的题,语言退到题目的第一门、还被记成偏好;现在回到学生习惯的那门
- 强制点评评完点「继续 / 下一题」,学生之前在看「题目」页签的话会停在原题
- 离开题目页之后判题、流程图的 WebSocket 一直连着,现在闲 15 分钟断开

运行例子
- 输出开头的空格被削掉,打印菱形这类题对的代码也显示「没对上」
- C 的编译警告拼进了「你的输出」(后台生成测试点也会写进 .out)
- Python 的语法错误(中文标点)被说成「下标有没有越界、除数是不是 0」
- 比赛里仍然把空格画成「·」、把不同的地方标红

入口与协作
- 比赛里不给「课堂统计」、不绑协作:房间和统计都按题号找,比赛题号和公开题撞号
- 「看他交过什么」按整名匹配,不再混进学号相近的同学
- 协作中点上下文条、相似题、下一题开新标签,协作不断
- 题单入口给管理员的「⋯」里留「本题提交」

结果与题面
- 「去自己输入拿 51 这些数试试」取到了题面 HTML 里的颜色值;「题目原话」露出 "
- 流程图 A 级的学生标题下还写着「还没交过」
- 「我的提交」上方补回「答案错误 × 3」这样的计数
- 流程图历次评分能点开看那一版的图;读分数失败不再说「还没交过流程图」;手机上能看流程图点评
- 「结果」页签有新结果没看时加粗上色,看过就恢复
- 空画布拖进第一个节点画布会跳一下;画布底下空着一条
- 比赛没开始写成「已结束」、倒计时超过一个月算错;分隔条拖到头工具栏被挤坏;题单里的题目页有备案页脚
- 流程图历次分数只读需要的几列(一个学生一道题 23 次提交:96 kB → 2 kB)
- 比赛密码、比赛没开始等几条学生看得到的英文报错换成中文

设计文档照实际做法更新了第 5.5、5.6、6、8、9、10、11 节。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:07:16 +08:00

483 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { HINT_MIN_FAILURES, hintLevelLabel } from "@oj2/contract"
import type { JudgeCaseResult } from "@oj2/contract"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import {
submissionCaseResults,
submissionMemoryFormat,
submissionPartialCases,
submissionTimeFormat,
} from "utils/functions"
import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission"
import { markdownToText } from "oj/problem/utils/plainText"
import PythonErrorExplain from "./PythonErrorExplain.vue"
import RuntimeErrorExplain from "./RuntimeErrorExplain.vue"
import WrongAnswerExplain from "./WrongAnswerExplain.vue"
import LessonNext from "./LessonNext.vue"
import ResultHeader from "./ResultHeader.vue"
import SimilarProblems from "./SimilarProblems.vue"
import { useLessonStore } from "oj/store/lesson"
import { useProblemPageContext } from "../composables/problemPageContext"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { useDark } from "@vueuse/core"
const props = defineProps<{
submission?: Submission
/**
* 看的是别人的提交(协作中的老师看学生最近一次交的):不给 AI 提示 —— 那是按登录的人
* 自己的失败次数解锁的 —— 也不给「这节课的下一题」
*/
peer?: boolean
}>()
const isDark = useDark()
const problemStore = useProblemStore()
const theme = useThemeVars()
// AI 提示的状态在 submission store 里(见 oj/problem/composables/useSubmissionHint.ts):
// 这个组件在提交按钮那边,编辑器一卸载它就跟着卸载,已经生成的提示不能跟着没
const {
hintTarget,
hintContent,
hintLoading,
hintError,
hintId,
hintLevel,
hintCanEscalate,
hintHelpful,
hintFeedbackSending,
hintTyping,
fetchHint,
sendHintFeedback,
} = useSubmissionStore().hint
/** 判题机的临时目录名(`/judger/run/<32 位随机串>/`)对学生没有意义,只剩文件名 */
function stripJudgePath(text: string) {
return text.replace(/\/judger\/run\/[^/"]+\//g, "")
}
/**
* Python 的编译错误交给 PythonErrorExplain 翻成中文。只管 Python:C / C++ 的
* gcc 报错另是一套句式,还没做。
*/
const pythonCompileError = computed(() => {
const submission = props.submission
if (!submission || submission.result !== SubmissionStatus.compile_error) return ""
if (submission.language !== "Python") return ""
return submission.statisticInfo?.err_info ?? ""
})
// 错误信息格式化
/**
* 运行时错误的诊断(行号 + 归类),交给 RuntimeErrorExplain。比赛提交、诊断失败的
* 没有这个字段(见后端 judge/run.ts 的 diagnoseRuntimeError),走下面 msg 的通用提示。
*/
const runtimeError = computed(() => {
const submission = props.submission
if (!submission || submission.result !== SubmissionStatus.runtime_error) return null
return submission.statisticInfo?.runtime_error ?? null
})
/** 答案错误时公开样例上的对比,交给 WrongAnswerExplain;比赛提交没有(见后端 checkSamples) */
const sampleCheck = computed(() => {
const submission = props.submission
if (!submission || submission.result !== SubmissionStatus.wrong_answer) return null
return submission.statisticInfo?.sample_check ?? null
})
/**
* 通过之后给「这节课的下一题」。比赛有自己的题目列表;从题单入口进来的,通过后
* 1.5 秒会自动跳回题单(SubmissionEffects),这两种都不给
*/
const ctx = useProblemPageContext()
const showLessonNext = computed(
() =>
!props.peer && props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext,
)
/**
* 通过了、但这道题不在这节课里(没有「下一题」可给):给几道相似题接着练。
* 原来相似题只在题面最底下,交完不会有人再滚回去看
*/
const lessonStore = useLessonStore()
const showSimilar = computed(
() =>
!props.peer &&
props.submission?.result === SubmissionStatus.accepted &&
ctx.value.similar &&
!!problemStore.problem &&
!lessonStore.includes(problemStore.problem._id),
)
const msg = computed(() => {
if (!props.submission) return ""
// 走 PythonErrorExplain / RuntimeErrorExplain 那两张中文卡片
if (pythonCompileError.value || runtimeError.value) return ""
let msg = ""
const result = props.submission.result
// 编译错误或运行时错误时给出提示;
// SQL 题的运行错误多半是"查询题里写了增删改"这类被判题拒绝的语句,err_info 已说明原因,不套这句
if (props.submission.language !== "SQL") {
if (result === SubmissionStatus.compile_error) {
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
} else if (result === SubmissionStatus.runtime_error) {
// 原来和编译错误共用「代码格式写错了」那句,可运行时错误恰恰是格式没错、跑起来才出事
msg +=
"程序运行到一半出错,停下来了。检查一下输入是怎么读的、下标有没有越界、除数是不是 0。\n\n"
}
}
// 题面不写时间、内存限制(学生看了只会迷糊),判出来时就得在这里说清楚是怎么回事
if (
result === SubmissionStatus.cpu_time_limit_exceeded ||
result === SubmissionStatus.real_time_limit_exceeded
) {
msg +=
"程序跑了太久还没结束。看看是不是有死循环(条件一直成立、循环变量忘了改),或者在等一个没有给的输入(多写了一个 input)。\n\n"
} else if (result === SubmissionStatus.memory_limit_exceeded) {
msg +=
"程序占的内存太多了。通常是列表、数组开得太大,或者在循环里不停地往里加东西、停不下来。\n\n"
}
if (result !== SubmissionStatus.ast_check_failed && props.submission.statisticInfo?.err_info) {
msg += stripJudgePath(props.submission.statisticInfo.err_info)
}
return msg.trim()
})
// 部分测试点通过时的进度。学生拿不到 info,那张测试点表格只有管理员看得见,
// 这是学生这边唯一能看出「比上次多过了几个点」的地方。
const partialCases = computed(() => submissionPartialCases(props.submission))
/** 在公开的例子上就错了:这时候「通过 x/y 个测试点」没意义,先说是哪个例子 */
const failedOnSample = computed(() =>
sampleCheck.value && !sampleCheck.value.passed ? sampleCheck.value : null,
)
/**
* 结果标题(设计文档第 6 节、设计稿「答案错误 / 答案正确 / 错在隐藏测试点」):
* 大字是判题结果,灰色小字说清楚错在哪 —— 例子上就错了的说「在例子 N 上就错了」、不挂进度;
* 其余照旧报「通过 x/y 个测试点」(一个都没过就不报);做对了说全对了。
* 提交详情页还用通用的 submissionResultTitle
*/
const header = computed(() => {
const submission = props.submission
if (!submission) return null
const status = JUDGE_STATUS[submission.result]
if (
submission.result === SubmissionStatus.pending ||
submission.result === SubmissionStatus.judging ||
submission.result === SubmissionStatus.submitting
) {
return { kind: "pending" as const, title: status.title, sub: "", progress: null }
}
if (submission.result === SubmissionStatus.accepted) {
return {
kind: "success" as const,
title: status.title,
sub: "所有测试点都对了",
progress: null,
}
}
if (failedOnSample.value) {
return {
kind: status.type,
title: status.title,
sub: `在例子 ${(failedOnSample.value.index ?? 0) + 1} 上就错了`,
progress: null,
}
}
const cases = partialCases.value
return {
kind: status.type,
title: status.title,
sub: cases ? `通过 ${cases.passed}/${cases.total} 个测试点` : "",
progress: cases,
}
})
/**
* 例子都对了、错在隐藏测试点:「去『自己输入』拿 90、80 这些数试试」。数取题目描述里出现的、
* 例子里没有的头两个 —— 分段、判断一类题的边界多半就写在描述里(设计稿「错在隐藏测试点」)。
* SQL 题、比赛里没有「自己输入」
*/
const submissionStore = useSubmissionStore()
const customRunHint = computed(() => {
if (!sampleCheck.value?.passed || props.peer) return ""
const problem = problemStore.problem
if (!problem || problem.sqlConfig) return ""
// 例子里出现过的数不算:例子已经对了,拿它们再试一遍没用
const inSamples = new Set(
problem.samples.flatMap(
(sample) => `${sample.input} ${sample.output}`.match(/-?\d+(?:\.\d+)?/g) ?? [],
),
)
// 先摊成纯文字:题面是 HTML,446 道题带着 style="color: rgb(51, 51, 51)",直接取数会建议「拿 51 试试」
const description = markdownToText(problem.description, Infinity)
const numbers = [...new Set(description.match(/-?\d+(?:\.\d+)?/g) ?? [])]
.filter((value) => !inSamples.has(value))
.slice(0, 2)
return numbers.length
? `去「自己输入」拿 ${numbers.join("、")} 这些数试试 ›`
: "去「自己输入」拿特殊的数试试 ›"
})
// 是否显示AI提示区域。
// 阈值和后端 POST /ai/hint 共用契约里的 HINT_MIN_FAILURES,别在这里写死数字;
// 编译失败不数次数,和后端同口径(理由见 HINT_MIN_FAILURES 的注释);
// failCount 现在含服务端下发的历史失败数,刷新页面不会把进度清掉。
// system_error 也要排掉:那是判题机自己崩了,学生代码没毛病,让 AI 去分析
// 只会瞎编一通,后端的失败计数同样不认这个状态。
const showAIHint = computed(() => {
if (!props.submission || props.peer) return false
// 比赛题不给提示,和「求助」按钮一致。用 problem.contestId 而不是路由参数:
// 带 contestId 的题目只可能从比赛入口进来(题库列表按 contest_id is null 过滤)。
if (problemStore.problem?.contestId != null) return false
return (
(problemStore.failCount >= HINT_MIN_FAILURES ||
props.submission.result === SubmissionStatus.compile_error) &&
props.submission.result !== SubmissionStatus.accepted &&
props.submission.result !== SubmissionStatus.ast_check_failed &&
props.submission.result !== SubmissionStatus.system_error &&
props.submission.result !== SubmissionStatus.pending &&
props.submission.result !== SubmissionStatus.judging &&
props.submission.result !== SubmissionStatus.submitting
)
})
// 测试用例表格数据(只在部分通过时显示)
const infoTable = computed(() => {
const submission = props.submission
if (!submission) return []
const data = submissionCaseResults(submission.info)
if (!data.length) return []
const result = submission.result
// AC、编译错误、运行时错误不显示测试用例表格
if (
result === SubmissionStatus.accepted ||
result === SubmissionStatus.ast_check_failed ||
result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error
) {
return []
}
// 只有存在失败的测试用例时才显示
return data.some((item) => item.result === 0) ? data : []
})
// 测试用例表格列配置
const columns: DataTableColumn<JudgeCaseResult>[] = [
{ title: "测试用例", key: "test_case" },
{
title: "测试状态",
key: "result",
render: (row) => h(SubmissionResultTag, { result: row.result }),
},
{
title: "占用内存",
key: "memory",
render: (row) => submissionMemoryFormat(row.memory),
},
{
title: "执行耗时",
key: "real_time",
render: (row) => submissionTimeFormat(row.real_time),
},
{ title: "信号", key: "signal" },
]
</script>
<template>
<div v-if="submission">
<ResultHeader
v-if="header"
:kind="header.kind"
:title="header.title"
:sub="header.sub"
:progress="header.progress"
/>
<LessonNext
v-if="showLessonNext && problemStore.problem"
:problem-display-id="problemStore.problem._id"
/>
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
<n-flex
vertical
v-if="
pythonCompileError ||
runtimeError ||
sampleCheck ||
msg ||
infoTable.length ||
submission.statisticInfo?.ast_results?.length
"
>
<PythonErrorExplain v-if="pythonCompileError" :err-info="pythonCompileError" />
<RuntimeErrorExplain v-if="runtimeError" :info="runtimeError" :code="submission.code" />
<WrongAnswerExplain
v-if="sampleCheck"
full
:check="sampleCheck"
:code="submission.code"
:language="submission.language"
/>
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded>
<n-flex vertical :size="8">
<n-flex
v-for="(rule, i) in submission.statisticInfo.ast_results"
:key="i"
align="center"
:size="6"
>
<n-icon :color="rule.passed ? theme.successColor : theme.errorColor">
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" />
</n-icon>
<span>{{ rule.description }}</span>
<!-- 次数类规则光说「出现 2 次 ✗」,学生不知道自己写了几次 -->
<span
v-if="!rule.passed && rule.actual !== undefined"
:style="{ color: theme.errorColor }"
>
当前 {{ rule.actual }} 次
</span>
</n-flex>
</n-flex>
</n-card>
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
<n-data-table v-if="infoTable.length" striped :data="infoTable" :columns="columns" />
</n-flex>
<!--
设计稿:还没问过 AI 时只是一个描边按钮;错在隐藏测试点时旁边给「去自己输入试试」。
问过之后,AI 的回答才放进卡片
-->
<div v-if="(showAIHint && !hintTarget) || customRunHint" class="actions">
<n-button
v-if="showAIHint && !hintTarget"
type="primary"
ghost
:loading="hintLoading"
@click="fetchHint(submission.id)"
>
让 AI 分析我的代码
</n-button>
<n-button
v-if="customRunHint"
text
type="primary"
@click="submissionStore.revealResult('custom')"
>
{{ customRunHint }}
</n-button>
</div>
<n-alert v-if="showAIHint && hintError" type="error" :title="hintError" class="hint-error" />
<template v-if="showAIHint && hintTarget">
<n-card size="small" class="hint-card">
<n-spin v-if="hintLoading && !hintContent" size="small" />
<MdPreview
v-if="hintContent"
:model-value="hintContent"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
/>
<!-- 等级和「再多一点提示」。按钮出不出由后端 done 里的 canEscalate 定,
前端不自己推阶梯(要再交一次才升得动,规则在 services/hint-level.ts) -->
<n-flex
v-if="hintLevel !== null && !hintLoading && !hintTyping"
align="center"
size="small"
style="margin-top: 8px"
>
<n-tag size="small" :bordered="false">
{{ hintLevelLabel(hintLevel) }}
</n-tag>
<n-button
v-if="hintCanEscalate"
size="tiny"
type="primary"
ghost
@click="fetchHint(submission.id, true)"
>
再多一点提示
</n-button>
</n-flex>
<n-flex
v-if="hintId !== null && !hintLoading && !hintTyping"
align="center"
size="small"
style="margin-top: 8px"
>
<n-text depth="3">这条提示对你有帮助吗?</n-text>
<n-button
size="tiny"
:type="hintHelpful === true ? 'primary' : 'default'"
:disabled="hintFeedbackSending"
@click="sendHintFeedback(true)"
>
有帮助
</n-button>
<n-button
size="tiny"
:type="hintHelpful === false ? 'warning' : 'default'"
:disabled="hintFeedbackSending"
@click="sendHintFeedback(false)"
>
没帮助
</n-button>
</n-flex>
</n-card>
</template>
</div>
</template>
<style scoped>
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
margin-top: 12px;
}
/* 手机上「让 AI 分析我的代码」占满一行、44px 高,好按 */
@media (max-width: 768px) {
.actions :deep(.n-button:not(.n-button--text-type)) {
width: 100%;
height: 44px;
}
}
.hint-error {
margin-top: 12px;
}
.hint-card {
margin-top: 12px;
max-width: 560px;
}
.msg {
white-space: pre-wrap;
word-break: break-all;
line-height: 1.5;
}
.gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: bold;
}
</style>