- 0031 迁移:tutorial 加 project 列(从正文「所属项目:」回填),新表 tutorial_problem 挂每课的相关题(两头 CASCADE) - 学完一课 = 练一练全对(契约 isLessonDone),不再按停留满 3 分钟算 - /tutorials/:id 带相关题和做对没有,/tutorials/:id/exercises 带每道做对没有 - /trial-runs 收 tutorialId:教程里的代码块走同一台判题机跑一组 - 后台:教程列表带练一练 / 相关题数 / 打开过的人,拖动排序、项目改名、挑题候选(必会题在前) - 自学情况换成学生 × 课的方块格(/learn-analytics/board),默认班和排名页同一套猜法 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
8.3 KiB
TypeScript
233 lines
8.3 KiB
TypeScript
import { z } from "zod"
|
||
|
||
import { paginatedSchema, sampleUserSchema } from "./common"
|
||
import { embeddedSubmissionSchema } from "./submission"
|
||
|
||
export const announcementSchema = z.object({
|
||
id: z.number().int(),
|
||
title: z.string(),
|
||
tag: z.string(),
|
||
content: z.string(),
|
||
top: z.boolean(),
|
||
createdBy: sampleUserSchema,
|
||
createTime: z.string(),
|
||
lastUpdateTime: z.string(),
|
||
})
|
||
|
||
/**
|
||
* 列表不下发正文:公告是 8MB 上限的富文本,列表页只显示标题。
|
||
* 原来 content 写成 `.optional()` 让一个 schema 兼两种形态,结果详情页
|
||
* 拿到的 content 类型上也是 `string | undefined`,组件只能 ?? 兜底。
|
||
* 与后台侧 adminAnnouncementListItemSchema 同一个套路。
|
||
*/
|
||
export const announcementListItemSchema = announcementSchema.omit({
|
||
content: true,
|
||
})
|
||
|
||
export const announcementListSchema = paginatedSchema(announcementListItemSchema)
|
||
|
||
export const messageSchema = z.object({
|
||
id: z.number().int(),
|
||
sender: sampleUserSchema,
|
||
createTime: z.string(),
|
||
message: z.string(),
|
||
submission: embeddedSubmissionSchema,
|
||
})
|
||
|
||
export const messageListSchema = paginatedSchema(messageSchema)
|
||
|
||
export const createMessageRequestSchema = z.object({
|
||
recipientId: z.number().int().positive(),
|
||
submissionId: z.string().min(1),
|
||
message: z
|
||
.string()
|
||
.min(1)
|
||
.max(1024 * 1024),
|
||
})
|
||
|
||
export const reactionKeySchema = z.enum([
|
||
"too_easy",
|
||
"too_hard",
|
||
"confusing",
|
||
"buggy",
|
||
"learned",
|
||
"interesting",
|
||
"want_explain",
|
||
])
|
||
|
||
export const reactionCountsSchema = z.record(reactionKeySchema, z.number().int())
|
||
export const reactionStateSchema = z.object({
|
||
mine: reactionKeySchema.nullable(),
|
||
counts: reactionCountsSchema.nullable(),
|
||
})
|
||
|
||
export const setReactionRequestSchema = z.object({ type: reactionKeySchema })
|
||
|
||
/**
|
||
* 目录里的一课。`title` 是「情境——知识点」(「蜜橘园环境预警——条件语句基础」),
|
||
* 前端拆成知识点在前、情境在后显示;`project` 是所属项目,目录按它分组,可能为空。
|
||
*/
|
||
export const tutorialSummarySchema = z.object({
|
||
id: z.number().int(),
|
||
title: z.string(),
|
||
project: z.string(),
|
||
})
|
||
|
||
/**
|
||
* 一课结尾「去做几道题」里的一道。`solved` 是当前学生做对过没有(未登录恒为 false),
|
||
* `mustDo` 是带不带「必会题」标签。
|
||
*/
|
||
export const tutorialProblemSchema = z.object({
|
||
id: z.number().int(),
|
||
displayId: z.string(),
|
||
title: z.string(),
|
||
mustDo: z.boolean(),
|
||
solved: z.boolean(),
|
||
})
|
||
|
||
export const tutorialSchema = tutorialSummarySchema.extend({
|
||
content: z.string(),
|
||
problems: z.array(tutorialProblemSchema),
|
||
isPublic: z.boolean(),
|
||
order: z.number().int(),
|
||
type: z.enum(["python", "c"]),
|
||
createdBy: sampleUserSchema,
|
||
createdAt: z.string(),
|
||
updatedAt: z.string(),
|
||
})
|
||
|
||
/**
|
||
* 自学留痕的上报。前端每进一课发一次 `opened: true`(`seconds` 为 0),
|
||
* 之后按心跳补时长发 `opened: false`。
|
||
*
|
||
* `seconds` 卡在 1 小时以内:一次上报最多也就攒几分钟,超出这个量级只可能是
|
||
* 客户端算错或有人手造请求,直接拒掉比默默入库好——停留时长是要给老师看的。
|
||
*/
|
||
export const tutorialProgressPingSchema = z.object({
|
||
seconds: z.number().int().min(0).max(3600),
|
||
opened: z.boolean(),
|
||
})
|
||
|
||
/**
|
||
* 学习页目录要的整套进度:**每篇公开教程都有一行**,没读过的就是一行零。
|
||
* 让前端 `progress[id]` 永远取得到,省得目录里每处都判一次 undefined。
|
||
*/
|
||
export const tutorialProgressSchema = z.object({
|
||
tutorialId: z.number().int(),
|
||
viewCount: z.number().int(),
|
||
totalSeconds: z.number().int(),
|
||
// 没读过时是 null,不是假的零时间
|
||
firstViewedAt: z.string().nullable(),
|
||
lastViewedAt: z.string().nullable(),
|
||
exerciseTotal: z.number().int(),
|
||
exerciseSolved: z.number().int(),
|
||
})
|
||
|
||
/**
|
||
* 一次练一练的作答。
|
||
*
|
||
* `answer` 是前端拼好的一句人话(「选了 A、C」),只在做错时留下来给老师看;
|
||
* 做对了没什么好看的。长度卡在 200 字符:它是给人扫一眼的摘要,不是完整作答,
|
||
* 填空题写一整段进来只会把后台表格撑爆。
|
||
*/
|
||
export const exerciseAttemptRequestSchema = z.object({
|
||
correct: z.boolean(),
|
||
answer: z.string().max(200).optional(),
|
||
})
|
||
|
||
/**
|
||
* 题型 → 内容形状。**只在写入侧校验**(`apps/api/src/services/exercise.ts` 的
|
||
* `exerciseDataError`),读出侧不校验。
|
||
*
|
||
* 这张表曾经挂在 `exerciseSchema` 的 superRefine 上,于是学生端那条
|
||
* `GET /tutorials/:id/exercises`(`routes/content.ts` 里硬 parse)变成了一道
|
||
* 读闸:一行 data 对不上,整条练习列表 500 —— 坏的不是那一道,是整页。而写入侧
|
||
* 当时并不检查 `question`,两边严紧度不一致,脏数据进得来、出不去。
|
||
*
|
||
* JSONB 的形状真相在写入侧,就在写入侧卡住:那里能给老师一句中文原因,
|
||
* 也不会让历史数据把学生端打不开。
|
||
*/
|
||
export const exerciseDataByType: Record<string, z.ZodType> = {
|
||
mcq: z.object({
|
||
question: z.string(),
|
||
options: z.array(z.string()),
|
||
answer: z.array(z.number()),
|
||
}),
|
||
sort: z.object({ question: z.string(), lines: z.array(z.string()) }),
|
||
fill: z.object({ question: z.string(), code: z.string() }),
|
||
match: z.object({
|
||
question: z.string(),
|
||
left: z.array(z.string()),
|
||
right: z.array(z.string()),
|
||
answer: z.array(z.number()),
|
||
}),
|
||
predict: z.object({
|
||
question: z.string(),
|
||
code: z.string(),
|
||
answer: z.array(z.string()),
|
||
}),
|
||
debug: z.object({
|
||
question: z.string(),
|
||
lines: z.array(z.string()),
|
||
answer: z.array(z.number()),
|
||
explanation: z.string().optional(),
|
||
}),
|
||
group: z.object({
|
||
question: z.string(),
|
||
buckets: z.array(z.string()),
|
||
items: z.array(z.string()),
|
||
answer: z.array(z.number()),
|
||
}),
|
||
}
|
||
|
||
/**
|
||
* 练一练。`data` 是 `Record<string, unknown>`,**读出侧刻意不按题型收紧** ——
|
||
* 这个 schema 后端也在 parse(`routes/content.ts`),收紧它等于给学生端加一道
|
||
* 会 500 的闸。七种题型各自的形状见上面的 `exerciseDataByType`,在写入时卡。
|
||
*/
|
||
export const exerciseSchema = z.object({
|
||
id: z.number().int(),
|
||
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
|
||
data: z.record(z.string(), z.unknown()),
|
||
order: z.number().int(),
|
||
/** 当前学生做对过没有。未登录恒为 false;一课的练一练全做对就算学完这课 */
|
||
solved: z.boolean(),
|
||
})
|
||
|
||
export type Message = z.infer<typeof messageSchema>
|
||
export type MessageList = z.infer<typeof messageListSchema>
|
||
export type Announcement = z.infer<typeof announcementSchema>
|
||
export type AnnouncementListItem = z.infer<typeof announcementListItemSchema>
|
||
export type TutorialSummary = z.infer<typeof tutorialSummarySchema>
|
||
|
||
export type AnnouncementList = z.infer<typeof announcementListSchema>
|
||
export type CreateMessageRequest = z.infer<typeof createMessageRequestSchema>
|
||
export type ReactionKey = z.infer<typeof reactionKeySchema>
|
||
export type ReactionCounts = z.infer<typeof reactionCountsSchema>
|
||
export type ReactionState = z.infer<typeof reactionStateSchema>
|
||
export type SetReactionRequest = z.infer<typeof setReactionRequestSchema>
|
||
export type Tutorial = z.infer<typeof tutorialSchema>
|
||
export type TutorialProblem = z.infer<typeof tutorialProblemSchema>
|
||
export type Exercise = z.infer<typeof exerciseSchema>
|
||
export type TutorialProgress = z.infer<typeof tutorialProgressSchema>
|
||
export type TutorialProgressPing = z.infer<typeof tutorialProgressPingSchema>
|
||
export type ExerciseAttemptRequest = z.infer<typeof exerciseAttemptRequestSchema>
|
||
|
||
/**
|
||
* 「学完一课」的口径:**这课的练一练全做对**。没有练一练的课,打开过就算。
|
||
*
|
||
* 原来按「累计停留满 3 分钟」算已读,学生看不见这条规则,只看见圆点变黄;而快照里
|
||
* 停留时长中位数是 0 秒 —— 一节课上十几分钟点开 17 课的大有人在。练一练是学生
|
||
* 自己做的、看得见的,「全对了」比「待够了」说得清。停留时长照样记,只给老师看。
|
||
*
|
||
* 前端目录、首页、后台「自学情况」都走这一个函数,别各写一份。
|
||
*/
|
||
export function isLessonDone(progress: {
|
||
viewCount: number
|
||
exerciseTotal: number
|
||
exerciseSolved: number
|
||
}) {
|
||
if (progress.exerciseTotal > 0) return progress.exerciseSolved >= progress.exerciseTotal
|
||
return progress.viewCount > 0
|
||
}
|