feat(提交结果): 没通过时显示「通过 x/y 个测试点」;编译失败不等三次就能用 AI 分析
Some checks failed
Deploy / deploy (push) Has been cancelled

学生拿不到 info(每个点带 output_md5,只给管理员),测试点表格从来只有管理员看得见,
学生这边只有一句「答案错误」—— 从 2/8 交到 6/8 的人,感受是连输五次。

- 提交详情新增 caseSummary,后端从 info 数出通过数下发,不放开原文。
  比赛提交、SQL 题(被杀的测试点会 break,total 偏小)、无逐点结果时为 null
- 结果面板标题缀上通过数并加进度条,提交详情页标题同步;一个都没过时不缀
- POST /ai/hint 对编译失败跳过失败次数门槛,throttleAi 照旧;前端显示条件同口径

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TecwAowmYcoNZRheSyTgH
This commit is contained in:
2026-09-13 07:25:00 -06:00
parent a833819679
commit 9132901cc7
7 changed files with 85 additions and 9 deletions

View File

@@ -502,8 +502,12 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
// 不然这就是个不限次数的免费 LLM 接口。数法(判题中的不算、判题机自己崩的不算)
// 由 countFailedSubmissions 统一,题目详情的 myFailedCount 走的是同一个函数,
// 所以前端亮出按钮的时刻和这里放行的时刻严格对齐。
const failed = await countFailedSubmissions(c.get("user")!.id, row.submission.problemId)
if (failed < HINT_MIN_FAILURES) return failure(c, 403, "hint-locked", `Hint unlocks after ${HINT_MIN_FAILURES} failed submissions`)
// 编译失败不数次数(理由见 HINT_MIN_FAILURES 的注释)。放开的只是这一次提交本身,
// 下面的 throttleAi 照样卡着,不会因此变成不限次数的接口。
if (row.submission.result !== JudgeStatus.COMPILE_ERROR) {
const failed = await countFailedSubmissions(c.get("user")!.id, row.submission.problemId)
if (failed < HINT_MIN_FAILURES) return failure(c, 403, "hint-locked", `Hint unlocks after ${HINT_MIN_FAILURES} failed submissions`)
}
const limited = await throttleAi(c)
if (limited) return limited
// 这里**不要**把 problem.answers 的参考答案放进 prompt。学生的代码本身就是 prompt 的

View File

@@ -813,6 +813,18 @@ const submissionListColumns = {
},
} as const
/**
* 从判题原文里数出通过的测试点,为 null 的情形见契约 `caseSummary` 的注释。
* 这里只信「有没有 data 数组」,数组项的形状信判题机,和前端 submissionCaseResults 同口径。
*/
function caseSummary(submission: typeof schema.submission.$inferSelect) {
if (submission.contestId !== null || submission.language === "SQL") return null
const data = objectValue(submission.info).data
if (!Array.isArray(data) || data.length === 0) return null
const passed = data.filter((item) => objectValue(item).result === JudgeStatus.ACCEPTED).length
return { passed, total: data.length }
}
async function submissionDetail(id: string, user: AuthUser) {
const [row] = await db.select({ submission: schema.submission, problem: schema.problem, contest: schema.contest })
.from(schema.submission)
@@ -846,6 +858,7 @@ async function submissionDetail(id: string, user: AuthUser) {
// problem 表本来就 join 了,不额外查库
problemDisplayId: row.problem.displayId,
showLink: true,
caseSummary: caseSummary(row.submission),
} satisfies SubmissionDetail
}

View File

@@ -7,6 +7,8 @@ import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import {
submissionCaseResults,
submissionMemoryFormat,
submissionPartialCases,
submissionResultTitle,
submissionTimeFormat,
} from "utils/functions"
import type { Submission } from "utils/types"
@@ -57,8 +59,13 @@ const msg = computed(() => {
return msg
})
// 部分测试点通过时的进度。学生拿不到 info那张测试点表格只有管理员看得见
// 这是学生这边唯一能看出「比上次多过了几个点」的地方。
const partialCases = computed(() => submissionPartialCases(props.submission))
// 是否显示AI提示区域。
// 阈值和后端 POST /ai/hint 共用契约里的 HINT_MIN_FAILURES别在这里写死数字
// 编译失败不数次数,和后端同口径(理由见 HINT_MIN_FAILURES 的注释);
// failCount 现在含服务端下发的历史失败数,刷新页面不会把进度清掉。
// system_error 也要排掉:那是判题机自己崩了,学生代码没毛病,让 AI 去分析
// 只会瞎编一通,后端的失败计数同样不认这个状态。
@@ -68,7 +75,8 @@ const showAIHint = computed(() => {
// 带 contestId 的题目只可能从比赛入口进来(题库列表按 contest_id is null 过滤)。
if (problemStore.problem?.contestId != null) return false
return (
problemStore.failCount >= HINT_MIN_FAILURES &&
(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 &&
@@ -172,9 +180,18 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
<div v-if="submission">
<n-alert
:type="JUDGE_STATUS[submission.result]['type']"
:title="JUDGE_STATUS[submission.result]['title']"
:title="submissionResultTitle(submission)"
class="mb-3"
/>
>
<template v-if="partialCases" #default>
<n-progress
type="line"
status="success"
:percentage="(partialCases.passed / partialCases.total) * 100"
:show-indicator="false"
/>
</template>
</n-alert>
<n-flex
vertical
v-if="

View File

@@ -10,6 +10,7 @@ import {
parseTime,
submissionCaseResults,
submissionMemoryFormat,
submissionResultTitle,
submissionTimeFormat,
utoa,
} from "utils/functions"
@@ -129,7 +130,7 @@ onMounted(init)
<n-alert
style="flex: 1"
:type="JUDGE_STATUS[submission.result]['type']"
:title="JUDGE_STATUS[submission.result]['title']"
:title="submissionResultTitle(submission)"
>
<n-flex>
<span>提交时间{{ parseTime(submission.createTime) }}</span>

View File

@@ -1,8 +1,8 @@
import { toAdminType } from "@oj2/contract"
import type { JudgeCaseResult, JudgeInfo } from "@oj2/contract"
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { User } from "./types"
import { USER_TYPE } from "./constants"
import { Submission, User } from "./types"
import { JUDGE_STATUS, USER_TYPE } from "./constants"
import {
strFromU8,
strToU8,
@@ -37,6 +37,32 @@ export function submissionCaseResults(info: unknown): JudgeCaseResult[] {
return Array.isArray(data) ? data : []
}
/**
* 没通过、但已经过了一部分测试点时的进度,其余情况(通过、一个都没过、没有逐点结果)为 null。
*
* 一个都没过不给:「通过 0/8」只是把「答案错误」换个更刺眼的说法再说一遍。
* 全过也不给AC 用不着AST_CHECK_FAILED 是测试点全过、语法规则没过,那边有自己的规则清单。
*/
export function submissionPartialCases(
submission: Pick<Submission, "caseSummary"> | undefined,
) {
const summary = submission?.caseSummary
if (!summary || summary.passed === 0 || summary.passed >= summary.total)
return null
return summary
}
/** 结果标题:部分测试点通过时缀上「通过 x/y 个测试点」 */
export function submissionResultTitle(
submission: Pick<Submission, "result" | "caseSummary">,
) {
const title = JUDGE_STATUS[submission.result]["title"]
const partial = submissionPartialCases(submission)
return partial
? `${title} · 通过 ${partial.passed}/${partial.total} 个测试点`
: title
}
export function getACRate(acCount: number, totalCount: number): string {
return `${calculateACRate(acCount, totalCount)}%`
}

View File

@@ -113,6 +113,9 @@ export const aiAnalysisRequestSchema = z.object({
/**
* 解锁「让 AI 分析我的代码」所需的失败提交数。前端拿它决定按钮露不露面、
* 后端拿它卡 POST /ai/hint —— 放在契约里就是为了不让两边各写一个 3。
*
* 编译失败不受这个门槛限制:报错只关乎语法、不涉及解法,而英文编译报错恰恰是
* 零基础学生最先撞上、最容易直接放弃的那堵墙。
*/
export const HINT_MIN_FAILURES = 3

View File

@@ -138,6 +138,18 @@ export const submissionDetailSchema = z.object({
*/
problemDisplayId: z.string(),
showLink: z.boolean(),
/**
* 通过了几个测试点,给学生看「离 AC 还差多远」。`info` 只给管理员(每个点带
* output_md5所以这里只算出两个数下发不放开原文。
*
* 为 null 的情形比赛提交ACM 只报对错,多给通过数等于变相放水,和 AI 提示同口径)、
* SQL 题(`judge/run.ts` 遇到被杀的测试点会 breaktotal 偏小)、没有逐点结果
* (待判、编译失败)。
*/
caseSummary: z.object({
passed: z.number().int(),
total: z.number().int(),
}).nullable(),
})
/**
@@ -152,7 +164,7 @@ export const embeddedSubmissionSchema = submissionDetailSchema
// problemDisplayId 也要去掉:下面的 problem 就是它,同一个值留两份,
// 而路由只填了 problem —— 这里漏 omit 的那阵子,凡是收到过站内信的人
// 打开消息页都是 500parse 抛在缺失的 problemDisplayId 上,列表为空时才碰巧不炸)。
.omit({ info: true, contestId: true, problemId: true, problemDisplayId: true })
.omit({ info: true, contestId: true, problemId: true, problemDisplayId: true, caseSummary: true })
// 旧 SubmissionSafeModelSerializer 里 problem 是
// `SlugRelatedField(slug_field="_id")`,即**展示用题号**而非数字主键。
// 站内信页面拿它拼 `/problem/<题号>` 链接,给数字 id 会拼出打不开的地址。