From 9132901cc7ac7293bb56af4b527e55955ad5f6e5 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 13 Sep 2026 07:25:00 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E6=8F=90=E4=BA=A4=E7=BB=93=E6=9E=9C):=20?= =?UTF-8?q?=E6=B2=A1=E9=80=9A=E8=BF=87=E6=97=B6=E6=98=BE=E7=A4=BA=E3=80=8C?= =?UTF-8?q?=E9=80=9A=E8=BF=87=20x/y=20=E4=B8=AA=E6=B5=8B=E8=AF=95=E7=82=B9?= =?UTF-8?q?=E3=80=8D=EF=BC=9B=E7=BC=96=E8=AF=91=E5=A4=B1=E8=B4=A5=E4=B8=8D?= =?UTF-8?q?=E7=AD=89=E4=B8=89=E6=AC=A1=E5=B0=B1=E8=83=BD=E7=94=A8=20AI=20?= =?UTF-8?q?=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 学生拿不到 info(每个点带 output_md5,只给管理员),测试点表格从来只有管理员看得见, 学生这边只有一句「答案错误」—— 从 2/8 交到 6/8 的人,感受是连输五次。 - 提交详情新增 caseSummary,后端从 info 数出通过数下发,不放开原文。 比赛提交、SQL 题(被杀的测试点会 break,total 偏小)、无逐点结果时为 null - 结果面板标题缀上通过数并加进度条,提交详情页标题同步;一个都没过时不缀 - POST /ai/hint 对编译失败跳过失败次数门槛,throttleAi 照旧;前端显示条件同口径 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013TecwAowmYcoNZRheSyTgH --- apps/api/src/routes/ai.ts | 8 +++-- apps/api/src/routes/submission.ts | 13 ++++++++ .../problem/components/SubmissionResult.vue | 23 ++++++++++++-- apps/web/src/oj/submission/detail.vue | 3 +- apps/web/src/utils/functions.ts | 30 +++++++++++++++++-- packages/contract/src/ai.ts | 3 ++ packages/contract/src/submission.ts | 14 ++++++++- 7 files changed, 85 insertions(+), 9 deletions(-) diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index 57fdb98..fe13850 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -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 的 diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 27082cc..d8a056b 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -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 } diff --git a/apps/web/src/oj/problem/components/SubmissionResult.vue b/apps/web/src/oj/problem/components/SubmissionResult.vue index 03f88cc..9ec20d2 100644 --- a/apps/web/src/oj/problem/components/SubmissionResult.vue +++ b/apps/web/src/oj/problem/components/SubmissionResult.vue @@ -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[] = [
+ > + + 提交时间:{{ parseTime(submission.createTime) }} diff --git a/apps/web/src/utils/functions.ts b/apps/web/src/utils/functions.ts index 24fd536..290aff5 100644 --- a/apps/web/src/utils/functions.ts +++ b/apps/web/src/utils/functions.ts @@ -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 | 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, +) { + 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)}%` } diff --git a/packages/contract/src/ai.ts b/packages/contract/src/ai.ts index b62b931..8408bf7 100644 --- a/packages/contract/src/ai.ts +++ b/packages/contract/src/ai.ts @@ -113,6 +113,9 @@ export const aiAnalysisRequestSchema = z.object({ /** * 解锁「让 AI 分析我的代码」所需的失败提交数。前端拿它决定按钮露不露面、 * 后端拿它卡 POST /ai/hint —— 放在契约里就是为了不让两边各写一个 3。 + * + * 编译失败不受这个门槛限制:报错只关乎语法、不涉及解法,而英文编译报错恰恰是 + * 零基础学生最先撞上、最容易直接放弃的那堵墙。 */ export const HINT_MIN_FAILURES = 3 diff --git a/packages/contract/src/submission.ts b/packages/contract/src/submission.ts index 6a33e55..3551e3a 100644 --- a/packages/contract/src/submission.ts +++ b/packages/contract/src/submission.ts @@ -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` 遇到被杀的测试点会 break,total 偏小)、没有逐点结果 + * (待判、编译失败)。 + */ + caseSummary: z.object({ + passed: z.number().int(), + total: z.number().int(), + }).nullable(), }) /** @@ -152,7 +164,7 @@ export const embeddedSubmissionSchema = submissionDetailSchema // problemDisplayId 也要去掉:下面的 problem 就是它,同一个值留两份, // 而路由只填了 problem —— 这里漏 omit 的那阵子,凡是收到过站内信的人 // 打开消息页都是 500(parse 抛在缺失的 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 会拼出打不开的地址。