feat(自学): 教程有了「所属项目」和课末的相关题,正文里的代码能直接运行

- 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>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 09:40:34 +08:00
1 parent 6d1bfc522d
commit 11194543a3
14 files changed
+4621 -268

No files matched your search

+34
View File
@@ -1042,6 +1042,9 @@ export const tutorial = pgTable(
createdById: integer("created_by_id").notNull(),
code: text(),
type: varchar({ length: 10 }).notNull().$type<TutorialType>(),
// 所属项目(「项目三 · 智慧农业·蜜橘园」),自学目录按它分组。0031 从正文第一段的
// 「**所属项目:…**」回填;发布脚本不传它,后端照样从正文里抽,见 admin/tutorial.ts
project: varchar({ length: 64 }).default("").notNull(),
},
(table) => [
index("tutorial_created_by_id_07973cab").using(
@@ -1056,6 +1059,37 @@ export const tutorial = pgTable(
],
)
/**
* 一课挂的「去做几道题」:老师在后台手挑的题目,按 `order` 排。
*
* 两头都 CASCADE:教程删了这串题就没意义了;题目删了,教程少挂一道题也不是学生的
* 做题证据(证据在 submission 那边,它对 problem 是拦住的)。
*/
export const tutorialProblem = pgTable(
"tutorial_problem",
{
tutorialId: integer("tutorial_id").notNull(),
problemId: integer("problem_id").notNull(),
order: integer().notNull(),
},
(table) => [
primaryKey({
columns: [table.tutorialId, table.problemId],
name: "tutorial_problem_pkey",
}),
foreignKey({
columns: [table.tutorialId],
foreignColumns: [tutorial.id],
name: "tutorial_problem_tutorial_id_fk_tutorial_id",
}).onDelete("cascade"),
foreignKey({
columns: [table.problemId],
foreignColumns: [problem.id],
name: "tutorial_problem_problem_id_fk_problem_id",
}).onDelete("cascade"),
],
)
export const userStat = pgTable(
"user_stat",
{