import { z } from "zod" import { paginatedSchema, sampleUserSchema } from "./common" 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 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 = { 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`,**读出侧刻意不按题型收紧** —— * 这个 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 Announcement = z.infer export type AnnouncementListItem = z.infer export type TutorialSummary = z.infer export type AnnouncementList = z.infer export type ReactionKey = z.infer export type ReactionCounts = z.infer export type ReactionState = z.infer export type SetReactionRequest = z.infer export type Tutorial = z.infer export type TutorialProblem = z.infer export type Exercise = z.infer export type TutorialProgress = z.infer export type TutorialProgressPing = z.infer export type ExerciseAttemptRequest = z.infer /** * 「学完一课」的口径:**这课的练一练全做对**。没有练一练的课,打开过就算。 * * 原来按「累计停留满 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 }