Files
OJ2/apps/web/src/oj/problem/components/WrongAnswerExplain.vue
T

271 lines
9.4 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 { isSqlProblem } from "@oj2/contract"
import { storeToRefs } from "pinia"
import { useThemeVars } from "naive-ui"
import { useProblemStore } from "oj/store/problem"
import { markdownToText } from "oj/problem/utils/plainText"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import { classifyDiff, explainDiff, firstDifferentLine, type SampleCheck } from "utils/wrongAnswer"
/**
* 答案错误时的样例对比(数据见后端 judge/run.ts 的 checkSamples)。
* 全是公开信息:样例写在题面上,「你的输出」是学生自己的程序在公开输入上的输出。
*/
const props = defineProps<{
check: SampleCheck
code: string
language: string
/**
* 只摆输入、正确输出、你的输出,不给中文提示句。比赛里的「运行例子」用:后端在比赛里
* 故意不做例子对比(judge/run.ts:比赛里有期末考试,格式提示在那里不该给),
* 前端试跑也不能绕过去
*/
plain?: boolean
/**
* 提交结果里用:末尾摆出题目对输出的原话,例子都对时再给「去自己输入试试」。
* 格式错、漏了一行这类错误,对着题目原话一看就明白;运行例子那边不需要
*/
full?: boolean
/**
* 看的是别人的提交(老师在独立的提交详情页看学生的):「你的输出」「你的程序」都换成「他」。
* 提示句全是对学生说的第二人称,整句换掉人称就读得通
*/
peer?: boolean
}>()
const theme = useThemeVars()
const { problem } = storeToRefs(useProblemStore())
/**
* 卡片末尾那行「题目原话」(设计稿「答案错误」「错在隐藏测试点」):
* 例子上就错了 —— 摆题面「输出」那一节,格式错、漏了一行这类,对着原话一看就明白;
* 例子都对了、错在隐藏测试点 —— 摆「描述」,边界条件多半写在那里。SQL 题两节都不摆
*/
const requirement = computed(() => {
if (!props.full || !problem.value || isSqlProblem(problem.value)) return null
const source = props.check.passed ? problem.value.description : problem.value.outputDescription
const text = markdownToText(source ?? "", props.check.passed ? 160 : 120)
if (!text) return null
return { label: props.check.passed ? "题目原话" : "题目要求的输出", text }
})
/** 后端每段截到 2000 字,到了上限就是被截过 */
const TEXT_LIMIT = 2000
const onSample = computed(() => props.check.result === SubmissionStatus.wrong_answer)
// plain(比赛)连差异类型都不算:把空格画成「·」、把不一样的那段标红,和提示句一样是格式线索
const kind = computed(() =>
onSample.value && !props.plain
? classifyDiff(props.check.expected ?? "", props.check.output ?? "")
: null,
)
const hint = computed(() => {
if (props.check.passed) return ""
if (!kind.value) {
const name = JUDGE_STATUS[props.check.result as keyof typeof JUDGE_STATUS]?.name ?? "出错了"
return `${props.peer ? "学生" : "你"}的程序在这个例子上就「${name}」了,先让它在例子上跑通。`
}
const text = explainDiff(kind.value, props.check, props.code, props.language)
return props.peer ? text.replace(/你/g, "学生") : text
})
const diffLine = computed(() =>
firstDifferentLine(props.check.expected ?? "", props.check.output ?? ""),
)
/**
* 不一样的那一行,拆成「相同的开头 / 不同的中间 / 相同的结尾」三段,只把中间那段标红
* (设计稿:多出来的「等级:」、错成的「E」、多出来的「333333333333」)。
* 标在「你的输出」上;你的输出那段是空的(少了东西)就标在正确输出上,告诉他少了什么
*/
function splitDiff(expected: string, output: string) {
const e = [...expected]
const o = [...output]
let head = 0
while (head < e.length && head < o.length && e[head] === o[head]) head++
let tail = 0
while (
tail < e.length - head &&
tail < o.length - head &&
e[e.length - 1 - tail] === o[o.length - 1 - tail]
)
tail++
const cut = (chars: string[]) => ({
before: chars.slice(0, head).join(""),
marked: chars.slice(head, chars.length - tail).join(""),
after: chars.slice(chars.length - tail).join(""),
})
const mine = cut(o)
const theirs = cut(e)
return mine.marked ? { output: mine, expected: null } : { output: null, expected: theirs }
}
const diffParts = computed(() => {
if (!onSample.value || props.plain || diffLine.value < 0) return null
const expected = lines(props.check.expected ?? "")[diffLine.value] ?? ""
const output = lines(props.check.output ?? "")[diffLine.value] ?? ""
return splitDiff(expected, output)
})
/**
* 只差空白的时候把空白画出来,不然两边看着一模一样。
*
* 末尾的空白先去掉:判题比的是整个输出去掉末尾空白之后的样子(JudgeServer 的
* stripped_output_md5),print 自己带的那个换行不算错。画出来的话,学生会以为
* 是结尾那个「↵」惹的祸。
*/
function lines(text: string) {
const visible = kind.value === "whitespace"
const parts = text.replace(/\s+$/, "").split("\n")
return parts.map((line, i) => {
if (!visible) return line
const ending = i < parts.length - 1 ? "↵" : ""
return line.replace(/ /g, "·").replace(/\t/g, "→") + ending
})
}
</script>
<template>
<div class="explain-card">
<p v-if="check.passed" class="explain">
题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。
</p>
<template v-else>
<p class="explain">
<b>例子 {{ (check.index ?? 0) + 1 }} 没有通过{{ plain ? "。" : ":" }}</b
><template v-if="!plain">{{ hint }}</template>
</p>
<div class="blocks">
<div class="block">
<div class="label">输入</div>
<div class="text">
<div
v-for="(line, i) in (check.input ?? '').split('\n')"
:key="i"
class="line"
v-text="line"
/>
</div>
</div>
<div class="block">
<div class="label">正确输出</div>
<div class="text">
<template v-for="(line, i) in lines(check.expected ?? '')" :key="i">
<div v-if="i === diffLine && diffParts?.expected" class="line">
<span v-text="diffParts.expected.before" /><span
class="diff"
v-text="diffParts.expected.marked"
/><span v-text="diffParts.expected.after" />
</div>
<div v-else class="line" v-text="line" />
</template>
</div>
</div>
<div class="block">
<div class="label">{{ peer ? "学生" : "你" }}的输出</div>
<div v-if="check.output" class="text">
<template v-for="(line, i) in lines(check.output)" :key="i">
<div v-if="i === diffLine && diffParts?.output" class="line">
<span v-text="diffParts.output.before" /><span
class="diff"
v-text="diffParts.output.marked"
/><span v-text="diffParts.output.after" />
</div>
<div v-else class="line" v-text="line" />
</template>
</div>
<div v-else class="text empty">(什么都没有输出)</div>
<div v-if="check.output && check.output.length >= TEXT_LIMIT" class="cut">
输出太长,只显示了前面一部分
</div>
</div>
</div>
</template>
<p v-if="requirement" class="requirement">{{ requirement.label }}:{{ requirement.text }}</p>
</div>
</template>
<style scoped>
/* 设计稿「答案错误」:一块浅灰卡片,三栏并排的白底小框 */
.explain-card {
container-type: inline-size;
padding: 12px 14px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
border: 1px solid v-bind("theme.dividerColor");
display: flex;
flex-direction: column;
gap: 10px;
}
.explain {
margin: 0;
line-height: 1.7;
}
.blocks {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
/* 窄的时候(手机)一栏一个往下排,三个窄框里什么都看不清 */
@container (max-width: 440px) {
.blocks {
grid-template-columns: minmax(0, 1fr);
}
}
.block {
min-width: 0;
padding: 6px 10px 8px;
border-radius: 5px;
background-color: v-bind("theme.cardColor");
border: 1px solid v-bind("theme.dividerColor");
}
.label {
font-size: 12px;
color: v-bind("theme.textColor3");
margin-bottom: 3px;
}
.text {
font-family: Consolas, Monaco, monospace;
font-size: 14px;
line-height: 1.6;
max-height: 200px;
overflow: auto;
}
/* 每行用 v-text 填,不写 {{ line }}:模板里标签两边的换行缩进会被 Vue 压成一个空格,
配上 white-space: pre 就是每行开头多一个空格 —— 在一个专门比对空格的地方,这不能有 */
.line {
white-space: pre;
min-height: 1.6em;
}
.diff {
background-color: rgba(208, 48, 80, 0.18);
color: v-bind("theme.errorColorPressed");
}
.empty {
color: v-bind("theme.textColor3");
font-family: inherit;
font-size: 13px;
}
.cut {
margin-top: 4px;
font-size: 12px;
color: v-bind("theme.textColor3");
}
.requirement {
margin: 0;
font-size: 13px;
color: v-bind("theme.textColor2");
line-height: 1.6;
}
</style>