import { z } from 'zod' import { achievementRaritySchema } from './achievement' import { paginatedSchema, sampleUserSchema } from './common' import { judgeStatusSchema } from './judge-status' import { problemLanguageSchema } from './language' /** * 题目难度。生产库 956 道题只有这三个值(旧 Django 的 Problem.difficulty choices * 也是这三个),前端的 DIFFICULTY 映射表按它建 —— 写成 z.string() 的话 * 多出来的值会静默渲染成 undefined。 */ export const problemDifficultySchema = z.enum(['Low', 'Mid', 'High']) /** * 题目详情/列表里的难度。**可为 null** —— 比赛进行中、看的人又不是管理员时, * 难度和提交数一样属于「先别告诉参赛者」的信息(旧 Django 的 * `ProblemSafeSerializer` 直接把 difficulty 放进 exclude,字段整个不下发)。 * * 端上必须当「没有」处理,不要拿它去查 DIFFICULTY 映射表 —— 这正是 * problemDifficultySchema 写成严格枚举要防的那件事。 */ export const maskedProblemDifficultySchema = problemDifficultySchema.nullable() /** * SQL 题配置与展示数据。两者都是 `problem.sql_config` / `problem.sql_display` * 的 **JSONB 原文**,所以键名保持 snake_case —— 生产库 9 道 SQL 题存的就是这个形状 * (移植自旧后端 `judge/sql_runner.py:build_display`,逐条比对过,键集完全一致)。 * * 写成精确 schema 而不是 `z.record(z.unknown())`:前端原来得自己手抄一份 * SQLDisplay 接口才能渲染表格,抄错了没人拦得住。 */ export const sqlConfigSchema = z.object({ mode: z.enum(['query', 'modify']), order_sensitive: z.boolean(), }) /** 表格里的单元格。SQLite 只会给出这三种;BLOB 在落库前已转成十六进制字符串 */ const sqlCellSchema = z.union([z.string(), z.number(), z.null()]) const sqlDisplayColumnSchema = z.object({ name: z.string(), /** 表达式/聚合列(COUNT(*)、别名)在数据表里无同名列,类型为空串,前端据此隐藏 */ type: z.string(), }) const sqlResultSetSchema = z.object({ columns: z.array(sqlDisplayColumnSchema), rows: z.array(z.array(sqlCellSchema)), total_rows: z.number().int().nonnegative(), truncated: z.boolean(), }) export const sqlDisplayTableSchema = sqlResultSetSchema.extend({ name: z.string(), /** 被标准答案 DROP 的表:条目用初始数据补齐、rows 清空,前端提示「表已删除」 */ dropped: z.boolean().optional(), }) export const sqlDisplaySchema = z.object({ tables: z.array(sqlDisplayTableSchema), // query 题给结果集,modify 题给改动后的表 —— 两种形态,前端按有没有 // changed_tables 分支 expected: z.union([sqlResultSetSchema, z.object({ changed_tables: z.array(sqlDisplayTableSchema) })]), }) /** * AST 代码要求。同一个形状原来在**三个地方**各写了一份,三份都不一样: * apps/api/src/judge/ast.ts 的 AstRule(判题机真读的那份,九个字段)、 * apps/web/src/utils/types.ts 的 AstRules(少了 label / exact / outer / inner)、 * AstRulesEditor.vue 里的本地 AstRule(少了 outer / inner)。 * 编辑器写得出 label / exact,题目类型却描述不了它们。现在以这里为准。 * * 除 engine 外全部可选:判题机每条规则只读自己那几个字段 * (见 ast.ts 的 evaluateRule),缺了就走默认文案。 */ export const astRuleEngineSchema = z.enum([ 'must_exist_node', 'must_not_exist_node', 'count_node', 'must_call_function', 'must_not_call_function', 'count_function_call', 'must_call_method', 'must_not_call_method', 'must_use_operator', // 只有 Python 有关键字参数:「调用 print() 时必须用 sep 参数」 'must_use_keyword', // 判题机实现了,但后台编辑器还没有对应的选项,目前只能手工造数据用上 'must_have_nesting', ]) export const astRuleSchema = z.object({ engine: astRuleEngineSchema, /** 检查目标:节点类型 / 函数名 / 方法名 / 运算符,按 engine 而定 */ target: z.string().optional(), /** must_have_nesting 专用:外层、内层节点 */ outer: z.string().optional(), inner: z.string().optional(), /** must_use_keyword 专用:参数名(target 是函数名) */ keyword: z.string().optional(), /** 展示用的中文名,缺省回落到 target */ label: z.string().optional(), /** 自定义提示。生产库里存的是空串而不是缺键,判题机按 `||` 回落到默认文案 */ message: z.string().optional(), /** count_* 引擎的次数约束 */ exact: z.number().int().optional(), min: z.number().int().optional(), max: z.number().int().optional(), }) /** 按语言分组:`{ Python: [...], C: [...] }`,键是 languages 里的语言名 */ export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema)) /** * 一个 target 一条,`label` 给人看(后台下拉、题目页的「要求」),`node` 给判题机 * 拿去比 tree-sitter 的节点类型。 * * **这两半原来是分在两个包里的两张表** —— 这边 target → 中文名, * `apps/api/src/judge/ast.ts` 的 mappings 是 target → tree-sitter 节点类型,靠一句 * 「两边必须同增同减」的注释维持。只加一边是静默错判:老师给 C 题选到只有 Python * 有的 `list_comprehension`,判题机 `mapping[target] ?? target` 拿裸名去比节点类型, * C 的语法树里永远不存在它,于是「必须使用列表推导式」永远失败、「不能使用 * f-string」永远通过,两头都不报错,只有学生受着。并成一张之后,加 target 而漏配 * 节点类型在结构上就不可能了。 * * (更早之前 AstRulesEditor.vue 和 ProblemContent.vue 还各手抄过一份中文名, * 且 C 和 Python 的节点混在一张 15 条的表里铺成后台下拉。) */ export interface AstNodeTarget { /** 后台下拉和题目页展示的中文名 */ label: string /** tree-sitter 里对应的节点类型,判题机按它 collectNodes */ node: string } const C_NODE_TARGETS = { for_loop: { label: 'for 循环', node: 'for_statement' }, while_loop: { label: 'while 循环', node: 'while_statement' }, do_while: { label: 'do-while 循环', node: 'do_statement' }, if_statement: { label: 'if 条件', node: 'if_statement' }, else_clause: { label: 'else 子句', node: 'else_clause' }, switch_statement: { label: 'switch 语句', node: 'switch_statement' }, case_statement: { label: 'case 分支', node: 'case_statement' }, break: { label: 'break 语句', node: 'break_statement' }, continue: { label: 'continue 语句', node: 'continue_statement' }, return: { label: 'return 语句', node: 'return_statement' }, // 不算 main(判题机 judge/ast.ts 的 nodeMatch 里排掉的),所以不叫「函数定义」 function_definition: { label: '自定义函数', node: 'function_definition' }, assignment: { label: '赋值语句', node: 'assignment_expression' }, struct: { label: '结构体', node: 'struct_specifier' }, include: { label: '#include 指令', node: 'preproc_include' }, } satisfies Record export const AST_NODE_TARGETS_BY_LANGUAGE: Record> = { C: C_NODE_TARGETS, /** * tree-sitter-cpp 继承 tree-sitter-c 的语法,C 那 14 条在 C++ 树里逐个实测通用, * 所以共用的条目一律**引用** C_NODE_TARGETS 而不是抄一遍 —— 原来 C 的 14 行在 * 契约和判题机两个文件里各抄了两遍(C 一份、C++ 一份),同一份数据四份拷贝。 * 这里逐条列出来是为了保住下拉框的显示顺序(C++ 独有的几条是插在中间的)。 */ 'C++': { for_loop: C_NODE_TARGETS.for_loop, range_for_loop: { label: '范围 for 循环', node: 'for_range_loop' }, while_loop: C_NODE_TARGETS.while_loop, do_while: C_NODE_TARGETS.do_while, if_statement: C_NODE_TARGETS.if_statement, else_clause: C_NODE_TARGETS.else_clause, switch_statement: C_NODE_TARGETS.switch_statement, case_statement: C_NODE_TARGETS.case_statement, break: C_NODE_TARGETS.break, continue: C_NODE_TARGETS.continue, return: C_NODE_TARGETS.return, function_definition: C_NODE_TARGETS.function_definition, class_definition: { label: '类定义', node: 'class_specifier' }, struct: C_NODE_TARGETS.struct, assignment: C_NODE_TARGETS.assignment, include: C_NODE_TARGETS.include, try_except: { label: 'try-catch', node: 'try_statement' }, throw: { label: 'throw 语句', node: 'throw_statement' }, namespace: { label: 'namespace 定义', node: 'namespace_definition' }, template: { label: '模板定义', node: 'template_declaration' }, lambda: { label: 'lambda 表达式', node: 'lambda_expression' }, using: { label: 'using 声明', node: 'using_declaration' }, }, Python: { for_loop: { label: 'for 循环', node: 'for_statement' }, while_loop: { label: 'while 循环', node: 'while_statement' }, if_statement: { label: 'if 条件', node: 'if_statement' }, elif_clause: { label: 'elif 子句', node: 'elif_clause' }, else_clause: { label: 'else 子句', node: 'else_clause' }, break: { label: 'break 语句', node: 'break_statement' }, continue: { label: 'continue 语句', node: 'continue_statement' }, return: { label: 'return 语句', node: 'return_statement' }, function_definition: { label: '函数定义', node: 'function_definition' }, class_definition: { label: '类定义', node: 'class_definition' }, assignment: { label: '赋值语句', node: 'assignment' }, try_except: { label: 'try-except', node: 'try_statement' }, with_statement: { label: 'with 语句', node: 'with_statement' }, import: { label: 'import 语句', node: 'import_statement' }, import_from: { label: 'from-import 语句', node: 'import_from_statement' }, list_comprehension: { label: '列表推导式', node: 'list_comprehension' }, list_literal: { label: '列表', node: 'list' }, dict_literal: { label: '字典', node: 'dictionary' }, set_literal: { label: '集合', node: 'set' }, /** * f-string 认的是 `interpolation`(`f"{x}"` 里的 `{x}`),不是 `format_string` —— * 这个版本的 tree-sitter-python 里**没有** format_string 这种节点,f-string 是 * 一个 `string`,靠 `string_start` 为 `f"` 和内部的 interpolation 子节点来认。 * 配成 format_string 的那阵子,「不能使用 f-string」从上线起就一直判成通过。 * * 代价是 `f"abc"` 这种**没有占位符**的 f-string 认不出来(它确实不含 * interpolation)。没占位符的 f-string 本来也没有意义,而且比起「一个都认不出来」 * 这已经是严格的改善。实测 `%` 格式化、`.format()`、普通字符串、字符串拼接 * 都不会误伤。 */ f_string: { label: 'f-string', node: 'interpolation' }, }, } /** * 运算符 target → 该语言里的实际写法。逻辑名 `and` / `or` / `not` 在 C 里写作 * `&&` / `||` / `!`,文案要翻(否则 C 题的学生看到「必须使用 and 运算符」,而 C 里 * 根本没有 `and` 这个词),判题机也正好拿同一个值去比节点类型 —— 所以这张表**一份 * 两用**,不像节点那样需要两个字段。原来判题机的 mappings 里还抄了一份非恒等的 * (`and`→`&&` 那三条),取值逐个相同,纯属重复。 * * 恒等的条目(`+`、`==` …)写全是因为这张表同时是后台下拉的选项来源。 */ const C_OPERATOR_TARGETS = { '+': '+', '-': '-', '*': '*', '/': '/', '%': '%', '+=': '+=', '-=': '-=', '*=': '*=', '/=': '/=', '%=': '%=', '++': '++', '--': '--', '==': '==', '!=': '!=', '>': '>', '>=': '>=', '<': '<', '<=': '<=', and: '&&', or: '||', not: '!', '&': '&', '|': '|', } export const AST_OPERATOR_TARGETS_BY_LANGUAGE: Record> = { C: C_OPERATOR_TARGETS, // `<<` / `>>` 对 C++ 主要是 cout/cin 的流运算符(位移是同一个 token) 'C++': { ...C_OPERATOR_TARGETS, '<<': '<<', '>>': '>>' }, Python: { '+': '+', '-': '-', '*': '*', '/': '/', '//': '//', '%': '%', '**': '**', '+=': '+=', '-=': '-=', '*=': '*=', '/=': '/=', '%=': '%=', '==': '==', '!=': '!=', '>': '>', '>=': '>=', '<': '<', '<=': '<=', and: 'and', or: 'or', not: 'not', '&': '&', '|': '|', }, } /** * 判题机真正能做 AST 检查的语言(C / C++ / Python 三种,就是上面这张表的键)。设计文档写的是 * "支持全部 6 种语言",其余语言 judge/ast.ts 的 loadLanguage 返回 null,规则一条都不会跑。 * 所以后台不给别的语言开 tab,题目页也不把它们的规则展示成「要求」。 */ export const AST_SUPPORTED_LANGUAGES = Object.keys(AST_NODE_TARGETS_BY_LANGUAGE) /** 全语言的节点中文名并集,只给拿不到语言的场合做回落。有语言一律走 astNodeLabel() */ export const AST_NODE_TARGET_LABELS: Record = Object.fromEntries( Object.values(AST_NODE_TARGETS_BY_LANGUAGE).flatMap((table) => Object.entries(table).map(([target, entry]) => [target, entry.label])), ) export function astNodeLabel(target: string, language?: string): string { const table = language ? AST_NODE_TARGETS_BY_LANGUAGE[language] : undefined return table?.[target]?.label ?? AST_NODE_TARGET_LABELS[target] ?? target } /** * target → tree-sitter 节点类型。判题机唯一的解析入口 —— * 节点走上面那张表的 `node`,运算符走运算符表(它的值本身就是要比的 token), * 都对不上就回落到裸 target(恒等的运算符 `+` / `==` 走的就是这条)。 */ export function astTargetNodeType(target: string, language: string): string { return AST_NODE_TARGETS_BY_LANGUAGE[language]?.[target]?.node ?? AST_OPERATOR_TARGETS_BY_LANGUAGE[language]?.[target] ?? target } export function astOperatorLabel(target: string, language?: string): string { return (language ? AST_OPERATOR_TARGETS_BY_LANGUAGE[language]?.[target] : undefined) ?? target } /** * count_* 引擎至少要有一个数字约束,否则这条规则恒真 —— rangePassed 三个字段全 * undefined 直接返回 true,描述也退化成光秃秃一个「for 循环」。编辑器切到 * 「出现次数」时会清掉 exact/min/max,老师不填数字就会存出这种规则。 * * 读取路径(判题、题目页要求)用它把这种规则整条丢掉,而不是让 schema 校验失败: * astRulesSchema 同时用于**读**后台题目详情,在那儿抛错会让整个详情打不开。 */ export function astRuleIsMeaningful(rule: { engine: string; exact?: number; min?: number; max?: number }): boolean { if (!rule.engine.startsWith('count')) return true return rule.exact !== undefined || rule.min !== undefined || rule.max !== undefined } /** * 下发给**学生**的代码要求。只有渲染要用的两个字段 —— 文案由后端生成, * engine / target 这些内部字段不出现在响应里。 * * 旧后端的 ProblemSerializer 没排掉 ast_rules,学生拿到的是规则原文;阶段 3 * 泄露评审刻意收掉了它,同时写明「前端要读具体内容的话得补个专门的字段」—— * 就是这个。收紧保留,展示恢复。 */ export const astRequirementSchema = z.object({ /** 已经渲染好的中文文案,例如「if 条件 出现 2 次」 */ description: z.string(), /** 标签配色:必须做 / 不能做 / 次数约束 */ kind: z.enum(['require', 'forbid', 'count']), }) /** 按语言分组,与 astRulesSchema 同一套键 */ export const astRequirementsSchema = z.record(z.string(), z.array(astRequirementSchema)) /** * 挑战:做对之后,同一道题加一条写法上的限制再做一遍(「整首歌只用 1 次 print」)。 * 判定沿用 AST 规则,几条规则同时满足才算做到 —— 单写「用 while」的话学生留着原来的 * for、再补一个空 while 就过了,所以常配成「用 while + 不用 for」。 * * 和 astRules 一样按语言分组。`id` 是谁做到了(challenge_success)的记账键: * 规则一改就换新 id,旧的做到记录跟着旧规则作废,只改说法不换。 * `scannedAt` 是后端记的「已经拿历史上做对的代码补过一遍记录」,见 services/challenge.ts。 */ export const challengeSchema = z.object({ id: z.string(), /** 给学生看的说法,例如「整首歌只用 1 次 print」 */ wording: z.string(), rules: z.array(astRuleSchema), scannedAt: z.string().optional(), }) export const problemChallengesSchema = z.record(z.string(), z.array(challengeSchema)) /** 挑战说法的长度上限:要在结果页签的一行里放得下 */ export const CHALLENGE_WORDING_MAX = 24 /** 一条挑战最多几条规则、一道题一种语言最多几条挑战 */ export const CHALLENGE_RULES_MAX = 4 export const CHALLENGES_PER_LANGUAGE_MAX = 4 /** 我做到了一条挑战:本班、全站各是第几个做到的 */ export const challengeMineSchema = z.object({ at: z.string(), siteRank: z.number().int(), /** 没有班级(老师、自己注册的号)时为 null */ classRank: z.number().int().nullable(), }) /** 下发给学生的一条挑战:只有说法和人数,规则原文不出去(同 astRequirements) */ export const challengeItemSchema = z.object({ id: z.string(), wording: z.string(), /** 全站做到的人数 */ doneCount: z.number().int(), mine: challengeMineSchema.nullable(), }) /** GET /challenges:几道题、某种语言下的挑战 */ export const problemChallengeGroupSchema = z.object({ problemId: z.string(), title: z.string(), /** 这种语言做对过这道题的人数(「642 个做对的人里 62 人做到」的 642) */ acCount: z.number().int(), /** 我用这种语言做对过没有:没做对的挑战不给接 */ solved: z.boolean(), challenges: z.array(challengeItemSchema), }) export const challengeListSchema = z.array(problemChallengeGroupSchema) export const problemDetailSchema = z.object({ id: z.number().int(), _id: z.string(), title: z.string(), description: z.string(), inputDescription: z.string(), outputDescription: z.string(), samples: z.array( z.object({ input: z.string(), output: z.string(), }), ), hint: z.string().nullable(), languages: z.array(problemLanguageSchema), /** * 语言 → 代码模板。**用 partialRecord 让键受语言联合约束** —— * 原来这里是 `z.record(z.string(), z.string())`,等价于 `Record`, * 前端按语言查模板时拿不到任何键名保护(`template["Pytho3"]` 也是合法表达式)。 * * partialRecord 而非 record:没配模板的语言不该出现该键(`template: {}` 是常态), * 用 record 会要求每一个语言键都存在。 */ template: z.partialRecord(problemLanguageSchema, z.string()), createTime: z.string(), lastUpdateTime: z.string().nullable(), timeLimit: z.number().int(), memoryLimit: z.number().int(), difficulty: maskedProblemDifficultySchema, source: z.string().nullable(), prompt: z.string().nullable(), submissionNumber: z.number().int(), acceptedNumber: z.number().int(), statisticInfo: z.record(z.string(), z.unknown()), contestId: z.number().int().nullable(), tags: z.array(z.string()), createdBy: z.object({ id: z.number().int(), username: z.string(), realName: z.string().nullable(), }), myStatus: z.number().int().nullable(), myFailedCount: z.number().int(), allowFlowchart: z.boolean(), showFlowchart: z.boolean(), mermaidCode: z.string().nullable(), flowchartData: z.record(z.string(), z.unknown()).nullable(), flowchartHint: z.string().nullable(), sqlConfig: sqlConfigSchema.nullable(), sqlDisplay: sqlDisplaySchema.nullable(), // 代码要求(AST 规则的展示投影)。规则原文不下发给学生,见 astRequirementSchema astRequirements: astRequirementsSchema.nullable(), }) export type ProblemDetail = z.infer export const problemListItemSchema = z.object({ id: z.number().int(), _id: z.string(), title: z.string(), submissionNumber: z.number().int(), acceptedNumber: z.number().int(), difficulty: maskedProblemDifficultySchema, createdBy: sampleUserSchema, tags: z.array(z.string()), contestId: z.number().int().nullable(), allowFlowchart: z.boolean(), showFlowchart: z.boolean(), hasAstRules: z.boolean(), /** SQL 题(可选语言里有 SQL):写 SQL 语句查数据库,和写 C / Python 是两回事,列表里单独标出来 */ isSql: z.boolean(), /** 0 = 做对了。画流程图的题,流程图评到 A / S 也给 0(和专题、课堂条一个口径) */ myStatus: z.number().int().nullable(), /** * 做对的人 / 交过的人,和题目页「统计」页签同一个口径(代码提交、不算还在判的)。 * 代替原来的提交数和按提交算的通过率:1044 按提交算只有 69%,按人算 817 / 826 */ solvedUsers: z.number().int(), triedUsers: z.number().int(), }) export const problemListSchema = paginatedSchema(problemListItemSchema) /** * 相似题推荐的一条。`sharedTags` 是它和原题共有的标签(知识点在前、主题在后), * 学生不点开就知道练的是什么 */ export const similarProblemSchema = problemListItemSchema.extend({ sharedTags: z.array(z.string()), }) /** 题目列表「全部类型」下拉:SQL / 有参考图 / 画流程图 / 语法要求 */ export const problemTypeFilterSchema = z.enum(['sql', 'reference', 'flowchart', 'ast']) /** * 题目列表的排序:空串 = 最新创建、create_time = 最早创建, * ±submission_number / ±accepted_number 是做过 / 做对的**人数**,带 - 是从多到少 */ export const problemListSortSchema = z.enum(['', 'create_time', '-submission_number', 'submission_number', '-accepted_number', 'accepted_number']) /** * 题目列表顶上那行和左栏的进度。**只说做到了什么**:做对几道、这周新做对几道、 * 离哪个成就最近;没做对的只给一个数(「有 n 道差一点」),不列出来。 */ export const problemProgressSchema = z.object({ /** 做对的题数,和成就的「AC 题目数」同一个数(不含比赛),这样「再做对 n 道」加得上 */ solved: z.number().int(), /** 本周(东八区周一起)新做对几道 */ weekSolved: z.number().int(), /** 连续几周都有新做对(到本周或上周为止;断了就是 0) */ weekStreak: z.number().int(), /** 交过、还没做对的公开题。只在点「有 n 道差一点」时弹出来看,不常驻在页面上 */ almost: z.array(z.object({ _id: z.string(), title: z.string() })), /** 标签 id → 这个标签下做对了几道(只数公开可见的题) */ byTag: z.record(z.string(), z.number().int()), /** 离得最近的下一个成就;全拿到了、或者没有能提示的为 null */ next: z .object({ name: z.string(), icon: z.string(), rarity: achievementRaritySchema, metric: z.string(), threshold: z.number().int(), progress: z.number().int(), }) .nullable(), }) /** * 标签分两类:`knowledge` 是知识点(循环结构、字符串……),`theme` 是题目的 * 题材(LOL、周杰伦、哈利波特……)。两类原来混在一起,题目列表上四十多个标签 * 挤成一片,学生想按知识点找题得在一堆游戏名里挑。新标签默认算知识点。 */ export const tagCategorySchema = z.enum(['knowledge', 'theme']) export const tagSchema = z.object({ id: z.number().int(), name: z.string(), category: tagCategorySchema, problemCount: z.number().int().nonnegative(), }) export const problemAuthorSchema = z.object({ username: z.string(), problemCount: z.number().int().nonnegative(), }) const failureCountSchema = z.object({ result: judgeStatusSchema, count: z.number().int() }) /** * 题目页「统计」页签。全部**按人**算,不按提交条数 —— 原来的「通过数」是 AC 条数 * (3092 是 219 条、202 个人),「通过率」是按提交算的,学生看了不知道这题难不难。 * 原来的「击败用户」(分母是近两年所有登录过的人)、饼图、历年 AC 率一起去掉了。 */ export const problemStatsSchema = z.object({ /** 比赛还没结束(又不是比赛管理员):别人的情况一律不给,下面的数字都是 0 / 空 */ locked: z.boolean(), /** 交过的人 */ tried: z.number().int(), solved: z.number().int(), /** 做对的人用了几次:1 次 / 2–3 次 / 4 次以上 */ tries: z.object({ one: z.number().int(), few: z.number().int(), many: z.number().int() }), /** 没通过的提交按结果分,多的在前(不含判题中) */ failures: z.array(failureCountSchema), /** 我自己:交了几次、做对没有。没登录为 null */ me: z.object({ attempts: z.number().int(), solved: z.boolean() }).nullable(), /** 学生自己班这题 x / y 人做对(y 是班里交过的人)。没班级、比赛里为 null */ myClass: z.object({ className: z.string(), tried: z.number().int(), solved: z.number().int() }).nullable(), /** * 老师:各班做这道题的情况,和数据统计「按班级汇总」**同一个口径**(只算学生的提交; * 做完 / 班级人数、交了没对、没交、正确率、最近一次),交过的人多的在前。 * 两边数字要一模一样 —— 老师从这里点过去,看到的数「变了」就不会再信任何一边。 * 学生、比赛里为 null */ classes: z .array( z.object({ /** null = 没有班级的号(数据统计那边写「没有班级」) */ className: z.string().nullable(), /** 班里的学生数(学生角色、没禁用);没有班级的那一行就是交过的人数 */ classSize: z.number().int(), solved: z.number().int(), /** 交了但没做对的人 */ unsolved: z.number().int(), /** 班里还没交过的人 */ untouched: z.number().int(), /** 提交正确率,分母不算还在判的;一条都没判完为 null */ correctRate: z.number().nullable(), lastTime: z.string(), }), ) .nullable(), }) export type AstRuleEngine = z.infer export type AstRule = z.infer export type AstRules = z.infer export type AstRequirement = z.infer export type AstRequirements = z.infer export type Challenge = z.infer export type ProblemChallenges = z.infer export type ChallengeMine = z.infer export type ChallengeItem = z.infer export type ProblemChallengeGroup = z.infer export type ChallengeList = z.infer export type ProblemDifficulty = z.infer export type ProblemListItem = z.infer export type ProblemList = z.infer export type SimilarProblem = z.infer export type ProblemTypeFilter = z.infer export type ProblemListSort = z.infer export type ProblemProgress = z.infer export type Tag = z.infer export type TagCategory = z.infer export type ProblemAuthor = z.infer export type SqlConfig = z.infer export type SqlDisplay = z.infer export type SqlDisplayTable = z.infer export type SqlDisplayColumn = z.infer export type ProblemStats = z.infer