feat(结果): 判完之后测试点一格一格亮起来,错的那格写着第几组
Deploy / deploy (push) Canceled after 0s

借小丑牌结算时一张张牌亮起来:结果里每组测试点一个方块,从左往右依次亮,
对的绿、错的红并写组号,不到 1 秒亮完,提示和按钮亮完才出来,全对时亮完再放烟花。
大字和「通过几个」判完马上就有;只播刚交的那条,刷新、看旧提交、系统减少动画都直接全亮。
提交详情多给每组对没对(只有对错,不带数据),比赛照旧不给。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 09:56:32 +08:00
1 parent 1a173f75d7
commit 36b3cd6b21
8 files changed
+375 -144

No files matched your search

+6 -2
View File
@@ -372,8 +372,12 @@ function caseSummary(submission: typeof schema.submission.$inferSelect) {
if (submission.contestId !== null || submission.language === "SQL") return null
const data = asRecord(submission.info).data
if (!Array.isArray(data) || data.length === 0) return null
const passed = data.filter((item) => asRecord(item).result === JudgeStatus.ACCEPTED).length
return { passed, total: data.length }
// 判题时已经按组号排过(judge/run.ts),这里再排一次:老数据不一定是排好的
const results = data
.map((item) => asRecord(item))
.sort((a, b) => Number(a.test_case) - Number(b.test_case))
.map((item) => item.result === JudgeStatus.ACCEPTED)
return { passed: results.filter(Boolean).length, total: results.length, results }
}
/** 列表行的 caseSummary:SQL 里数好的两个数,为 null 的情形和详情一致(SQL 题、没有逐点结果) */
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { caseRevealStep } from "../utils/caseReveal"
/**
* 每组测试点一个方块:对的绿、错的红并写上第几组(设计稿「测试点亮起来」)。
* `animate` 时从左往右一格一格亮,亮完发 done;不播就一下全亮、马上发 done。
* 只在挂上的那一刻看 animate —— 播到一半它变了也不打断。
*/
const props = defineProps<{
results: boolean[]
animate: boolean
}>()
const emit = defineEmits<{ (e: "done"): void }>()
const theme = useThemeVars()
const shown = ref(props.animate ? 0 : props.results.length)
const playing = ref(props.animate)
let timer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
if (!props.animate) {
emit("done")
return
}
timer = setInterval(() => {
shown.value++
if (shown.value >= props.results.length) {
stop()
emit("done")
}
}, caseRevealStep(props.results.length))
})
function stop() {
if (timer) clearInterval(timer)
timer = null
playing.value = false
}
onBeforeUnmount(stop)
const failed = computed(() => props.results.flatMap((passed, i) => (passed ? [] : [i + 1])))
const label = computed(() => {
const passed = props.results.length - failed.value.length
const wrong = failed.value.length ? `,第 ${failed.value.join("、")} 组错了` : ""
return `${props.results.length} 组测试点通过 ${passed} 组${wrong}`
})
</script>
<template>
<div class="blocks" role="img" :aria-label="label">
<span
v-for="(passed, i) in results"
:key="i"
class="block"
:class="{
wait: i >= shown,
ok: i < shown && passed,
bad: i < shown && !passed,
now: playing && i === shown - 1,
}"
>{{ i < shown && !passed ? i + 1 : "" }}</span
>
</div>
</template>
<style scoped>
.blocks {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.block {
width: 20px;
height: 20px;
border-radius: 4px;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.block.wait {
background-color: v-bind("theme.dividerColor");
}
.block.ok {
background-color: v-bind("theme.successColor");
}
.block.bad {
background-color: v-bind("theme.errorColor");
color: #fff;
}
/* 刚亮的那格外面一圈,看得出「亮到哪了」 */
.block.now {
box-shadow:
0 0 0 2px v-bind("theme.cardColor"),
0 0 0 4px v-bind("theme.successColorSuppl");
}
.block.now.bad {
box-shadow:
0 0 0 2px v-bind("theme.cardColor"),
0 0 0 4px v-bind("theme.errorColorSuppl");
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import CaseBlocks from "./CaseBlocks.vue"
/**
* 结果页签里每一块结果的标题:圆形状态图标 + 大字 + 灰色小字,下面可以挂一条测试点进度。
@@ -12,7 +13,15 @@ const props = defineProps<{
sub?: string
/** 测试点进度:通过几个、一共几个 */
progress?: { passed: number; total: number } | null
/**
* 每组对没对:有就画一排方块代替上面那根进度条(设计稿「测试点亮起来」),
* 没有(老接口、SQL 题)才退回进度条
*/
cases?: boolean[] | null
/** 方块一格一格亮;不亮就一下全亮 */
animate?: boolean
}>()
const emit = defineEmits<{ (e: "revealed"): void }>()
const theme = useThemeVars()
@@ -90,8 +99,15 @@ const color = computed(() => {
<span v-if="sub" class="sub">{{ sub }}</span>
<slot name="extra" />
</div>
<CaseBlocks
v-if="cases && cases.length"
:results="cases"
:animate="!!animate"
class="cases"
@done="emit('revealed')"
/>
<div
v-if="progress && progress.total > 0"
v-else-if="progress && progress.total > 0"
class="progress"
role="img"
:aria-label="`通过 ${progress.passed}/${progress.total} 个测试点`"
@@ -141,6 +157,10 @@ const color = computed(() => {
color: v-bind("theme.textColor3");
}
.cases {
margin-top: 4px;
}
.progress {
height: 6px;
border-radius: 3px;
@@ -9,6 +9,7 @@ import { useLessonStore } from "shared/store/lesson"
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
import { SubmissionStatus } from "utils/constants"
import { useProblemPageContext } from "../composables/problemPageContext"
import { caseRevealDuration } from "../utils/caseReveal"
/**
* 判完之后「该发生什么」:失败计数、标成已解决、烟花、点评、回题单。
@@ -222,6 +223,20 @@ watch(
},
)
/**
* 这一条要播「测试点一格一格亮」的话,等它亮完。判完那一刻只有 WebSocket 推来的结果,
* 方块要等详情(带每组对错)回来才开始亮,所以先等详情,最多 3 秒
*/
async function afterCasesRevealed(id: string | undefined) {
if (!submissionStore.shouldRevealCases(id)) return
await until(() => submission.value?.id === id && submission.value?.code !== undefined).toBe(
true,
{ timeout: 3000 },
)
const count = submission.value?.caseSummary?.results?.length ?? 0
if (count) await new Promise((resolve) => setTimeout(resolve, caseRevealDuration(count)))
}
// ==================== AC庆祝效果 ====================
watch(
() => submission.value?.result,
@@ -238,14 +253,17 @@ watch(
if (result !== SubmissionStatus.accepted) return
// 2. 放烟花
const fireworks = celebrate()
// 3. 烟花放完弹出评价框。比赛里不打扰;题单里 1.5 秒后要跳回题单页,弹了也会被冲掉
if (ctx.value.reviewAfterAccepted) {
reviewOwed.value = true
showCommentPanelAfter(fireworks)
}
// (评价是欠着的这件事马上记下:方块还在亮的时候离开这道题,也要先评)
if (ctx.value.reviewAfterAccepted) reviewOwed.value = true
// 2. 放烟花:等结果页签里的方块一格一格亮完(不到 1 秒)再放
const id = submission.value?.id
void afterCasesRevealed(id).then(() => {
if (submission.value?.id !== id) return
const fireworks = celebrate()
if (ctx.value.reviewAfterAccepted) showCommentPanelAfter(fireworks)
})
if (ctx.value.backToProblemSet) {
// 延迟回到题单页面
@@ -214,12 +214,15 @@ const header = computed(() => {
) {
return { kind: "pending" as const, title: status.title, sub: "", progress: null }
}
// 每组对没对:一排方块代替进度条(设计稿「测试点亮起来」),全对也画
const cases = submission.caseSummary?.results ?? null
if (submission.result === SubmissionStatus.accepted) {
return {
kind: "success" as const,
title: status.title,
sub: "所有测试点都对了",
progress: null,
cases,
}
}
if (failedOnSample.value) {
@@ -229,17 +232,42 @@ const header = computed(() => {
// 和提交列表右栏同一句,「题目里的」是为了和测试点的编号分开
sub: `题目里的例子 ${(failedOnSample.value.index ?? 0) + 1} 就错了`,
progress: null,
cases,
}
}
const cases = partialCases.value
const partial = partialCases.value
return {
kind: status.type,
title: status.title,
sub: cases ? `通过 ${cases.passed}/${cases.total} 个测试点` : "",
progress: cases,
sub: partial ? `通过 ${partial.passed}/${partial.total} 个测试点` : "",
progress: partial,
cases,
}
})
/**
* 方块一格一格亮的时候,下面的提示、按钮、「下一题」先不出来,亮完一起出来(设计稿「测试点亮起来」)。
* 判完那一刻只有 WebSocket 推来的结果,详情(带每组对错)还在路上 —— 这段也先等着,
* 不然下面的东西会先闪出来、方块开始亮时又缩回去
*/
const revealedId = ref<string | null>(null)
const waitingReveal = computed(() => {
const submission = props.submission
if (!submission || props.peer || !header.value || header.value.kind === "pending") return false
if (!submissionStore.shouldRevealCases(submission.id)) return false
if (submission.code === undefined) return true
return !!header.value.cases?.length && revealedId.value !== submission.id
})
const animateCases = computed(
() => !props.peer && submissionStore.shouldRevealCases(props.submission?.id),
)
function onCasesRevealed() {
const id = props.submission?.id
if (!id) return
revealedId.value = id
submissionStore.markCasesRevealed(id)
}
/**
* 例子都对了、错在隐藏测试点:「去『自己输入』拿 90、80 这些数试试」。数取题目描述里出现的、
* 例子里没有的头两个 —— 分段、判断一类题的边界多半就写在描述里(设计稿「错在隐藏测试点」)。
@@ -352,152 +380,160 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
<template v-else>
<ResultHeader
v-if="header"
:key="submission.id"
:kind="header.kind"
:title="header.title"
:sub="header.sub"
:progress="header.progress"
:cases="header.cases"
:animate="animateCases"
@revealed="onCasesRevealed"
/>
<LessonNext
v-if="showLessonNext && problemStore.problem"
:problem-display-id="problemStore.problem._id"
:language="submission.language"
:refresh-key="submission.id"
:challenges="ctx.challenges"
/>
<!-- 不在这节课里(题库入口自己做的):做对之后先给这道题的挑战,再给相似题 -->
<ChallengeList
v-if="showSimilar && ctx.challenges && problemStore.problem"
:problem-ids="[problemStore.problem._id]"
:language="submission.language"
:refresh-key="submission.id"
/>
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
<div v-show="!waitingReveal">
<LessonNext
v-if="showLessonNext && problemStore.problem"
:problem-display-id="problemStore.problem._id"
:language="submission.language"
:refresh-key="submission.id"
:challenges="ctx.challenges"
/>
<!-- 不在这节课里(题库入口自己做的):做对之后先给这道题的挑战,再给相似题 -->
<ChallengeList
v-if="showSimilar && ctx.challenges && problemStore.problem"
:problem-ids="[problemStore.problem._id]"
:language="submission.language"
:refresh-key="submission.id"
/>
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
</div>
</template>
<n-flex
vertical
v-if="
compileError ||
runtimeError ||
sampleCheck ||
msg ||
infoTable.length ||
submission.statisticInfo?.ast_results?.length
"
>
<CompileErrorExplain
v-if="compileError"
:err-info="compileError"
:language="submission.language"
/>
<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 }"
<div v-show="!waitingReveal">
<n-flex
vertical
v-if="
compileError ||
runtimeError ||
sampleCheck ||
msg ||
infoTable.length ||
submission.statisticInfo?.ast_results?.length
"
>
<CompileErrorExplain
v-if="compileError"
:err-info="compileError"
:language="submission.language"
/>
<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"
>
当前 {{ rule.actual }} 次
</span>
<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-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>
</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" />
<Markdown v-if="hintContent" :content="hintContent" />
<!-- 等级和「再多一点提示」。按钮出不出由后端 done 里的 canEscalate 定,
<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" />
<Markdown v-if="hintContent" :content="hintContent" />
<!-- 等级和「再多一点提示」。按钮出不出由后端 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-flex
v-if="hintLevel !== null && !hintLoading && !hintTyping"
align="center"
size="small"
style="margin-top: 8px"
>
再多一点提示
</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-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-button>
<n-button
size="tiny"
:type="hintHelpful === false ? 'warning' : 'default'"
:disabled="hintFeedbackSending"
@click="sendHintFeedback(false)"
>
没帮助
</n-button>
</n-flex>
</n-card>
</template>
<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>
</div>
</template>
@@ -0,0 +1,15 @@
/**
* 测试点一格一格亮(设计稿「测试点亮起来」,借小丑牌结算时一张张牌亮起来)的节奏。
* 不管几组,全部亮完不超过 0.9 秒:组少的一格 120 毫秒,组多的平分 900 毫秒。
* 结果页签里的方块(CaseBlocks)和等方块亮完再放的烟花(SubmissionEffects)用同一份。
*/
const TOTAL_MS = 900
const STEP_MAX_MS = 120
export function caseRevealStep(count: number) {
return Math.min(STEP_MAX_MS, Math.floor(TOTAL_MS / Math.max(count, 1)))
}
export function caseRevealDuration(count: number) {
return caseRevealStep(count) * count
}
+21
View File
@@ -65,6 +65,25 @@ export const useSubmissionStore = defineStore("submission", () => {
revealResult("custom")
trial.runCustom({ ...codeStore.code }, { problemId: problem.id, contestId: problem.contestId })
}
/**
* 测试点一格一格亮(结果页签里的 CaseBlocks)只播一次,而且只播这一页上刚交、刚判完的那条:
* 刷新、切页签回来、看以前的提交都直接全亮。系统设了「减少动画」的不播。
* 烟花(SubmissionEffects)按同一个判断决定要不要等方块亮完
*/
const reducedMotion = usePreferredReducedMotion()
const revealedCases = new Set<string>()
function shouldRevealCases(id: string | undefined) {
return (
!!id &&
reducedMotion.value !== "reduce" &&
id === monitor.submissionId.value &&
!revealedCases.has(id)
)
}
function markCasesRevealed(id: string) {
revealedCases.add(id)
}
const isFormatting = ref(false)
const isSubmittingRequest = ref(false)
@@ -206,6 +225,8 @@ export const useSubmissionStore = defineStore("submission", () => {
resultSeq,
resultSegment,
revealResult,
shouldRevealCases,
markCasesRevealed,
runSamples,
runCustom,
isFormatting,
+5
View File
@@ -218,6 +218,11 @@ export const caseSummarySchema = z
.object({
passed: z.number().int(),
total: z.number().int(),
/**
* 每组对没对,按组号排。只有详情给(结果页签里方块一格一格亮,错的写组号),列表只给
* 两个数。只有对错、不带数据和输出,所以学生也给;为 null 的情形同上(比赛照旧不给)
*/
results: z.array(z.boolean()).optional(),
})
.nullable()