feat(自学): 教程和练一练都留痕,老师能看到谁学了多少
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
This commit is contained in:
2026-09-01 08:54:27 -06:00
parent 49681d04a6
commit bd84599174
30 changed files with 9484 additions and 36 deletions

View File

@@ -512,6 +512,90 @@ export const acTrendSchema = z.object({
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() })
@@ -715,6 +799,13 @@ 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>