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

@@ -0,0 +1,13 @@
CREATE TABLE "tutorial_progress" (
"user_id" integer NOT NULL,
"tutorial_id" integer NOT NULL,
"view_count" integer DEFAULT 0 NOT NULL,
"total_seconds" integer DEFAULT 0 NOT NULL,
"first_viewed_at" timestamp with time zone NOT NULL,
"last_viewed_at" timestamp with time zone NOT NULL,
CONSTRAINT "tutorial_progress_pkey" PRIMARY KEY("user_id","tutorial_id")
);
--> statement-breakpoint
ALTER TABLE "tutorial_progress" ADD CONSTRAINT "tutorial_progress_user_id_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tutorial_progress" ADD CONSTRAINT "tutorial_progress_tutorial_id_fk_tutorial_id" FOREIGN KEY ("tutorial_id") REFERENCES "public"."tutorial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "tutorial_progress_tutorial_id_idx" ON "tutorial_progress" USING btree ("tutorial_id");

View File

@@ -0,0 +1,17 @@
CREATE TABLE "exercise_attempt" (
"user_id" integer NOT NULL,
"exercise_id" integer NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"wrong_attempts" integer DEFAULT 0 NOT NULL,
"solved" boolean DEFAULT false NOT NULL,
"attempts_to_solve" integer,
"last_wrong_answer" text,
"first_attempt_at" timestamp with time zone NOT NULL,
"last_attempt_at" timestamp with time zone NOT NULL,
"solved_at" timestamp with time zone,
CONSTRAINT "exercise_attempt_pkey" PRIMARY KEY("user_id","exercise_id")
);
--> statement-breakpoint
ALTER TABLE "exercise_attempt" ADD CONSTRAINT "exercise_attempt_user_id_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "exercise_attempt" ADD CONSTRAINT "exercise_attempt_exercise_id_fk_exercise_id" FOREIGN KEY ("exercise_id") REFERENCES "public"."exercise"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "exercise_attempt_exercise_id_idx" ON "exercise_attempt" USING btree ("exercise_id");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,20 @@
"when": 1787850608174,
"tag": "0003_submission_public_create_time_id_idx",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1788271411343,
"tag": "0004_add_tutorial_progress",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1788272667927,
"tag": "0005_add_exercise_attempt",
"breakpoints": true
}
]
}

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"),
]);

View File

@@ -6,6 +6,7 @@ import { adminAchievementRoutes } from "./achievement"
import { adminAiRoutes } from "./ai"
import { adminConfRoutes } from "./conf"
import { adminContestRoutes } from "./contest"
import { adminLearnRoutes } from "./learn"
import { adminProblemRoutes } from "./problem"
import { adminProblemSetRoutes } from "./problemset"
import { adminTagRoutes } from "./tag"
@@ -27,6 +28,7 @@ adminRoutes.route("/", adminAchievementRoutes)
adminRoutes.route("/", adminAiRoutes)
adminRoutes.route("/", adminConfRoutes)
adminRoutes.route("/", adminContestRoutes)
adminRoutes.route("/", adminLearnRoutes)
adminRoutes.route("/", adminProblemRoutes)
adminRoutes.route("/", adminProblemSetRoutes)
adminRoutes.route("/", adminTagRoutes)

View File

@@ -0,0 +1,249 @@
import {
learnExerciseAttemptSchema,
learnExerciseProgressListSchema,
learnExerciseProgressSchema,
learnStudentProgressListSchema,
learnStudentProgressSchema,
learnTutorialProgressListSchema,
learnTutorialProgressSchema,
} from "@oj2/contract"
import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { queryInteger, rounded } from "../helpers"
/**
* 自学情况:教程读了没、读了多久。
*
* 路径用 `/learn-analytics` 而不是挂在 `/tutorials` 下,理由同 tag.ts 里那段注释:
* Hono 按注册顺序匹配,`/tutorials/:id` 会把同级的静态段整个吃掉且不报错。
*/
export const adminLearnRoutes = new Hono<AppEnv>()
/** 统计只算学生,不算老师和管理员自己 —— 和班级榜classroom.ts的口径一致 */
const STUDENT_ROLES = ["Regular User", "Student Admin"]
function tutorialTypeOf(value: string | undefined) {
return value === "c" ? "c" : "python"
}
/**
* 班级筛选。班号形如 `241`24 级 1 班),只允许纯数字:
* 直接拼进 like 的话,`%` 会变成通配符,把筛选变成「全选」。
*/
function classFilter(className: string | undefined) {
const value = className?.trim()
if (!value) return { ok: true as const, value: null }
if (!/^\d{1,4}$/.test(value)) return { ok: false as const, value: null }
return { ok: true as const, value }
}
/**
* `className` 传 3 位以上是具体班级(精确匹配),传 1-2 位当年级前缀like
* 年级前缀这条是给「24 级整体读得怎么样」用的,不然老师得一个班一个班点。
*/
function classCondition(value: string | null) {
if (!value) return undefined
return value.length >= 3
? eq(schema.user.className, value)
: like(schema.user.className, `${value}%`)
}
/**
* 没有班级的学生**照样统计**(班级列显示为空)。班级是从用户名的数字前缀推出来的,
* 推不出来时就是 null见 admin/account.ts 的 classNameOf把这些人过滤掉等于让他们
* 在「谁没学」这张表上凭空消失 —— 班级榜可以只算入班的人,这里不行。
*/
function studentCondition(value: string | null) {
return and(
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, STUDENT_ROLES),
classCondition(value),
)
}
adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
// 该语言下已公开的教程,既是分母,也是「哪些课算数」的白名单 ——
// 未公开的课学生本来就打不开,混进来会让读完的人显示成没读完
const tutorials = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
const tutorialIds = tutorials.map((row) => row.id)
// 学生表打底 left join 进度:没读过的人也要出现在结果里,这是这张表的重点
const progressJoin = tutorialIds.length
? and(
eq(schema.tutorialProgress.userId, schema.user.id),
inArray(schema.tutorialProgress.tutorialId, tutorialIds),
)
: sql`false`
// 阅读和练习**分两条查**再在内存里拼。写成一条的话,一个学生读了 3 课、
// 做了 8 道练习join 出来是 24 行count 全是错的 —— 两个一对多挂在同一张表上
// 就是这个下场,用 filter 也救不回来
const [rows, exerciseRows] = await Promise.all([
db.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
readCount: count(schema.tutorialProgress.tutorialId),
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number),
lastViewedAt: sql<string | null>`max(${schema.tutorialProgress.lastViewedAt})`,
}).from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.leftJoin(schema.tutorialProgress, progressJoin)
.where(studentCondition(className.value))
.groupBy(schema.user.id, schema.user.username, schema.userProfile.realName, schema.user.className),
db.select({
userId: schema.exerciseAttempt.userId,
tried: count(),
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
attempts: sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}), 0)`.mapWith(Number),
}).from(schema.exerciseAttempt)
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId))
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(schema.exerciseAttempt.userId),
])
const attempts = new Map(exerciseRows.map((row) => [row.userId, row]))
const [exerciseCountRow] = tutorialIds.length
? await db.select({ value: count() }).from(schema.exercise)
.where(inArray(schema.exercise.tutorialId, tutorialIds))
: [{ value: 0 }]
return success(c, learnStudentProgressListSchema.parse({
tutorialCount: tutorialIds.length,
exerciseCount: exerciseCountRow?.value ?? 0,
results: rows.map((row) => learnStudentProgressSchema.parse({
...row,
exerciseTried: attempts.get(row.userId)?.tried ?? 0,
exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
})),
}))
})
adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const [studentCountRow] = await db.select({ value: count() }).from(schema.user)
.where(studentCondition(className.value))
const studentCount = studentCountRow?.value ?? 0
// 进度行 join 回 user 是为了让班级筛选生效,同时把老师自己试读的记录挡在外面
const rows = await db.select({
tutorialId: schema.tutorial.id,
title: schema.tutorial.title,
order: schema.tutorial.order,
// 数的是 user.id 而不是 progress.user_idjoin 不上的(老师自己试读的、
// 已禁用的、不在所选班级的)在这一列是 NULLcount(distinct) 正好不算它,
// 而 progress.user_id 那边永远非空,会把过滤当没发生
readers: sql<number>`count(distinct ${schema.user.id})`.mapWith(Number),
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
}).from(schema.tutorial)
.leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id))
.leftJoin(schema.user, and(
eq(schema.user.id, schema.tutorialProgress.userId),
studentCondition(className.value),
))
// 学生条件写在 join 的 on 上而不是 where 上:写 where 会把没人读过的课整行滤掉,
// 而「一节课一个人都没读」恰恰是老师最需要看见的一行
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order))
return success(c, learnTutorialProgressListSchema.parse({
studentCount,
results: rows.map((row) => learnTutorialProgressSchema.parse({
...row,
avgSeconds: row.readers ? Math.round(row.totalSeconds / row.readers) : 0,
})),
}))
})
/**
* 按练习:哪道练一练卡住了全班。
*
* 一道题一行,含做过/做对的人数、做对的人平均试了几次、一次就做对的人数。
* 没人做过的题也在列表里(一行零)—— 「这道题全班没一个人碰」同样是要看见的。
*/
adminLearnRoutes.get("/learn-analytics/exercises", requireTeacher, async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const [studentCountRow] = await db.select({ value: count() }).from(schema.user)
.where(studentCondition(className.value))
const rows = await db.select({
exerciseId: schema.exercise.id,
tutorialId: schema.tutorial.id,
tutorialTitle: schema.tutorial.title,
tutorialOrder: schema.tutorial.order,
type: schema.exercise.type,
order: schema.exercise.order,
// 题干在 jsonb 里,各题型的字段名都叫 question取不到就给空串别让整行挂掉
question: sql<string>`coalesce(${schema.exercise.data}->>'question', '')`,
triedUsers: sql<number>`count(distinct ${schema.user.id})`.mapWith(Number),
solvedUsers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
firstTryUsers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.attemptsToSolve} = 1)`.mapWith(Number),
attempts: sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
// 只算做对的人:没做对的人「试了几次」还没停,混进平均值只会把它拉花
avgAttemptsToSolve: sql<number>`coalesce(avg(${schema.exerciseAttempt.attemptsToSolve}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
}).from(schema.exercise)
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.leftJoin(schema.exerciseAttempt, eq(schema.exerciseAttempt.exerciseId, schema.exercise.id))
// 学生条件挂在 join 的 on 上,不是 where 上:写 where 会把没人做过的题整行滤掉
.leftJoin(schema.user, and(
eq(schema.user.id, schema.exerciseAttempt.userId),
studentCondition(className.value),
))
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(schema.exercise.id, schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
return success(c, learnExerciseProgressListSchema.parse({
studentCount: studentCountRow?.value ?? 0,
results: rows.map((row) => learnExerciseProgressSchema.parse({
...row,
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
})),
}))
})
/** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */
adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const rows = await db.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
attempts: schema.exerciseAttempt.attempts,
wrongAttempts: schema.exerciseAttempt.wrongAttempts,
solved: schema.exerciseAttempt.solved,
attemptsToSolve: schema.exerciseAttempt.attemptsToSolve,
lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer,
lastAttemptAt: schema.exerciseAttempt.lastAttemptAt,
}).from(schema.exerciseAttempt)
.innerJoin(schema.user, eq(schema.user.id, schema.exerciseAttempt.userId))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and(eq(schema.exerciseAttempt.exerciseId, id), studentCondition(className.value)))
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
.orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts))
return success(c, rows.map((row) => learnExerciseAttemptSchema.parse(row)))
})

View File

@@ -10,10 +10,13 @@ import {
reactionStateSchema,
setReactionRequestSchema,
embeddedSubmissionSchema,
exerciseAttemptRequestSchema,
tutorialProgressPingSchema,
tutorialProgressSchema,
tutorialSchema,
tutorialSummarySchema,
} from "@oj2/contract"
import { and, asc, count, desc, eq, inArray } from "drizzle-orm"
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
@@ -206,6 +209,162 @@ contentRoutes.get("/tutorials/:id", async (c) => {
}))
})
// ---------------------------------------------------------------- 自学留痕
/**
* 学生自己的自学进度,给学习页的目录打勾用。
*
* 路径特意不放在 `/tutorials` 下Hono 按**注册顺序**匹配(不是静态优先),
* `/tutorials/:id` 就在上面几行,`/tutorials/progress` 会被它整个吃掉,而且不报错
* ——`queryInteger("progress")` 回落成 0学生只会看到一个「教程不存在」。
*/
contentRoutes.get("/learn/progress", requireAuth, async (c) => {
const user = c.get("user")!
const type = c.req.query("type") === "c" ? "c" : "python"
const visible = and(eq(schema.tutorial.type, type), eq(schema.tutorial.isPublic, true))
// 从 tutorial 打底 left join 进度,而不是反过来:没读过的课也要有一行零,
// 否则目录里「练习 0/5」和「这课没有练习」在前端分不出来
const [rows, exerciseRows] = await Promise.all([
db.select({
tutorialId: schema.tutorial.id,
viewCount: schema.tutorialProgress.viewCount,
totalSeconds: schema.tutorialProgress.totalSeconds,
firstViewedAt: schema.tutorialProgress.firstViewedAt,
lastViewedAt: schema.tutorialProgress.lastViewedAt,
}).from(schema.tutorial)
.leftJoin(schema.tutorialProgress, and(
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
eq(schema.tutorialProgress.userId, user.id),
))
.where(visible)
.orderBy(asc(schema.tutorial.order)),
db.select({
tutorialId: schema.exercise.tutorialId,
total: count(),
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
}).from(schema.exercise)
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.leftJoin(schema.exerciseAttempt, and(
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
eq(schema.exerciseAttempt.userId, user.id),
))
.where(visible)
.groupBy(schema.exercise.tutorialId),
])
const exercises = new Map(exerciseRows.map((row) => [row.tutorialId, row]))
return success(c, rows.map((row) => tutorialProgressSchema.parse({
tutorialId: row.tutorialId,
viewCount: row.viewCount ?? 0,
totalSeconds: row.totalSeconds ?? 0,
firstViewedAt: row.firstViewedAt,
lastViewedAt: row.lastViewedAt,
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
})))
})
/**
* 上报一次自学留痕。`opened` 为真表示「刚进这一课」,计一次打开;
* 否则只是心跳补时长,见 apps/web/src/oj/learn/composables/useLearnTrace.ts。
*
* 未登录一律 401 而不是静默丢弃 —— 教程本身保持免登录可读,前端只在登录后才调它,
* 真收到匿名请求说明前端判断错了,得让它响。
*/
contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
const user = c.get("user")!
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = tutorialProgressPingSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid progress payload")
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const now = new Date().toISOString()
const { seconds, opened } = parsed.data
await db.insert(schema.tutorialProgress).values({
userId: user.id,
tutorialId: id,
viewCount: opened ? 1 : 0,
totalSeconds: seconds,
firstViewedAt: now,
lastViewedAt: now,
}).onConflictDoUpdate({
target: [schema.tutorialProgress.userId, schema.tutorialProgress.tutorialId],
set: {
// 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页
// 同时上报时,读改写会互相覆盖,时长凭空少掉一半
viewCount: sql`${schema.tutorialProgress.viewCount} + ${opened ? 1 : 0}`,
totalSeconds: sql`${schema.tutorialProgress.totalSeconds} + ${seconds}`,
lastViewedAt: now,
},
})
return success(c, null)
})
/**
* 上报一次练一练的作答。
*
* 对错是**前端判的** —— 练一练的答案本来就随题面一起下发给浏览器(见
* `/tutorials/:id/exercises`),后端再判一遍也挡不住任何人,只是重复实现七套判题。
* 所以这里存的是「学生自己说他做对了」,作为教学观察够用,**不能当考试成绩**。
*
* 做对之后的重复提交只更新时间,不再累加 —— 学生做对后再点几下提交,
* 不该把「他试了几次」这个数字变大。
*/
contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
const user = c.get("user")!
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = exerciseAttemptRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid attempt payload")
// 练习跟着教程走:教程没公开,它底下的练习也不该能上报
const [exercise] = await db.select({ id: schema.exercise.id }).from(schema.exercise)
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.where(and(eq(schema.exercise.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
if (!exercise) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
const now = new Date().toISOString()
const { correct } = parsed.data
const answer = correct ? null : (parsed.data.answer ?? null)
await db.insert(schema.exerciseAttempt).values({
userId: user.id,
exerciseId: id,
attempts: 1,
wrongAttempts: correct ? 0 : 1,
solved: correct,
attemptsToSolve: correct ? 1 : null,
lastWrongAnswer: answer,
firstAttemptAt: now,
lastAttemptAt: now,
solvedAt: correct ? now : null,
}).onConflictDoUpdate({
target: [schema.exerciseAttempt.userId, schema.exerciseAttempt.exerciseId],
set: {
// 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。
//
// 每一列都先看 `solved`:做对之后这一行就冻住了,只有 lastAttemptAt 还动。
// 不冻的话,学生做对后随手再点几下提交,「他试了几次才做对」就被改花了。
attempts: sql`${schema.exerciseAttempt.attempts} + case when ${schema.exerciseAttempt.solved} then 0 else 1 end`,
wrongAttempts: sql`${schema.exerciseAttempt.wrongAttempts} + case when ${schema.exerciseAttempt.solved} or ${correct} then 0 else 1 end`,
solved: sql`${schema.exerciseAttempt.solved} or ${correct}`,
attemptsToSolve: sql`case
when ${schema.exerciseAttempt.solved} then ${schema.exerciseAttempt.attemptsToSolve}
when ${correct} then ${schema.exerciseAttempt.attempts} + 1
else null end`,
solvedAt: sql`case
when ${schema.exerciseAttempt.solved} then ${schema.exerciseAttempt.solvedAt}
when ${correct} then ${now}::timestamptz
else null end`,
lastWrongAnswer: sql`case
when ${schema.exerciseAttempt.solved} or ${correct} then ${schema.exerciseAttempt.lastWrongAnswer}
else ${answer} end`,
lastAttemptAt: now,
},
})
return success(c, null)
})
contentRoutes.get("/tutorials/:id/exercises", async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)

View File

@@ -12,6 +12,10 @@ import type {
AdminAiReport,
AdminAiReportList,
StuckProblem,
LearnStudentProgressList,
LearnTutorialProgressList,
LearnExerciseProgressList,
LearnExerciseAttempt,
AdminContestList,
AdminUser,
AdminUserList,
@@ -664,6 +668,43 @@ export function getStuckProblems() {
return api.get<StuckProblem[]>("admin/problem-analytics/stuck")
}
export function getLearnStudents(params: {
type: "python" | "c"
className?: string
}) {
return api.get<LearnStudentProgressList>("admin/learn-analytics/students", {
params,
})
}
export function getLearnTutorials(params: {
type: "python" | "c"
className?: string
}) {
return api.get<LearnTutorialProgressList>("admin/learn-analytics/tutorials", {
params,
})
}
export function getLearnExercises(params: {
type: "python" | "c"
className?: string
}) {
return api.get<LearnExerciseProgressList>("admin/learn-analytics/exercises", {
params,
})
}
export function getLearnExerciseAttempts(
exerciseId: number,
params: { className?: string },
) {
return api.get<LearnExerciseAttempt[]>(
`admin/learn-analytics/exercises/${exerciseId}/attempts`,
{ params },
)
}
export function getTopACTrend(params: {
sinceYear: number
untilYear: number

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { NText } from "naive-ui"
import { getLearnExerciseAttempts } from "admin/api"
import { parseTime } from "utils/functions"
import type { LearnExerciseAttempt } from "utils/types"
const props = defineProps<{ exerciseId: number; className: string }>()
const loading = ref(true)
const rows = ref<LearnExerciseAttempt[]>([])
const columns: DataTableColumn<LearnExerciseAttempt>[] = [
{ title: "班级", key: "className", width: 80 },
{ title: "学号", key: "username", width: 140 },
{
title: "姓名",
key: "realName",
width: 100,
render: (row) => row.realName || "-",
},
{
title: "结果",
key: "solved",
width: 130,
render: (row) =>
row.solved
? h(
NText,
{ type: "success" },
() => `做对了(第 ${row.attemptsToSolve} 次)`,
)
: h(NText, { type: "error" }, () => "还没做对"),
},
{ title: "提交次数", key: "attempts", width: 100, sorter: "default" },
{
// 学生最后一次做错时提交的内容,前端拼好的一句人话。选择题看这一列
// 就知道全班是不是都掉进同一个干扰项
title: "最后一次错在",
key: "lastWrongAnswer",
minWidth: 180,
ellipsis: { tooltip: true },
render: (row) => row.lastWrongAnswer || "-",
},
{
title: "最后作答",
key: "lastAttemptAt",
width: 150,
render: (row) => parseTime(row.lastAttemptAt, "M月D日 HH:mm"),
},
]
onMounted(async () => {
try {
rows.value = await getLearnExerciseAttempts(props.exerciseId, {
className: props.className,
})
} finally {
loading.value = false
}
})
</script>
<template>
<n-data-table
:loading="loading"
:columns="columns"
:data="rows"
size="small"
:pagination="rows.length > 10 ? { pageSize: 10 } : false"
/>
<n-empty
v-if="!loading && rows.length === 0"
description="还没有人做过这道练习"
size="small"
style="padding: 16px 0"
/>
</template>

View File

@@ -0,0 +1,324 @@
<script setup lang="ts">
import { NProgress, NText } from "naive-ui"
import {
getLearnStudents,
getLearnTutorials,
getLearnExercises,
} from "admin/api"
import { readableDuration, parseTime } from "utils/functions"
import type {
LearnStudentProgress,
LearnTutorialProgress,
LearnExerciseProgress,
} from "utils/types"
import ExerciseAttempts from "./ExerciseAttempts.vue"
const EXERCISE_TYPE_LABEL: Record<string, string> = {
mcq: "选择",
sort: "排序",
fill: "填空",
match: "连线",
predict: "预测输出",
debug: "找错",
group: "分组",
}
const type = ref<"python" | "c">("python")
// 3-4 位是具体班级1-2 位当年级前缀(后端 classFilter 分的岔)
const className = ref("")
const tab = ref("students")
const loading = ref(false)
const students = ref<LearnStudentProgress[]>([])
const tutorials = ref<LearnTutorialProgress[]>([])
const exercises = ref<LearnExerciseProgress[]>([])
const tutorialCount = ref(0)
const exerciseCount = ref(0)
const studentCount = ref(0)
// 展开明细的那一行;一次只展开一道题,免得几十个请求一起打出去
const expanded = ref<number[]>([])
const typeOptions = [
{ label: "Python", value: "python" },
{ label: "C 语言", value: "c" },
]
const startedCount = computed(
() => students.value.filter((row) => row.readCount > 0).length,
)
const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
{ title: "班级", key: "className", width: 90, sorter: "default" },
{ title: "学号", key: "username", width: 140 },
{
title: "姓名",
key: "realName",
width: 110,
render: (row) => row.realName || "-",
},
{
title: `已读(共 ${tutorialCount.value} 课)`,
key: "readCount",
width: 170,
sorter: "default",
// 默认把读得最少的排在最前面:这张表要回答的是「谁还没开始」,
// 按读得多的排在前面,需要盯的人全在最后一页
defaultSortOrder: "ascend",
render: (row) =>
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
h("span", `${row.readCount} / ${tutorialCount.value}`),
h(NProgress, {
type: "line",
percentage: tutorialCount.value
? Math.round((row.readCount / tutorialCount.value) * 100)
: 0,
showIndicator: false,
status: row.readCount === 0 ? "error" : "success",
style: "width: 70px",
}),
]),
},
{
title: `练一练(共 ${exerciseCount.value} 道)`,
key: "exerciseSolved",
width: 190,
sorter: "default",
render: (row) =>
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
h("span", `${row.exerciseSolved} / ${exerciseCount.value}`),
// 做过但没做对的题,和提交总次数,一起说明「他在硬啃还是没碰」
row.exerciseTried > row.exerciseSolved
? h(
NText,
{ depth: 3, style: "font-size: 12px" },
() => `${row.exerciseTried - row.exerciseSolved}`,
)
: null,
row.exerciseAttempts
? h(
NText,
{ depth: 3, style: "font-size: 12px" },
() => `${row.exerciseAttempts}`,
)
: null,
]),
},
{
title: "累计时长",
key: "totalSeconds",
width: 130,
sorter: "default",
render: (row) => readableDuration(row.totalSeconds),
},
{
title: "最后学习",
key: "lastViewedAt",
width: 170,
sorter: "default",
render: (row) =>
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日 HH:mm") : "-",
},
])
const tutorialColumns = computed<DataTableColumn<LearnTutorialProgress>[]>(
() => [
{
title: "#",
key: "order",
width: 60,
render: (_, index) => index + 1,
},
{ title: "课程", key: "title", minWidth: 200 },
{
title: `读过的人(共 ${studentCount.value} 人)`,
key: "readers",
width: 200,
sorter: "default",
render: (row) =>
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
h("span", `${row.readers} / ${studentCount.value}`),
h(NProgress, {
type: "line",
percentage: studentCount.value
? Math.round((row.readers / studentCount.value) * 100)
: 0,
showIndicator: false,
status: row.readers === 0 ? "error" : "success",
style: "width: 70px",
}),
]),
},
{
title: "人均时长",
key: "avgSeconds",
width: 130,
sorter: "default",
render: (row) => readableDuration(row.avgSeconds),
},
{
title: "累计时长",
key: "totalSeconds",
width: 130,
sorter: "default",
render: (row) => readableDuration(row.totalSeconds),
},
],
)
const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
() => [
{ type: "expand", renderExpand: (row) => h(ExerciseAttempts, {
exerciseId: row.exerciseId,
className: className.value.trim(),
}) },
{
title: "课",
key: "tutorialOrder",
width: 160,
ellipsis: { tooltip: true },
render: (row) => `${row.tutorialOrder}. ${row.tutorialTitle}`,
},
{
title: "题型",
key: "type",
width: 90,
render: (row) => EXERCISE_TYPE_LABEL[row.type] ?? row.type,
},
{
title: "题干",
key: "question",
minWidth: 220,
ellipsis: { tooltip: true },
render: (row) => row.question || "(无题干)",
},
{
title: "做对 / 做过",
key: "solvedUsers",
width: 150,
sorter: "default",
render: (row) =>
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
h("span", `${row.solvedUsers} / ${row.triedUsers}`),
h(NProgress, {
type: "line",
percentage: row.triedUsers
? Math.round((row.solvedUsers / row.triedUsers) * 100)
: 0,
showIndicator: false,
status: row.triedUsers === 0 ? "error" : "success",
style: "width: 60px",
}),
]),
},
{
title: "一次做对",
key: "firstTryUsers",
width: 110,
sorter: "default",
render: (row) => `${row.firstTryUsers}`,
},
{
// 做对的人平均试了几次。它和「一次做对」一起看才分得清难题和歧义题:
// 平均 3 次但没人一次对 → 题目本身有坑
title: "平均试几次",
key: "avgAttemptsToSolve",
width: 120,
sorter: "default",
defaultSortOrder: "descend",
render: (row) => (row.solvedUsers ? `${row.avgAttemptsToSolve}` : "-"),
},
{
title: "提交总次数",
key: "attempts",
width: 120,
sorter: "default",
},
],
)
async function load() {
loading.value = true
expanded.value = []
const params = { type: type.value, className: className.value.trim() }
try {
// 三张表一起拉:切 tab 是纯前端的事,不该再等一次网络
const [studentRes, tutorialRes, exerciseRes] = await Promise.all([
getLearnStudents(params),
getLearnTutorials(params),
getLearnExercises(params),
])
students.value = studentRes.results
tutorialCount.value = studentRes.tutorialCount
exerciseCount.value = studentRes.exerciseCount
tutorials.value = tutorialRes.results
studentCount.value = tutorialRes.studentCount
exercises.value = exerciseRes.results
} finally {
loading.value = false
}
}
watch(type, load)
onMounted(load)
</script>
<template>
<h2 style="margin-top: 0">自学情况</h2>
<n-flex align="center" style="margin-bottom: 16px">
<n-radio-group v-model:value="type" size="small">
<n-radio-button
v-for="item in typeOptions"
:key="item.value"
:value="item.value"
:label="item.label"
/>
</n-radio-group>
<n-input
v-model:value="className"
placeholder="班级或年级,如 241 / 24"
clearable
style="width: 200px"
@keyup.enter="load"
@clear="load"
/>
<n-button type="primary" secondary @click="load">查询</n-button>
<n-text depth="3">
{{ studentCount }} 名学生{{ startedCount }} 人已经开始学
</n-text>
</n-flex>
<n-tabs v-model:value="tab" type="line" animated>
<n-tab-pane name="students" tab="按学生">
<n-data-table
:loading="loading"
:columns="studentColumns"
:data="students"
:row-key="(row: LearnStudentProgress) => row.userId"
striped
:pagination="{ pageSize: 20 }"
/>
</n-tab-pane>
<n-tab-pane name="exercises" tab="按练习">
<n-data-table
:loading="loading"
:columns="exerciseColumns"
:data="exercises"
:row-key="(row: LearnExerciseProgress) => row.exerciseId"
v-model:expanded-row-keys="expanded"
striped
:pagination="{ pageSize: 20 }"
/>
</n-tab-pane>
<n-tab-pane name="tutorials" tab="按课程">
<n-data-table
:loading="loading"
:columns="tutorialColumns"
:data="tutorials"
:row-key="(row: LearnTutorialProgress) => row.tutorialId"
striped
:pagination="{ pageSize: 20 }"
/>
</n-tab-pane>
</n-tabs>
</template>

View File

@@ -55,6 +55,7 @@ import type {
SubmitCodePayload,
WebsiteConfig,
Tutorial,
TutorialProgress,
} from "utils/types"
/**
@@ -459,3 +460,50 @@ export function getProblemSetUserProgress(
export function getExercises(tutorialId: number): Promise<Exercise[]> {
return api.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
}
/**
* 上报一次练一练的作答。`answer` 是给老师看的一句人话(「选了 A、C」
* 只在做错时才有意义,做对了不用带。
*
* 截到 200 字符再发:后端契约卡的就是 200填空题填了一整段的话
* 不截就是一个 400而学生这边什么都看不见 —— 留痕失败得静悄悄的。
*/
export function reportExerciseAttempt(
exerciseId: number,
payload: { correct: boolean; answer?: string },
) {
return fetch(`/api/exercises/${exerciseId}/attempts`, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
correct: payload.correct,
answer: payload.answer?.slice(0, 200),
}),
}).catch(() => undefined)
}
export function getLearnProgress(type: "python" | "c") {
return api.get<TutorialProgress[]>("learn/progress", { params: { type } })
}
/**
* 上报自学留痕。`opened` 为真表示刚进这一课,否则只是补停留时长。
*
* 走裸 fetch 而不是 axios是为了 `keepalive`:离开页面那一下的最后一次上报,
* axios 发出去也会随页面卸载被浏览器掐掉,学生每节课的最后一段时长就永远丢了。
* 失败一律吞掉 —— 留痕是旁路,不该让学生看到任何报错。
*/
export function reportLearnProgress(
tutorialId: number,
payload: { seconds: number; opened: boolean },
) {
return fetch(`/api/tutorials/${tutorialId}/progress`, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => undefined)
}

View File

@@ -4,6 +4,10 @@ import { highlightLines } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExerciseDebugData)
const lineHtml = computed(() => highlightLines(data.value.lines, props.lang))
@@ -65,6 +69,11 @@ function lineStyle(i: number): Record<string, string> {
function submit() {
submitted.value = true
const picked = [...selected.value].sort((a, b) => a - b).map((i) => i + 1)
emit("attempt", {
correct: allCorrect.value,
answer: picked.length ? `选了第 ${picked.join("、")}` : "一行都没选",
})
}
function reset() {

View File

@@ -4,6 +4,10 @@ import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExerciseFillData)
type CodeSeg = { type: "code"; html: string }
@@ -58,6 +62,10 @@ function submit() {
}
wrongBlanks.value = wrong
allCorrect.value = wrong.size === 0
emit("attempt", {
correct: allCorrect.value,
answer: `填了 ${userInputs.value.map((v) => v.trim() || "(空)").join(" | ")}`,
})
}
function inputWidth(idx: number): string {

View File

@@ -3,6 +3,10 @@ import type { Exercise, ExerciseGroupData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExerciseGroupData)
const order = ref<number[]>([]) // item 的稳定展示顺序(初始乱序)
@@ -72,6 +76,10 @@ function chipStyle(i: number): Record<string, string> {
function submit() {
submitted.value = true
emit("attempt", {
correct: allCorrect.value,
answer: `分组 ${placement.value.map((b, i) => `${i + 1}${b === -1 ? "?" : b + 1}`).join("、")}`,
})
}
function reset() {

View File

@@ -3,6 +3,10 @@ import type { Exercise, ExerciseMatchData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExerciseMatchData)
const PALETTE = [
@@ -69,6 +73,10 @@ function onRightClick(rightIdx: number) {
function submit() {
submitted.value = true
emit("attempt", {
correct: allCorrect.value,
answer: `配对 ${pairs.value.map((p, i) => `${i + 1}${p === null ? "?" : p + 1}`).join("、")}`,
})
}
function reset() {

View File

@@ -2,6 +2,10 @@
import type { Exercise, ExerciseMcqData } from "utils/types"
const props = defineProps<{ exercise: Exercise }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExerciseMcqData)
const isSingle = computed(() => data.value.answer.length === 1)
@@ -31,6 +35,7 @@ function submit() {
const sel = selected.value
const isEqual =
sel.size === answer.size && [...sel].every((v) => answer.has(v))
emit("attempt", { correct: isEqual, answer: describe(sel) })
if (isEqual) {
correct.value = true
wrong.value = false
@@ -48,6 +53,11 @@ function submit() {
}
}
/** 给老师看的一句人话:选项按 A/B/C 报,报下标没人看得懂 */
function describe(sel: Set<number>) {
return `选了 ${[...sel].sort((a, b) => a - b).map((i) => String.fromCharCode(65 + i)).join("、")}`
}
function reset() {
selected.value = new Set()
correct.value = false

View File

@@ -4,6 +4,10 @@ import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExercisePredictData)
const codeHtml = computed(() => highlight(data.value.code, props.lang))
@@ -27,8 +31,21 @@ const allCorrect = computed(() =>
data.value.answer.some((a) => normalize(a) === normalize(userInput.value)),
)
// 改了答案就把上一次的判定收回去,等他重新点提交 —— 排序/连线/找错/分组四种题
// 本来就是这么做的(各自的交互处都会把 submitted 置回 false只有这里漏了。
// 不收回的话,`allCorrect` 是跟着输入实时算的,学生错一次之后把答案改对,
// 界面直接跳成「输出正确!」、提交按钮同时禁用 —— submit() 再也不会执行,
// 于是这道题**永远不会被记成做对**(留痕里他就一直卡在那次错的上面)。
watch(userInput, () => {
submitted.value = false
})
function submit() {
submitted.value = true
emit("attempt", {
correct: allCorrect.value,
answer: `答「${userInput.value.replace(/\n/g, "⏎")}`,
})
}
function reset() {

View File

@@ -5,6 +5,10 @@ import { highlightLines } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExerciseSortData)
type LineItem = { originalIdx: number; text: string }
@@ -55,6 +59,11 @@ const allCorrect = computed(() =>
function submit() {
submitted.value = true
emit("attempt", {
correct: allCorrect.value,
// 报的是「他把原文第几行排在了第几位」,老师对着题面就能看出错在哪
answer: `顺序 ${lines.value.map((item) => item.originalIdx + 1).join("-")}`,
})
}
function reset() {

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import type { Exercise } from "utils/types"
import { reportExerciseAttempt } from "oj/api"
import { useUserStore } from "shared/store/user"
const ExerciseMcq = defineAsyncComponent(() => import("./ExerciseMcq.vue"))
const ExerciseSort = defineAsyncComponent(() => import("./ExerciseSort.vue"))
@@ -11,31 +13,78 @@ const ExercisePredict = defineAsyncComponent(
const ExerciseDebug = defineAsyncComponent(() => import("./ExerciseDebug.vue"))
const ExerciseGroup = defineAsyncComponent(() => import("./ExerciseGroup.vue"))
defineProps<{ exercise: Exercise; lang?: string }>()
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const userStore = useUserStore()
/**
* 七种题型各自判完对错后都往上抛 attempt留痕只在这里做一次 ——
* 每个题型组件里各写一遍上报,早晚会漏掉一两个。
*
* 两道闸:做对之后不再上报(后端也冻结,这里省一次请求);同一份答案连点两次不算
* 两次(排序题、连线题的「提交」按钮点完不会禁用,一个字没动再点一下不是新的尝试)。
*/
let solved = false
let lastAnswer: string | null = null
// 教程页里 v-for 的 key 是段落序号,换课时组件实例会被复用 —— 不跟着题目 id 重置的话,
// 上一课做对的状态会把这一课的第一次作答吃掉
watch(
() => props.exercise.id,
() => {
solved = false
lastAnswer = null
},
)
function onAttempt(payload: { correct: boolean; answer?: string }) {
if (!userStore.isAuthed || solved) return
const answer = payload.answer ?? ""
if (answer === lastAnswer) return
lastAnswer = answer
if (payload.correct) solved = true
reportExerciseAttempt(props.exercise.id, payload)
}
</script>
<template>
<ExerciseMcq v-if="exercise.type === 'mcq'" :exercise="exercise" />
<ExerciseMcq
v-if="exercise.type === 'mcq'"
:exercise="exercise"
@attempt="onAttempt"
/>
<ExerciseSort
v-else-if="exercise.type === 'sort'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseFill
v-else-if="exercise.type === 'fill'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseMatch
v-else-if="exercise.type === 'match'"
:exercise="exercise"
@attempt="onAttempt"
/>
<ExerciseMatch v-else-if="exercise.type === 'match'" :exercise="exercise" />
<ExercisePredict
v-else-if="exercise.type === 'predict'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseDebug
v-else-if="exercise.type === 'debug'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseGroup
v-else-if="exercise.type === 'group'"
:exercise="exercise"
@attempt="onAttempt"
/>
<ExerciseGroup v-else-if="exercise.type === 'group'" :exercise="exercise" />
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { TutorialProgress } from "utils/types"
import { readableDuration } from "utils/functions"
defineProps<{
titles: { id: number; title: string }[]
step: number
/** 按教程 id 索引的自学留痕,未登录时是空的 */
progress: Record<number, TutorialProgress>
/** 是否在留痕(登录了才留) */
traced: boolean
}>()
const emit = defineEmits<{ select: [lesson: number] }>()
</script>
<template>
<n-list hoverable clickable>
<n-list-item
v-for="(item, index) in titles"
:key="item.id"
@click="emit('select', index + 1)"
>
<!-- 标题独占一行目录栏只有屏幕的五分之一宽已读摆在同一行会把
中文标题挤成两截 -->
<n-flex vertical :size="2">
<n-text
:type="step === index + 1 ? 'primary' : undefined"
:strong="step === index + 1"
>
{{ index + 1 }}. {{ item.title }}
</n-text>
<!-- 每篇教程都有一条进度没读过的是一行零所以这里判的是读没读过
不是有没有这条记录 -->
<n-text
v-if="progress[item.id]?.viewCount"
type="success"
style="font-size: 12px"
>
已读 · {{ readableDuration(progress[item.id].totalSeconds) }}
</n-text>
<n-text
v-if="progress[item.id]?.exerciseTotal"
:type="
progress[item.id].exerciseSolved === progress[item.id].exerciseTotal
? 'success'
: undefined
"
:depth="
progress[item.id].exerciseSolved === progress[item.id].exerciseTotal
? undefined
: 3
"
style="font-size: 12px"
>
练一练 {{ progress[item.id].exerciseSolved }} /
{{ progress[item.id].exerciseTotal }}
</n-text>
</n-flex>
</n-list-item>
</n-list>
<!-- 只在没登录时提一句登录了却还没读的人不需要被提醒你还没读 -->
<n-text v-if="!traced" depth="3" style="display: block; padding: 8px 4px">
登录后可以记录学习进度
</n-text>
</template>

View File

@@ -0,0 +1,72 @@
import { reportLearnProgress } from "oj/api"
/** 计时心跳。攒够 FLUSH_SECONDS 才上报一次,别让每个学生每秒钟打一次接口 */
const TICK_MS = 15_000
const FLUSH_SECONDS = 60
/**
* 挂机保护:连续这么久没有任何鼠标/键盘/滚轮动作就停止计时。
*
* 机房的电脑经常开着页面就走了,不设这道闸的话「停留时长」会变成「电脑开机时长」,
* 老师看到的数字全是假的。10 分钟是折中:真在读长课文的学生不会连滚轮都不碰这么久,
* 而挂机的最多也只多算 10 分钟。
*/
const IDLE_MS = 10 * 60 * 1000
/**
* 自学留痕的客户端计时。
*
* 只在「页面可见 + 人没挂机」时累加秒数,攒够一分钟或离开这一课时上报。
* 换课、组件卸载、页面隐藏(切标签页/关窗口/手机切后台)都会先把攒着的秒数冲出去 ——
* 手机上 `beforeunload` 常常不触发,`visibilitychange` 才是可靠的那个。
*
* @param tutorialId 当前这一课0 表示还没加载好
* @param enabled 是否留痕。未登录时为 false教程本身保持免登录可读只是不记
*/
export function useLearnTrace(
tutorialId: Ref<number>,
enabled: Ref<boolean>,
) {
const visibility = useDocumentVisibility()
const { idle } = useIdle(IDLE_MS)
// 攒着还没上报的秒数,以及它属于哪一课 —— 换课时先把上一课的冲掉,
// 不能记在当前 tutorialId 名下,否则时长会被算到下一课头上
let pending = 0
let pendingId = 0
function flush() {
if (!enabled.value || pending <= 0 || pendingId <= 0) return
const seconds = pending
const id = pendingId
pending = 0
reportLearnProgress(id, { seconds, opened: false })
}
const timer = window.setInterval(() => {
if (!enabled.value || tutorialId.value <= 0) return
if (visibility.value !== "visible" || idle.value) return
pendingId = tutorialId.value
pending += TICK_MS / 1000
if (pending >= FLUSH_SECONDS) flush()
}, TICK_MS)
// enabled 也要盯着:学生常常是打开教程之后才在弹窗里登录的,那一下 tutorialId
// 没变,只看 tutorialId 的话这一课就永远不算「打开过」,得等他翻到下一课才开始留痕
watch([tutorialId, enabled], ([id, on], [previousId, previousOn]) => {
if (previousId && previousId !== id) flush()
if (!on || id <= 0) return
if (id === previousId && on === previousOn) return
pendingId = id
reportLearnProgress(id, { seconds: 0, opened: true })
})
watch(visibility, (value) => {
if (value === "hidden") flush()
})
onBeforeUnmount(() => {
window.clearInterval(timer)
flush()
})
}

View File

@@ -9,20 +9,13 @@
>
<n-gi :span="1" class="learn-col">
<n-card title="教程目录" :bordered="false" size="small">
<n-list hoverable clickable>
<n-list-item
v-for="(item, index) in titles"
:key="item.id"
@click="goToLesson(index + 1)"
>
<n-text
:type="step === index + 1 ? 'primary' : undefined"
:strong="step === index + 1"
>
{{ index + 1 }}. {{ item.title }}
</n-text>
</n-list-item>
</n-list>
<LessonList
:titles="titles"
:step="step"
:progress="progress"
:traced="traced"
@select="goToLesson"
/>
</n-card>
</n-gi>
@@ -69,20 +62,13 @@
<template v-if="tutorial.id && !isDesktop">
<n-tabs type="line" animated v-model:value="activeTab">
<n-tab-pane name="catalog" tab="目录">
<n-list hoverable clickable>
<n-list-item
v-for="(item, index) in titles"
:key="item.id"
@click="goToLesson(index + 1)"
>
<n-text
:type="step === index + 1 ? 'primary' : undefined"
:strong="step === index + 1"
>
{{ index + 1 }}. {{ item.title }}
</n-text>
</n-list-item>
</n-list>
<LessonList
:titles="titles"
:step="step"
:progress="progress"
:traced="traced"
@select="goToLesson"
/>
</n-tab-pane>
<n-tab-pane name="content" :tab="`第 ${step} 课`">
@@ -140,11 +126,19 @@
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import type { Tutorial, Exercise, LANGUAGE } from "utils/types"
import { getTutorial, getTutorials, getExercises } from "../api"
import type { Tutorial, Exercise, LANGUAGE, TutorialProgress } from "utils/types"
import {
getTutorial,
getTutorials,
getExercises,
getLearnProgress,
} from "../api"
import { parseExercises } from "./composables/useExerciseParse"
import { useLearnTrace } from "./composables/useLearnTrace"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useUserStore } from "shared/store/user"
import LessonList from "./components/LessonList.vue"
const ExerciseWidget = defineAsyncComponent(
() => import("./components/ExerciseWidget.vue"),
@@ -158,6 +152,10 @@ const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const userStore = useUserStore()
// 未登录也能看教程(学习页本来就不要求登录),只是不留痕
const traced = computed(() => userStore.isAuthed)
const step = computed(() => {
const value = route.params.step as string | undefined
@@ -180,6 +178,7 @@ const editorLanguage = computed<LANGUAGE>(() =>
tutorial.value.type === "c" ? "C" : "Python3",
)
const titles = ref<{ id: number; title: string }[]>([])
const progress = ref<Record<number, TutorialProgress>>({})
const exercises = ref<Exercise[]>([])
const activeTab = ref("content")
const isEmpty = ref(false)
@@ -188,6 +187,12 @@ const segments = computed(() =>
parseExercises(tutorial.value.content ?? "", exercises.value),
)
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
useLearnTrace(
computed(() => tutorial.value.id ?? 0),
traced,
)
const isFirstLesson = computed(() => step.value === 1)
const isLastLesson = computed(() => step.value === titles.value.length)
@@ -204,6 +209,23 @@ function goToNextLesson() {
if (step.value < titles.value.length) goToLesson(step.value + 1)
}
/**
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响上课,
* 但弹个错会把「我是不是没学」的焦虑塞给学生。
*/
async function loadProgress() {
if (!traced.value) {
progress.value = {}
return
}
try {
const rows = await getLearnProgress(type.value)
progress.value = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
} catch {
progress.value = {}
}
}
async function init() {
const res1 = await getTutorials(type.value)
titles.value = res1
@@ -217,6 +239,7 @@ async function init() {
if (res2.status === "fulfilled") tutorial.value = res2.value
exercises.value = exs.status === "fulfilled" ? exs.value : []
learnStep.value[type.value] = step.value
loadProgress()
}
watch(
@@ -226,6 +249,10 @@ watch(
},
{ immediate: true },
)
// 在教程页上登录/退出时把目录的勾重新拉一遍。学生多半是先点开教程、
// 被弹窗拦下才登录的,不盯着这个的话勾要等他刷新页面才出现
watch(traced, loadProgress)
</script>
<style scoped>

View File

@@ -335,5 +335,11 @@ export const admins: RouteRecordRaw = {
component: () => import("admin/ai/list.vue"),
meta: { requiresTeacherAdmin: true },
},
{
path: "learn",
name: "admin learn analytics",
component: () => import("admin/learn/index.vue"),
meta: { requiresTeacherAdmin: true },
},
],
}

View File

@@ -67,6 +67,11 @@ const options = computed<MenuOption[]>(() => {
),
key: "admin ai reports",
},
{
label: () =>
h(RouterLink, { to: "/admin/learn" }, { default: () => "自学" }),
key: "admin learn analytics",
},
)
}
@@ -150,6 +155,11 @@ const options = computed<MenuOption[]>(() => {
),
key: "admin ai reports",
},
{
label: () =>
h(RouterLink, { to: "/admin/learn" }, { default: () => "自学" }),
key: "admin learn analytics",
},
)
}
@@ -171,6 +181,7 @@ const active = computed(() => {
if (path.startsWith("/admin/announcement")) return "admin announcement list"
if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
if (path.startsWith("/admin/ai")) return "admin ai reports"
if (path.startsWith("/admin/learn")) return "admin learn analytics"
return route.name as string
})

View File

@@ -90,6 +90,20 @@ export function duration(
return formatDurationUnits(durationObj, units)
}
/**
* 自学时长的显示。心跳是 15 秒一跳,本来就精确不到秒,一律按分钟取整;
* 0 显示成短横线而不是「0 分钟」——「没学过」和「学了不到一分钟」不是一回事。
*/
export function readableDuration(seconds: number): string {
if (seconds <= 0) return "-"
if (seconds < 60) return "不到 1 分钟"
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `${minutes} 分钟`
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
return rest ? `${hours} 小时 ${rest}` : `${hours} 小时`
}
export function durationToDays(
start: Date | string,
end: Date | string,

View File

@@ -413,6 +413,13 @@ export type {
AdminAiReportList,
StuckProblem,
AcTrend,
LearnStudentProgress,
LearnStudentProgressList,
LearnTutorialProgress,
LearnTutorialProgressList,
LearnExerciseProgress,
LearnExerciseProgressList,
LearnExerciseAttempt,
} from "@oj2/contract"
/**
@@ -464,6 +471,9 @@ export type {
AdminTutorial as Tutorial,
AdminTutorialListItem as TutorialListItem,
} from "@oj2/contract"
/** 学生自己的自学留痕,学习页的目录拿它打勾 */
export type { TutorialProgress } from "@oj2/contract"
import type {
AdminExercise,
AdminTutorial,

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>

View File

@@ -78,6 +78,45 @@ export const tutorialSchema = tutorialSummarySchema.extend({
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(),
})
export const exerciseSchema = z.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
@@ -99,3 +138,6 @@ export type ReactionState = z.infer<typeof reactionStateSchema>
export type SetReactionRequest = z.infer<typeof setReactionRequestSchema>
export type Tutorial = z.infer<typeof tutorialSchema>
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>