Some checks failed
Deploy / deploy (push) Has been cancelled
自学模块以前一个字节都不落库:读到第几课只存在浏览器的 localStorage 里, 练一练的对错是组件内的一个 ref,刷新即失忆。老师能看到的只有「谁交了题」。 现在两张新表: * tutorial_progress —— 一个学生 × 一课,记打开次数和累计停留秒数 * exercise_attempt —— 一个学生 × 一道练习,记试了几次、错了几次、 第几次做对的、最后一次做错时填的什么 都存聚合不存流水。练习那张表尤其明显:流水会随着学生反复点提交无限长, 而多出来的行回答不了任何新问题 ——「他第 3 次和第 5 次都选了 B」对老师 没有意义,「他试了 7 次才对」有。 停留时长只在页面可见、且十分钟内有过操作时才计。机房的电脑经常开着页面 就走了,不设这道闸的话「停留时长」会变成「电脑开机时长」,老师看到的 数字全是假的。换课、切标签页、关窗口都会先把攒着的秒数冲给**离开的那一课**。 练一练的对错仍然是前端判的:答案本来就随题面一起下发到浏览器,后端再判 一遍也挡不住任何人,只是重复实现七套判题。所以这是教学观察数据,不是成绩。 `last_wrong_answer` 存的是前端拼好的一句人话(「选了 C」「顺序 3-1-2」), 不是原始作答结构 —— 七种题型形状各不相同,存结构就得在后台按题型各写一套 渲染,而老师要看的只是他错在哪。 顺带修掉预测输出题的一个老问题:它的 `submitted` 一旦为真就不再收回,而 `allCorrect` 是跟着输入实时算的,于是学生错一次之后把答案改对,界面直接 跳成「输出正确!」、提交按钮同时禁用,submit() 再也执行不到 —— 这道题 **永远不会被记成做对**。排序/连线/找错/分组四种题本来就在交互处把 submitted 置回 false,只有这里漏了,按同一套补上。 学生端:目录每课显示「✓ 已读 · 11 分钟」和「练一练 3/5」。教程保持免登录 可读,未登录只是不留痕,并明说一句。 老师端:后台新开「自学情况」(教师及以上可进),三个 tab —— 按学生(默认把读得最少的排在最前,这张表要回答的是谁还没开始)、 按练习(每道题的正确率、一次做对几人、做对的人平均试几次;展开看逐人明细 和他们最后错在哪)、按课程。班级框填 3-4 位是具体班级,1-2 位当年级前缀。 外键用了库级 CASCADE,和 Django 建的那批 NO ACTION 不同:删教程、删用户 不必再记得回来手工清子表。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GW5ef6C2kRW8Ru27ghCaUu
872 lines
32 KiB
TypeScript
872 lines
32 KiB
TypeScript
import { z } from "zod"
|
||
|
||
import { achievementRaritySchema } from "./achievement"
|
||
import { rankProfileSchema } from "./account"
|
||
import { paginatedSchema, sampleUserSchema } from "./common"
|
||
import { reactionKeySchema } from "./content"
|
||
import {
|
||
astRulesSchema,
|
||
problemDifficultySchema,
|
||
sqlConfigSchema,
|
||
sqlDisplaySchema,
|
||
} from "./problem"
|
||
|
||
/**
|
||
* 后台侧的契约。与 oj 侧分开放:同一张表在两侧下发的字段集通常不同
|
||
* (后台要 `visible` 这类管理字段,oj 侧连键都不该出现),
|
||
* 混在一个 schema 里迟早会有人为了省事在 oj 侧复用后台那个。
|
||
*/
|
||
|
||
export const adminAnnouncementSchema = z.object({
|
||
id: z.number().int(),
|
||
title: z.string(),
|
||
tag: z.string(),
|
||
content: z.string(),
|
||
visible: z.boolean(),
|
||
top: z.boolean(),
|
||
createdBy: sampleUserSchema,
|
||
createTime: z.string(),
|
||
lastUpdateTime: z.string(),
|
||
})
|
||
|
||
// 列表不带 content:公告正文是 8MB 上限的富文本,列表页只显示标题
|
||
export const adminAnnouncementListItemSchema = adminAnnouncementSchema.omit({
|
||
content: true,
|
||
})
|
||
|
||
export const adminAnnouncementListSchema = paginatedSchema(
|
||
adminAnnouncementListItemSchema,
|
||
)
|
||
|
||
export const createAnnouncementRequestSchema = z.object({
|
||
title: z.string().trim().min(1).max(64),
|
||
tag: z.string().max(64),
|
||
content: z.string().max(1024 * 1024 * 8),
|
||
visible: z.boolean(),
|
||
top: z.boolean(),
|
||
})
|
||
|
||
export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema
|
||
|
||
export type ProblemSample = z.infer<typeof problemSampleSchema>
|
||
export type ProblemAnswer = z.infer<typeof problemAnswerSchema>
|
||
export type ProblemTestCaseScore = z.infer<typeof problemTestCaseScoreSchema>
|
||
export type AdminAnnouncement = z.infer<typeof adminAnnouncementSchema>
|
||
export type AdminAnnouncementListItem = z.infer<
|
||
typeof adminAnnouncementListItemSchema
|
||
>
|
||
|
||
// ---------------------------------------------------------------- 教程 / 练习
|
||
|
||
export const tutorialTypeSchema = z.enum(["python", "c"])
|
||
|
||
export const adminTutorialSchema = z.object({
|
||
id: z.number().int(),
|
||
title: z.string(),
|
||
content: z.string(),
|
||
code: z.string().nullable(),
|
||
isPublic: z.boolean(),
|
||
order: z.number().int(),
|
||
type: tutorialTypeSchema,
|
||
createdBy: sampleUserSchema,
|
||
createdAt: z.string(),
|
||
updatedAt: z.string(),
|
||
})
|
||
|
||
export const adminTutorialListItemSchema = adminTutorialSchema.omit({
|
||
// 教程正文是整篇 markdown,列表页只排序和切换可见性,不需要它
|
||
content: true,
|
||
code: true,
|
||
})
|
||
|
||
/** 后台教程列表按语言分组返回,对齐旧 TutorialAdminAPI.get 的 `{python, c}` 形状 */
|
||
export const adminTutorialGroupsSchema = z.object({
|
||
python: z.array(adminTutorialListItemSchema),
|
||
c: z.array(adminTutorialListItemSchema),
|
||
})
|
||
|
||
export const createTutorialRequestSchema = z.object({
|
||
title: z.string().trim().min(1).max(128),
|
||
content: z.string(),
|
||
code: z.string().nullable().default(null),
|
||
isPublic: z.boolean().default(false),
|
||
order: z.number().int().default(0),
|
||
type: tutorialTypeSchema,
|
||
})
|
||
|
||
export const updateTutorialRequestSchema = createTutorialRequestSchema
|
||
export const setTutorialVisibilityRequestSchema = z.object({ isPublic: z.boolean() })
|
||
|
||
export const exerciseTypeSchema = z.enum([
|
||
"mcq",
|
||
"sort",
|
||
"fill",
|
||
"match",
|
||
"predict",
|
||
"debug",
|
||
"group",
|
||
])
|
||
|
||
export const adminExerciseSchema = z.object({
|
||
id: z.number().int(),
|
||
type: exerciseTypeSchema,
|
||
data: z.record(z.string(), z.unknown()),
|
||
order: z.number().int(),
|
||
})
|
||
|
||
export const createExerciseRequestSchema = z.object({
|
||
tutorialId: z.number().int().positive(),
|
||
type: exerciseTypeSchema,
|
||
data: z.record(z.string(), z.unknown()),
|
||
order: z.number().int().default(0),
|
||
})
|
||
|
||
export const updateExerciseRequestSchema = createExerciseRequestSchema.omit({
|
||
// 练习不支持改挂到别的教程下 —— 旧 EditExerciseSerializer 也没有 tutorial_id
|
||
tutorialId: true,
|
||
})
|
||
|
||
// ---------------------------------------------------------------- AI 报告
|
||
|
||
/**
|
||
* 列表只给摘要,正文要点开详情才拿 —— 与旧 AIAnalysisListSerializer 一致。
|
||
* `data`(原始 prompt 与结构化输入)和 `system_prompt` / `user_prompt` 两侧都不下发。
|
||
*/
|
||
export const adminAiReportListItemSchema = z.object({
|
||
id: z.number().int(),
|
||
username: z.string(),
|
||
createTime: z.string(),
|
||
analysisExcerpt: z.string(),
|
||
isPinned: z.boolean(),
|
||
})
|
||
|
||
export const adminAiReportSchema = z.object({
|
||
id: z.number().int(),
|
||
username: z.string(),
|
||
className: z.string().nullable(),
|
||
createTime: z.string(),
|
||
analysis: z.string(),
|
||
})
|
||
|
||
export const adminAiReportListSchema = paginatedSchema(adminAiReportListItemSchema)
|
||
export const toggleAiReportPinResponseSchema = z.object({ isPinned: z.boolean() })
|
||
|
||
// ---------------------------------------------------------------- 成就
|
||
|
||
// rarity 复用 achievement.ts 里已有的定义,不重复声明 —— 两份枚举迟早会长歪
|
||
export const achievementOperatorSchema = z.enum(["gte", "lte"])
|
||
|
||
export const achievementMetricSchema = z.object({
|
||
key: z.string(),
|
||
name: z.string(),
|
||
helpText: z.string(),
|
||
})
|
||
|
||
export const adminAchievementSchema = z.object({
|
||
id: z.number().int(),
|
||
name: z.string(),
|
||
description: z.string(),
|
||
icon: z.string(),
|
||
rarity: achievementRaritySchema,
|
||
hidden: z.boolean(),
|
||
metric: z.string(),
|
||
metricName: z.string(),
|
||
operator: achievementOperatorSchema,
|
||
threshold: z.number().int(),
|
||
visible: z.boolean(),
|
||
// 后台列表必须显示:阈值配错时学生永远拿不到也永远不会来问,这个计数器是唯一的仪表盘
|
||
unlockCount: z.number().int(),
|
||
order: z.number().int(),
|
||
createTime: z.string().nullable(),
|
||
})
|
||
|
||
export const createAchievementRequestSchema = z.object({
|
||
name: z.string().trim().min(1).max(64),
|
||
description: z.string().trim().min(1),
|
||
icon: z.string().trim().min(1),
|
||
rarity: achievementRaritySchema,
|
||
hidden: z.boolean().default(false),
|
||
metric: z.string().min(1),
|
||
operator: achievementOperatorSchema,
|
||
threshold: z.number().int(),
|
||
visible: z.boolean().default(true),
|
||
order: z.number().int().default(0),
|
||
})
|
||
|
||
export const updateAchievementRequestSchema = createAchievementRequestSchema
|
||
|
||
// ---------------------------------------------------------------- 用户管理
|
||
|
||
export const adminUserSchema = z.object({
|
||
id: z.number().int(),
|
||
username: z.string(),
|
||
email: z.string().nullable(),
|
||
adminType: z.string(),
|
||
problemPermission: z.string(),
|
||
realName: z.string().nullable(),
|
||
createTime: z.string().nullable(),
|
||
lastLogin: z.string().nullable(),
|
||
openApi: z.boolean(),
|
||
isDisabled: z.boolean(),
|
||
// 明文密码。是有意保留的运营需求:老师要能查学生的密码。
|
||
// 只在超管专属的这一个接口下发,别往任何其它地方复制。
|
||
rawPassword: z.string().nullable(),
|
||
className: z.string().nullable(),
|
||
})
|
||
|
||
export const adminUserListSchema = paginatedSchema(adminUserSchema)
|
||
|
||
export const updateUserRequestSchema = z.object({
|
||
username: z.string().trim().min(1).max(32),
|
||
email: z.email().max(64),
|
||
adminType: z.enum(["Regular User", "Student Admin", "Teacher Admin", "Super Admin"]),
|
||
problemPermission: z.enum(["None", "Own", "All"]),
|
||
realName: z.string().max(32).nullable().default(null),
|
||
isDisabled: z.boolean(),
|
||
openApi: z.boolean(),
|
||
// 空串表示不改密码,与旧 EditUserSerializer 的 allow_blank 一致
|
||
password: z.string().max(128).default(""),
|
||
})
|
||
|
||
/** 导入用户:每行 [用户名, 密码, 邮箱, 真名],与前端粘贴的 Excel 列序一致 */
|
||
export const importUsersRequestSchema = z.object({
|
||
users: z.array(z.tuple([
|
||
z.string().trim().min(1).max(32),
|
||
z.string().min(1),
|
||
z.string(),
|
||
z.string(),
|
||
])).min(1),
|
||
})
|
||
|
||
export const deleteUsersRequestSchema = z.object({
|
||
ids: z.array(z.number().int().positive()).min(1),
|
||
})
|
||
|
||
export const resetPasswordResponseSchema = z.object({ password: z.string() })
|
||
|
||
// ---------------------------------------------------------------- 站点配置 / 运维
|
||
|
||
export const updateWebsiteConfigRequestSchema = z.object({
|
||
websiteBaseUrl: z.string().max(256),
|
||
websiteName: z.string().trim().min(1).max(64),
|
||
websiteNameShortcut: z.string().trim().min(1).max(32),
|
||
websiteFooter: z.string().max(1024 * 64),
|
||
allowRegister: z.boolean(),
|
||
submissionListShowAll: z.boolean(),
|
||
classList: z.array(z.string()),
|
||
enableMaxkb: z.boolean(),
|
||
})
|
||
|
||
export const judgeServerSchema = z.object({
|
||
id: z.number().int(),
|
||
hostname: z.string(),
|
||
ip: z.string().nullable(),
|
||
judgerVersion: z.string(),
|
||
cpuCore: z.number().int(),
|
||
memoryUsage: z.number(),
|
||
cpuUsage: z.number(),
|
||
lastHeartbeat: z.string(),
|
||
createTime: z.string(),
|
||
taskNumber: z.number().int(),
|
||
serviceUrl: z.string().nullable(),
|
||
isDisabled: z.boolean(),
|
||
/** 心跳在 6 秒内才算在线,与 dashboard 的判活口径一致 */
|
||
status: z.enum(["normal", "abnormal"]),
|
||
})
|
||
|
||
export const judgeServerListSchema = z.object({
|
||
token: z.string(),
|
||
servers: z.array(judgeServerSchema),
|
||
})
|
||
|
||
export const updateJudgeServerRequestSchema = z.object({ isDisabled: z.boolean() })
|
||
|
||
export const orphanTestCaseSchema = z.object({
|
||
id: z.string(),
|
||
createTime: z.number(),
|
||
})
|
||
|
||
/**
|
||
* 后台的用户排名列表。行的形状与 oj 侧榜单相同,但**没有 100 名的上限**:
|
||
* 这里不是榜单,是老师按班级前缀翻全班学生的管理视图,要能一直翻到底。
|
||
*
|
||
* 独立成一条端点而不是给公开榜单加个「不限量」开关:那个开关一旦存在,
|
||
* 匿名请求就能拉走全校学生名单和个性签名。
|
||
*/
|
||
export const adminUserRankSchema = paginatedSchema(rankProfileSchema)
|
||
|
||
export const dashboardInfoSchema = z.object({
|
||
userCount: z.number().int(),
|
||
recentContestCount: z.number().int(),
|
||
todaySubmissionCount: z.number().int(),
|
||
judgeServerCount: z.number().int(),
|
||
})
|
||
|
||
export const uploadImageResponseSchema = z.object({
|
||
success: z.boolean(),
|
||
msg: z.string(),
|
||
filePath: z.string(),
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 比赛管理
|
||
|
||
export const adminContestSchema = z.object({
|
||
id: z.number().int(),
|
||
title: z.string(),
|
||
description: z.string(),
|
||
tag: z.string(),
|
||
startTime: z.string(),
|
||
endTime: z.string(),
|
||
createTime: z.string(),
|
||
lastUpdateTime: z.string(),
|
||
// 后台要能看到自己设的密码(用来告诉学生),oj 侧的 contestSchema 则永远不含它
|
||
password: z.string().nullable(),
|
||
visible: z.boolean(),
|
||
allowedIpRanges: z.array(z.string()),
|
||
createdBy: sampleUserSchema,
|
||
status: z.enum(["1", "0", "-1"]),
|
||
contestType: z.enum(["Public", "Password Protected"]),
|
||
})
|
||
|
||
export const adminContestListSchema = paginatedSchema(adminContestSchema)
|
||
|
||
export const createContestRequestSchema = z.object({
|
||
title: z.string().trim().min(1).max(128),
|
||
description: z.string(),
|
||
tag: z.string().max(64),
|
||
startTime: z.string().min(1),
|
||
endTime: z.string().min(1),
|
||
// 空串等同于「不设密码」,与旧 CreateConetestSeriaizer 的 allow_blank 一致
|
||
password: z.string().max(32).nullable().default(null),
|
||
visible: z.boolean(),
|
||
allowedIpRanges: z.array(z.string().max(32)).default([]),
|
||
})
|
||
|
||
export const updateContestRequestSchema = createContestRequestSchema
|
||
|
||
export const acmHelperItemSchema = z.object({
|
||
id: z.number().int(),
|
||
username: z.string(),
|
||
realName: z.string().nullable(),
|
||
problemId: z.string(),
|
||
problemDisplayId: z.string(),
|
||
acInfo: z.record(z.string(), z.unknown()),
|
||
checked: z.boolean(),
|
||
})
|
||
|
||
export const updateAcmHelperRequestSchema = z.object({
|
||
rankId: z.number().int().positive(),
|
||
problemId: z.string().min(1),
|
||
checked: z.boolean(),
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 题单管理
|
||
|
||
export const problemSetDifficultySchema = z.enum(["Easy", "Medium", "Hard"])
|
||
export const problemSetStatusSchema = z.enum(["draft", "active", "archived"])
|
||
export const badgeConditionTypeSchema = z.enum(["all_problems", "problem_count", "score"])
|
||
|
||
export const adminProblemSetSchema = z.object({
|
||
id: z.number().int(),
|
||
title: z.string(),
|
||
description: z.string(),
|
||
difficulty: problemSetDifficultySchema,
|
||
status: problemSetStatusSchema,
|
||
endTime: z.string().nullable(),
|
||
visible: z.boolean(),
|
||
createdBy: sampleUserSchema,
|
||
createTime: z.string(),
|
||
lastUpdateTime: z.string(),
|
||
problemsCount: z.number().int(),
|
||
/** 加入这份题单的人数,后台用来判断改动会影响多少人 */
|
||
participantCount: z.number().int(),
|
||
})
|
||
|
||
export const adminProblemSetListSchema = paginatedSchema(adminProblemSetSchema)
|
||
|
||
export const createProblemSetRequestSchema = z.object({
|
||
title: z.string().trim().min(1).max(200),
|
||
description: z.string(),
|
||
difficulty: problemSetDifficultySchema.default("Easy"),
|
||
status: problemSetStatusSchema.default("active"),
|
||
endTime: z.string().nullable().default(null),
|
||
visible: z.boolean().default(true),
|
||
})
|
||
|
||
export const updateProblemSetRequestSchema = createProblemSetRequestSchema
|
||
export const updateProblemSetStatusRequestSchema = z.object({ status: problemSetStatusSchema })
|
||
|
||
export const adminProblemSetProblemSchema = z.object({
|
||
id: z.number().int(),
|
||
problemsetId: z.number().int(),
|
||
problemId: z.number().int(),
|
||
displayId: z.string(),
|
||
title: z.string(),
|
||
difficulty: z.string(),
|
||
order: z.number().int(),
|
||
isRequired: z.boolean(),
|
||
score: z.number().int(),
|
||
hint: z.string().nullable(),
|
||
})
|
||
|
||
export const addProblemToSetRequestSchema = z.object({
|
||
/** 展示用题号(_id),不是自增主键 —— 老师手里只有题号 */
|
||
problemId: z.string().trim().min(1),
|
||
order: z.number().int().default(0),
|
||
isRequired: z.boolean().default(true),
|
||
score: z.number().int().default(0),
|
||
hint: z.string().default(""),
|
||
})
|
||
|
||
export const updateProblemInSetRequestSchema = z.object({
|
||
order: z.number().int().optional(),
|
||
isRequired: z.boolean().optional(),
|
||
score: z.number().int().optional(),
|
||
hint: z.string().optional(),
|
||
})
|
||
|
||
export const adminProblemSetBadgeSchema = z.object({
|
||
id: z.number().int(),
|
||
problemsetId: z.number().int(),
|
||
name: z.string(),
|
||
description: z.string(),
|
||
icon: z.string(),
|
||
conditionType: badgeConditionTypeSchema,
|
||
conditionValue: z.number().int(),
|
||
/** 已获得该奖章的人数,后台改条件前要能看到影响面 */
|
||
earnedCount: z.number().int(),
|
||
})
|
||
|
||
export const createProblemSetBadgeRequestSchema = z.object({
|
||
name: z.string().trim().min(1).max(100),
|
||
description: z.string(),
|
||
icon: z.string(),
|
||
conditionType: badgeConditionTypeSchema,
|
||
conditionValue: z.number().int().default(0),
|
||
})
|
||
|
||
export const updateProblemSetBadgeRequestSchema = createProblemSetBadgeRequestSchema
|
||
|
||
export const adminProblemSetProgressSchema = z.object({
|
||
id: z.number().int(),
|
||
userId: z.number().int(),
|
||
username: z.string(),
|
||
realName: z.string().nullable(),
|
||
joinTime: z.string(),
|
||
completeTime: z.string().nullable(),
|
||
isCompleted: z.boolean(),
|
||
progressPercentage: z.number(),
|
||
completedProblemsCount: z.number().int(),
|
||
totalProblemsCount: z.number().int(),
|
||
totalScore: z.number().int(),
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 标签与题目分析
|
||
|
||
export const adminTagSchema = z.object({
|
||
id: z.number().int(),
|
||
name: z.string(),
|
||
problemCount: z.number().int(),
|
||
})
|
||
|
||
export const renameTagRequestSchema = z.object({ name: z.string().trim().min(1).max(64) })
|
||
|
||
export const renameTagResponseSchema = z.object({
|
||
/** 改名撞上已有标签时视为合并:题目关系转移过去、原标签删除 */
|
||
merged: z.boolean(),
|
||
id: z.number().int(),
|
||
name: z.string(),
|
||
affectedCount: z.number().int(),
|
||
})
|
||
|
||
export const batchProblemTagRequestSchema = z.object({
|
||
problemIds: z.array(z.number().int().positive()).min(1),
|
||
tagNames: z.array(z.string()).min(1),
|
||
action: z.enum(["add", "remove"]),
|
||
})
|
||
|
||
export const batchProblemTagResponseSchema = z.object({
|
||
problemCount: z.number().int(),
|
||
tagCount: z.number().int(),
|
||
})
|
||
|
||
export const stuckProblemSchema = z.object({
|
||
problemId: z.string(),
|
||
problemTitle: z.string(),
|
||
total: z.number().int(),
|
||
failed: z.number().int(),
|
||
failedUsers: z.number().int(),
|
||
acRate: z.number(),
|
||
})
|
||
|
||
export const acTrendYearSchema = z.object({
|
||
year: z.number().int(),
|
||
total: z.number().int(),
|
||
accepted: z.number().int(),
|
||
acRate: z.number(),
|
||
})
|
||
|
||
export const acTrendSchema = z.object({
|
||
problemId: z.string(),
|
||
problemTitle: z.string(),
|
||
yearly: z.array(acTrendYearSchema),
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 自学情况
|
||
|
||
/**
|
||
* 「自学情况」按学生的一行。**没读过任何一课的学生也要有一行**(readCount = 0)——
|
||
* 这张表首要回答的问题是「谁还没开始」,只列有记录的人等于把该看的人筛掉了。
|
||
*/
|
||
export const learnStudentProgressSchema = z.object({
|
||
userId: z.number().int(),
|
||
username: z.string(),
|
||
realName: z.string().nullable(),
|
||
className: z.string().nullable(),
|
||
readCount: z.number().int(),
|
||
totalSeconds: z.number().int(),
|
||
lastViewedAt: z.string().nullable(),
|
||
// 练一练:做过几道、做对几道、一共点了几次提交
|
||
exerciseTried: z.number().int(),
|
||
exerciseSolved: z.number().int(),
|
||
exerciseAttempts: z.number().int(),
|
||
})
|
||
|
||
export const learnStudentProgressListSchema = z.object({
|
||
// 分母:该语言下已公开的教程篇数,前端拿它显示 3/17
|
||
tutorialCount: z.number().int(),
|
||
// 分母:这些教程里一共有多少道练一练
|
||
exerciseCount: z.number().int(),
|
||
results: z.array(learnStudentProgressSchema),
|
||
})
|
||
|
||
/**
|
||
* 「按练习」一行。回答的是「哪道练习卡住了全班」——
|
||
* `solvedUsers / triedUsers` 是正确率,`avgAttemptsToSolve` 是做对的人平均试了几次,
|
||
* `firstTryUsers` 是一次就做对的人数。三个一起看才分得清「题目难」和「题目有歧义」:
|
||
* 前者是试了几次终于做对,后者是很多人第一次就掉进同一个坑。
|
||
*/
|
||
export const learnExerciseProgressSchema = z.object({
|
||
exerciseId: z.number().int(),
|
||
tutorialId: z.number().int(),
|
||
tutorialTitle: z.string(),
|
||
tutorialOrder: z.number().int(),
|
||
type: exerciseTypeSchema,
|
||
order: z.number().int(),
|
||
question: z.string(),
|
||
triedUsers: z.number().int(),
|
||
solvedUsers: z.number().int(),
|
||
firstTryUsers: z.number().int(),
|
||
attempts: z.number().int(),
|
||
avgAttemptsToSolve: z.number(),
|
||
})
|
||
|
||
export const learnExerciseProgressListSchema = z.object({
|
||
studentCount: z.number().int(),
|
||
results: z.array(learnExerciseProgressSchema),
|
||
})
|
||
|
||
/** 单道练习的逐人明细,后台表格展开时才拉 */
|
||
export const learnExerciseAttemptSchema = z.object({
|
||
userId: z.number().int(),
|
||
username: z.string(),
|
||
realName: z.string().nullable(),
|
||
className: z.string().nullable(),
|
||
attempts: z.number().int(),
|
||
wrongAttempts: z.number().int(),
|
||
solved: z.boolean(),
|
||
attemptsToSolve: z.number().int().nullable(),
|
||
lastWrongAnswer: z.string().nullable(),
|
||
lastAttemptAt: z.string(),
|
||
})
|
||
|
||
/** 「自学情况」按课的一行 */
|
||
export const learnTutorialProgressSchema = z.object({
|
||
tutorialId: z.number().int(),
|
||
title: z.string(),
|
||
order: z.number().int(),
|
||
readers: z.number().int(),
|
||
totalSeconds: z.number().int(),
|
||
avgSeconds: z.number().int(),
|
||
})
|
||
|
||
export const learnTutorialProgressListSchema = z.object({
|
||
// 分母:统计范围内的学生数(受班级筛选影响)
|
||
studentCount: z.number().int(),
|
||
results: z.array(learnTutorialProgressSchema),
|
||
})
|
||
|
||
export const generateFlowchartRequestSchema = z.object({ python: z.string().min(1).max(64 * 1024) })
|
||
export const generateFlowchartResponseSchema = z.object({ flowchart: z.string() })
|
||
|
||
// ---------------------------------------------------------------- 题目管理
|
||
|
||
export const adminProblemListItemSchema = z.object({
|
||
id: z.number().int(),
|
||
_id: z.string(),
|
||
title: z.string(),
|
||
createdBy: sampleUserSchema,
|
||
visible: z.boolean(),
|
||
createTime: z.string(),
|
||
difficulty: problemDifficultySchema,
|
||
tags: z.array(z.string()),
|
||
hasAstRules: z.boolean(),
|
||
allowFlowchart: z.boolean(),
|
||
showFlowchart: z.boolean(),
|
||
// 最高票评价 {type, count}。**只有公开题列表下发**,比赛题列表恒传 null ——
|
||
// 与旧后端 reaction/services.py:get_top_reactions 的口径一致。
|
||
topReaction: z
|
||
.object({ type: reactionKeySchema, count: z.number().int() })
|
||
.nullable(),
|
||
})
|
||
|
||
export const adminProblemListSchema = paginatedSchema(adminProblemListItemSchema)
|
||
|
||
/**
|
||
* 题面样例 / 标准答案 / 测试点分值。这三个旧后端都是逐字段校验的
|
||
* (CreateSampleSerializer / CreateAnswerSerializer / CreateTestCaseScoreSerializer),
|
||
* 新后端一路写成 `z.record(z.unknown())` **反而比旧的松**。
|
||
*
|
||
* 生产库 956 道题逐条比对过:samples 947 条是 {input,output}(另外 9 条是空数组,
|
||
* SQL 题没样例)、answers 268 条是 {language,code}(633 条 null、55 条空数组)。
|
||
*/
|
||
export const problemSampleSchema = z.object({
|
||
input: z.string(),
|
||
output: z.string(),
|
||
})
|
||
|
||
export const problemAnswerSchema = z.object({
|
||
language: z.string(),
|
||
code: z.string(),
|
||
})
|
||
|
||
/**
|
||
* 测试点分值。**只有这三个键** —— 上传接口的响应比这多三个
|
||
* (stripped_output_md5 / input_size / output_size),旧后端的 DRF serializer
|
||
* 收下时把多的丢掉,生产库 956 行全是三键。zod 的 object 默认也剥未知键,
|
||
* 行为一致。
|
||
*
|
||
* score 前端算出来是字符串(`(100 / n).toFixed(0)`),旧后端 IntegerField 转成
|
||
* 整数落库,7990 条全是 int —— 这里用 coerce 保持同一个落库形状。
|
||
*/
|
||
export const problemTestCaseScoreSchema = z.object({
|
||
input_name: z.string().max(32),
|
||
output_name: z.string().max(32),
|
||
score: z.coerce.number().int().min(0),
|
||
})
|
||
|
||
|
||
/** 后台题目详情:包含 oj 侧永不下发的 answers / testCase* / astRules */
|
||
export const adminProblemSchema = z.object({
|
||
id: z.number().int(),
|
||
_id: z.string(),
|
||
title: z.string(),
|
||
description: z.string(),
|
||
inputDescription: z.string(),
|
||
outputDescription: z.string(),
|
||
samples: z.array(problemSampleSchema),
|
||
testCaseId: z.string(),
|
||
testCaseScore: z.array(problemTestCaseScoreSchema),
|
||
hint: z.string().nullable(),
|
||
languages: z.array(z.string()),
|
||
template: z.record(z.string(), z.string()),
|
||
createTime: z.string(),
|
||
lastUpdateTime: z.string(),
|
||
timeLimit: z.number().int(),
|
||
memoryLimit: z.number().int(),
|
||
visible: z.boolean(),
|
||
difficulty: problemDifficultySchema,
|
||
source: z.string().nullable(),
|
||
submissionNumber: z.number().int(),
|
||
acceptedNumber: z.number().int(),
|
||
statisticInfo: z.record(z.string(), z.unknown()),
|
||
shareSubmission: z.boolean(),
|
||
contestId: z.number().int().nullable(),
|
||
createdBy: sampleUserSchema,
|
||
isPublic: z.boolean(),
|
||
tags: z.array(z.string()),
|
||
allowFlowchart: z.boolean(),
|
||
showFlowchart: z.boolean(),
|
||
mermaidCode: z.string().nullable(),
|
||
flowchartHint: z.string().nullable(),
|
||
astRules: astRulesSchema.nullable(),
|
||
answers: z.array(problemAnswerSchema),
|
||
prompt: z.string().nullable(),
|
||
sqlConfig: sqlConfigSchema.nullable(),
|
||
sqlDisplay: sqlDisplaySchema.nullable(),
|
||
})
|
||
|
||
export const createProblemRequestSchema = z.object({
|
||
_id: z.string().trim().min(1).max(32),
|
||
title: z.string().trim().min(1).max(1024),
|
||
description: z.string(),
|
||
inputDescription: z.string(),
|
||
outputDescription: z.string(),
|
||
samples: z.array(problemSampleSchema),
|
||
testCaseId: z.string().regex(/^[a-zA-Z0-9]+$/).max(32),
|
||
testCaseScore: z.array(problemTestCaseScoreSchema),
|
||
timeLimit: z.number().int().min(1).max(1000 * 60),
|
||
memoryLimit: z.number().int().min(1).max(1024),
|
||
languages: z.array(z.string()).min(1),
|
||
template: z.record(z.string(), z.string()),
|
||
visible: z.boolean(),
|
||
difficulty: z.enum(["Low", "Mid", "High"]),
|
||
tags: z.array(z.string().max(32)).min(1),
|
||
hint: z.string().nullable().default(null),
|
||
source: z.string().max(256).nullable().default(null),
|
||
prompt: z.string().nullable().default(null),
|
||
answers: z.array(problemAnswerSchema).default([]),
|
||
shareSubmission: z.boolean(),
|
||
allowFlowchart: z.boolean().default(false),
|
||
showFlowchart: z.boolean().default(false),
|
||
mermaidCode: z.string().nullable().default(null),
|
||
flowchartHint: z.string().nullable().default(null),
|
||
astRules: astRulesSchema.nullable().default(null),
|
||
// order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的
|
||
// `BooleanField(default=False)` 一致
|
||
sqlConfig: sqlConfigSchema
|
||
.extend({ order_sensitive: z.boolean().default(false) })
|
||
.nullable()
|
||
.default(null),
|
||
})
|
||
|
||
export const updateProblemRequestSchema = createProblemRequestSchema
|
||
|
||
export const makeProblemPublicRequestSchema = z.object({
|
||
displayId: z.string().trim().min(1).max(32),
|
||
})
|
||
|
||
export const addContestProblemRequestSchema = z.object({
|
||
problemId: z.number().int().positive(),
|
||
displayId: z.string().trim().min(1).max(32),
|
||
})
|
||
|
||
export const testCaseEntrySchema = z.object({
|
||
stripped_output_md5: z.string(),
|
||
input_size: z.number().int(),
|
||
output_size: z.number().int(),
|
||
input_name: z.string(),
|
||
output_name: z.string(),
|
||
})
|
||
|
||
/**
|
||
* 测试点上传的返回。字段名保持 snake_case —— 它会被原样存进 problem.test_case_score
|
||
* 和落盘的 info 文件,而判题沙箱读的就是这套键名,改成 camelCase 会判不了题。
|
||
*/
|
||
export const uploadTestCaseResponseSchema = z.object({
|
||
id: z.string(),
|
||
info: z.array(testCaseEntrySchema),
|
||
})
|
||
|
||
export const sqlTestCaseScriptSchema = z.object({
|
||
name: z.string(),
|
||
content: z.string(),
|
||
})
|
||
|
||
export const sqlPreviewRequestSchema = z.object({
|
||
initSql: z.string().min(1).max(1024 * 1024),
|
||
refSql: z.string().min(1).max(1024 * 1024),
|
||
mode: z.enum(["query", "modify"]),
|
||
})
|
||
|
||
export const generateSqlTestCaseRequestSchema = z.object({
|
||
refSql: z.string().min(1).max(64 * 1024),
|
||
mode: z.enum(["query", "modify"]),
|
||
})
|
||
|
||
export const generateSqlTestCaseResponseSchema = z.object({ sql: z.string() })
|
||
|
||
export type AdminAchievement = z.infer<typeof adminAchievementSchema>
|
||
export type AchievementMetric = z.infer<typeof achievementMetricSchema>
|
||
|
||
export type AdminProblem = z.infer<typeof adminProblemSchema>
|
||
export type AdminProblemListItem = z.infer<typeof adminProblemListItemSchema>
|
||
export type AdminProblemList = z.infer<typeof adminProblemListSchema>
|
||
export type CreateProblemRequest = z.infer<typeof createProblemRequestSchema>
|
||
|
||
export type AdminContest = z.infer<typeof adminContestSchema>
|
||
export type AdminContestList = z.infer<typeof adminContestListSchema>
|
||
export type AcmHelperItem = z.infer<typeof acmHelperItemSchema>
|
||
export type AdminUser = z.infer<typeof adminUserSchema>
|
||
export type AdminUserList = z.infer<typeof adminUserListSchema>
|
||
export type JudgeServer = z.infer<typeof judgeServerSchema>
|
||
export type DashboardInfo = z.infer<typeof dashboardInfoSchema>
|
||
export type JudgeServerList = z.infer<typeof judgeServerListSchema>
|
||
export type OrphanTestCase = z.infer<typeof orphanTestCaseSchema>
|
||
export type AdminUserRank = z.infer<typeof adminUserRankSchema>
|
||
|
||
export type AdminAiReportListItem = z.infer<typeof adminAiReportListItemSchema>
|
||
export type AdminAiReport = z.infer<typeof adminAiReportSchema>
|
||
export type AdminAiReportList = z.infer<typeof adminAiReportListSchema>
|
||
export type StuckProblem = z.infer<typeof stuckProblemSchema>
|
||
export type LearnStudentProgress = z.infer<typeof learnStudentProgressSchema>
|
||
export type LearnStudentProgressList = z.infer<typeof learnStudentProgressListSchema>
|
||
export type LearnTutorialProgress = z.infer<typeof learnTutorialProgressSchema>
|
||
export type LearnTutorialProgressList = z.infer<typeof learnTutorialProgressListSchema>
|
||
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
|
||
export type LearnExerciseProgressList = z.infer<typeof learnExerciseProgressListSchema>
|
||
export type LearnExerciseAttempt = z.infer<typeof learnExerciseAttemptSchema>
|
||
export type AcTrend = z.infer<typeof acTrendSchema>
|
||
|
||
export type AdminTag = z.infer<typeof adminTagSchema>
|
||
export type RenameTagResponse = z.infer<typeof renameTagResponseSchema>
|
||
export type BatchProblemTagResponse = z.infer<typeof batchProblemTagResponseSchema>
|
||
export type SqlTestCaseScript = z.infer<typeof sqlTestCaseScriptSchema>
|
||
export type GenerateSqlTestCaseResponse = z.infer<typeof generateSqlTestCaseResponseSchema>
|
||
export type AdminProblemSetProgress = z.infer<typeof adminProblemSetProgressSchema>
|
||
|
||
export type AdminAnnouncementList = z.infer<typeof adminAnnouncementListSchema>
|
||
export type CreateAnnouncementRequest = z.infer<typeof createAnnouncementRequestSchema>
|
||
export type UpdateAnnouncementRequest = z.infer<typeof updateAnnouncementRequestSchema>
|
||
export type TutorialType = z.infer<typeof tutorialTypeSchema>
|
||
export type AdminTutorial = z.infer<typeof adminTutorialSchema>
|
||
export type AdminTutorialListItem = z.infer<typeof adminTutorialListItemSchema>
|
||
export type AdminTutorialGroups = z.infer<typeof adminTutorialGroupsSchema>
|
||
export type CreateTutorialRequest = z.infer<typeof createTutorialRequestSchema>
|
||
export type UpdateTutorialRequest = z.infer<typeof updateTutorialRequestSchema>
|
||
export type SetTutorialVisibilityRequest = z.infer<typeof setTutorialVisibilityRequestSchema>
|
||
export type ExerciseType = z.infer<typeof exerciseTypeSchema>
|
||
export type AdminExercise = z.infer<typeof adminExerciseSchema>
|
||
export type CreateExerciseRequest = z.infer<typeof createExerciseRequestSchema>
|
||
export type UpdateExerciseRequest = z.infer<typeof updateExerciseRequestSchema>
|
||
export type ToggleAiReportPinResponse = z.infer<typeof toggleAiReportPinResponseSchema>
|
||
export type AchievementOperator = z.infer<typeof achievementOperatorSchema>
|
||
export type CreateAchievementRequest = z.infer<typeof createAchievementRequestSchema>
|
||
export type UpdateAchievementRequest = z.infer<typeof updateAchievementRequestSchema>
|
||
export type UpdateUserRequest = z.infer<typeof updateUserRequestSchema>
|
||
export type ImportUsersRequest = z.infer<typeof importUsersRequestSchema>
|
||
export type DeleteUsersRequest = z.infer<typeof deleteUsersRequestSchema>
|
||
export type ResetPasswordResponse = z.infer<typeof resetPasswordResponseSchema>
|
||
export type UpdateWebsiteConfigRequest = z.infer<typeof updateWebsiteConfigRequestSchema>
|
||
export type UpdateJudgeServerRequest = z.infer<typeof updateJudgeServerRequestSchema>
|
||
export type UploadImageResponse = z.infer<typeof uploadImageResponseSchema>
|
||
export type CreateContestRequest = z.infer<typeof createContestRequestSchema>
|
||
export type UpdateContestRequest = z.infer<typeof updateContestRequestSchema>
|
||
export type UpdateAcmHelperRequest = z.infer<typeof updateAcmHelperRequestSchema>
|
||
export type ProblemSetDifficulty = z.infer<typeof problemSetDifficultySchema>
|
||
export type ProblemSetStatus = z.infer<typeof problemSetStatusSchema>
|
||
export type BadgeConditionType = z.infer<typeof badgeConditionTypeSchema>
|
||
export type AdminProblemSet = z.infer<typeof adminProblemSetSchema>
|
||
export type AdminProblemSetList = z.infer<typeof adminProblemSetListSchema>
|
||
export type CreateProblemSetRequest = z.infer<typeof createProblemSetRequestSchema>
|
||
export type UpdateProblemSetRequest = z.infer<typeof updateProblemSetRequestSchema>
|
||
export type UpdateProblemSetStatusRequest = z.infer<typeof updateProblemSetStatusRequestSchema>
|
||
export type AdminProblemSetProblem = z.infer<typeof adminProblemSetProblemSchema>
|
||
export type AddProblemToSetRequest = z.infer<typeof addProblemToSetRequestSchema>
|
||
export type UpdateProblemInSetRequest = z.infer<typeof updateProblemInSetRequestSchema>
|
||
export type AdminProblemSetBadge = z.infer<typeof adminProblemSetBadgeSchema>
|
||
export type CreateProblemSetBadgeRequest = z.infer<typeof createProblemSetBadgeRequestSchema>
|
||
export type UpdateProblemSetBadgeRequest = z.infer<typeof updateProblemSetBadgeRequestSchema>
|
||
export type RenameTagRequest = z.infer<typeof renameTagRequestSchema>
|
||
export type BatchProblemTagRequest = z.infer<typeof batchProblemTagRequestSchema>
|
||
export type AcTrendYear = z.infer<typeof acTrendYearSchema>
|
||
export type GenerateFlowchartRequest = z.infer<typeof generateFlowchartRequestSchema>
|
||
export type GenerateFlowchartResponse = z.infer<typeof generateFlowchartResponseSchema>
|
||
export type UpdateProblemRequest = z.infer<typeof updateProblemRequestSchema>
|
||
export type MakeProblemPublicRequest = z.infer<typeof makeProblemPublicRequestSchema>
|
||
export type AddContestProblemRequest = z.infer<typeof addContestProblemRequestSchema>
|
||
export type TestCaseEntry = z.infer<typeof testCaseEntrySchema>
|
||
export type UploadTestCaseResponse = z.infer<typeof uploadTestCaseResponseSchema>
|
||
export type SqlPreviewRequest = z.infer<typeof sqlPreviewRequestSchema>
|
||
export type GenerateSqlTestCaseRequest = z.infer<typeof generateSqlTestCaseRequestSchema>
|