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

@@ -16,7 +16,7 @@
// problem、contest、submission都是 int4。现存最大 id 一万出头,确实都用不上 bigint
// 但 2026-08-26 评估后决定**不改**:省 4 字节/行毫无意义ALTER TYPE 要重写整表并拿
// ACCESS EXCLUSIVE 锁,而且其中 6 处 id 被外键绑着得连坐。别再提这件事了。
import { pgTable, index, foreignKey, bigint, text, jsonb, timestamp, integer, boolean, serial, doublePrecision, varchar, unique, uniqueIndex } from "drizzle-orm/pg-core"
import { pgTable, index, foreignKey, primaryKey, bigint, text, jsonb, timestamp, integer, boolean, serial, doublePrecision, varchar, unique, uniqueIndex } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
export const aiAnalysis = pgTable("ai_analysis", {
@@ -683,3 +683,82 @@ export const problemsetBadge = pgTable("problemset_badge", {
name: "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id"
}),
]);
/**
* 自学模块的留痕:一个学生 × 一课一行。
*
* OJ2 自己建的表,不是 Django 遗留,所以没有代理主键 —— 写入路径只有一条 upsert
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
*
* **只记「读到哪一课 + 停留多久」,不记练一练的作答。** 练习的对错全在浏览器里判
* (见 apps/web/src/oj/learn/components/),学生可以随便重试到对为止,上报上来也只是
* 「他按了几次按钮」,不构成教学证据,还要为此多养一张按人按题膨胀的表。
*
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
*/
export const tutorialProgress = pgTable("tutorial_progress", {
userId: integer("user_id").notNull(),
tutorialId: integer("tutorial_id").notNull(),
// 打开次数。只有「进入这一课」才 +1后续补时长的心跳不动它
viewCount: integer("view_count").default(0).notNull(),
// 累计停留秒数。前端只在页面可见、且人没挂机时计时,见 useLearnTrace.ts
totalSeconds: integer("total_seconds").default(0).notNull(),
firstViewedAt: timestamp("first_viewed_at", { withTimezone: true, mode: 'string' }).notNull(),
lastViewedAt: timestamp("last_viewed_at", { withTimezone: true, mode: 'string' }).notNull(),
}, (table) => [
primaryKey({ columns: [table.userId, table.tutorialId], name: "tutorial_progress_pkey" }),
// 按课汇总(「这一课全班多少人读过」)要扫这一列,主键的前缀索引帮不上忙
index("tutorial_progress_tutorial_id_idx").on(table.tutorialId),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "tutorial_progress_user_id_fk_user_id"
}).onDelete("cascade"),
foreignKey({
columns: [table.tutorialId],
foreignColumns: [tutorial.id],
name: "tutorial_progress_tutorial_id_fk_tutorial_id"
}).onDelete("cascade"),
]);
/**
* 练一练的留痕:一个学生 × 一道练习一行。
*
* 存的是**聚合**,不是流水:试了几次、错了几次、做没做对、第几次做对的、
* 最后一次做错时填的什么。一道题一个学生一行,全校封顶就是「学生数 × 练习数」,
* 而流水会随着学生反复点「提交」无限长 —— 而且多存的那些行回答不了任何新问题:
* 「他第 3 次和第 5 次都选了 B」对老师没有意义「他试了 7 次才对」有。
*
* `lastWrongAnswer` 存的是**前端拼好的一句人话**(「选了 A、C」「第 2 空填了 xy」
* 不是原始作答结构:七种题型的作答形状各不相同,存结构就得在后台按题型各写一套
* 渲染,而老师要看的只是「他错在哪」。前端本来就知道怎么把自己的作答说成人话。
*/
export const exerciseAttempt = pgTable("exercise_attempt", {
userId: integer("user_id").notNull(),
exerciseId: integer("exercise_id").notNull(),
// 提交次数。同一份答案连点两次只算一次,见 ExerciseWidget.vue 的去重
attempts: integer().default(0).notNull(),
wrongAttempts: integer("wrong_attempts").default(0).notNull(),
solved: boolean().default(false).notNull(),
// 第一次做对时累计试了几次。做对之后就不再变 —— 后面再点提交不该把它改大
attemptsToSolve: integer("attempts_to_solve"),
lastWrongAnswer: text("last_wrong_answer"),
firstAttemptAt: timestamp("first_attempt_at", { withTimezone: true, mode: 'string' }).notNull(),
lastAttemptAt: timestamp("last_attempt_at", { withTimezone: true, mode: 'string' }).notNull(),
solvedAt: timestamp("solved_at", { withTimezone: true, mode: 'string' }),
}, (table) => [
primaryKey({ columns: [table.userId, table.exerciseId], name: "exercise_attempt_pkey" }),
// 按题汇总(「这道题全班多少人做对」)要扫这一列
index("exercise_attempt_exercise_id_idx").on(table.exerciseId),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "exercise_attempt_user_id_fk_user_id"
}).onDelete("cascade"),
foreignKey({
columns: [table.exerciseId],
foreignColumns: [exercise.id],
name: "exercise_attempt_exercise_id_fk_exercise_id"
}).onDelete("cascade"),
]);