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
+4596 -243

No files matched your search

@@ -0,0 +1,15 @@
CREATE TABLE "tutorial_problem" (
"tutorial_id" integer NOT NULL,
"problem_id" integer NOT NULL,
"order" integer NOT NULL,
CONSTRAINT "tutorial_problem_pkey" PRIMARY KEY("tutorial_id","problem_id")
);
--> statement-breakpoint
ALTER TABLE "tutorial" ADD COLUMN "project" varchar(64) DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "tutorial_problem" ADD CONSTRAINT "tutorial_problem_tutorial_id_fk_tutorial_id" FOREIGN KEY ("tutorial_id") REFERENCES "public"."tutorial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tutorial_problem" ADD CONSTRAINT "tutorial_problem_problem_id_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
-- 项目原来只写在正文第一段(「**所属项目:项目三 · 智慧农业·蜜橘园**」),抽出来回填。
-- 没写的(9 篇没公开的老 Python 教程)留空,自学目录里归到「其他」
UPDATE "tutorial"
SET "project" = left(trim(substring("content" from '\*\*所属项目[::]\s*([^*\n]+)\*\*')), 64)
WHERE "content" ~ '\*\*所属项目[::]';
File diff suppressed because it is too large. Load diff
+7
View File
@@ -218,6 +218,13 @@
"when": 1791587559546,
"tag": "0030_add_user_rank_hidden",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1791766110876,
"tag": "0031_tutorial_project_and_problems",
"breakpoints": true
}
]
}
+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",
{
+90 -131
View File
@@ -1,13 +1,10 @@
import {
STUDENT_ROLES,
TUTORIAL_READ_SECONDS,
isLessonDone,
type LearnBoard,
type LearnExerciseAttempt,
type LearnExerciseProgress,
type LearnExerciseProgressList,
type LearnStudentProgress,
type LearnStudentProgressList,
type LearnTutorialProgress,
type LearnTutorialProgressList,
} from "@oj2/contract"
import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm"
import { Hono } from "hono"
@@ -15,6 +12,8 @@ import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { defaultClassFor } from "../../services/ranking"
import { exerciseCountColumn } from "../../services/tutorial"
import { queryInteger, rounded } from "../helpers"
/**
@@ -64,157 +63,117 @@ function studentCondition(value: string | null) {
)
}
adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
/**
* 方块格:一个班的学生 × 这门课的每一课,打开过没有、学完没有(练一练全对)。
*
* 不选班就按排名页同一套规则猜老师在教哪个班(`defaultClassFor`);也能传年级前缀,
* 但一个年级两三百行方块,页面上主要还是按班看。
*/
adminLearnRoutes.get("/learn-analytics/board", 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 requested = classFilter(c.req.query("className"))
if (!requested.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const className = requested.value ?? (await defaultClassFor(c.get("user")!.id))
// 该语言下已公开的教程,既是分母,也是「哪些课算数」的白名单 ——
// 未公开的课学生本来就打不开,混进来会让读完的人显示成没读完
const tutorials = await db
.select({ id: schema.tutorial.id })
.select({
id: schema.tutorial.id,
title: schema.tutorial.title,
project: schema.tutorial.project,
exerciseTotal: exerciseCountColumn,
})
.from(schema.tutorial)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.orderBy(asc(schema.tutorial.order))
const tutorialIds = tutorials.map((row) => row.id)
if (!className)
return success(c, { className: null, tutorials, students: [] } satisfies LearnBoard)
// 学生表打底 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
const students = await db
.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
// 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。
// 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况
readCount:
sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(
Number,
),
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,
)
// 前端默认按「已读」升序排,同分的一大批(尤其一堆 0)就落回这里的次序。
// 不给 orderBy 的话那是聚合吐出来的任意顺序,刷一次换一个样 —— 按班级、
// 学号排稳住它。className 为空的(推不出班级的)ASC 默认排在最后
.orderBy(asc(schema.user.className), asc(schema.user.username)),
.where(studentCondition(className))
.orderBy(asc(schema.user.className), asc(schema.user.username))
const userIds = students.map((row) => row.userId)
// 阅读和练习分两条查再拼:两个一对多挂在同一张表上 join,count 全是错的
const [progressRows, solvedRows] =
userIds.length && tutorialIds.length
? await Promise.all([
db
.select({
userId: schema.tutorialProgress.userId,
tutorialId: schema.tutorialProgress.tutorialId,
viewCount: schema.tutorialProgress.viewCount,
totalSeconds: schema.tutorialProgress.totalSeconds,
lastViewedAt: schema.tutorialProgress.lastViewedAt,
})
.from(schema.tutorialProgress)
.where(
and(
inArray(schema.tutorialProgress.userId, userIds),
inArray(schema.tutorialProgress.tutorialId, tutorialIds),
),
),
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),
tutorialId: schema.exercise.tutorialId,
solved: count(),
})
.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, {
tutorialCount: tutorialIds.length,
exerciseCount: exerciseCountRow?.value ?? 0,
results: rows.map(
(row) =>
({
...row,
exerciseTried: attempts.get(row.userId)?.tried ?? 0,
exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
}) satisfies LearnStudentProgress,
.where(
and(
inArray(schema.exerciseAttempt.userId, userIds),
inArray(schema.exercise.tutorialId, tutorialIds),
eq(schema.exerciseAttempt.solved, true),
),
} satisfies LearnStudentProgressList)
})
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_id:join 不上的(老师自己试读的、
// 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它,
// 而 progress.user_id 那边永远非空,会把过滤当没发生
readers:
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(
Number,
),
totalSeconds:
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
Number,
),
// 人均时长的分母是 readers(读满 3 分钟的人),分子就得是同一批人的时长,
// 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高
readSeconds:
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 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))
.groupBy(schema.exerciseAttempt.userId, schema.exercise.tutorialId),
])
: [[], []]
const progress = new Map<string, (typeof progressRows)[number]>()
for (const row of progressRows) progress.set(`${row.userId}:${row.tutorialId}`, row)
const solved = new Map<string, number>()
for (const row of solvedRows) solved.set(`${row.userId}:${row.tutorialId}`, row.solved)
return success(c, {
studentCount,
results: rows.map(
({ readSeconds, ...row }) =>
({
...row,
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
}) satisfies LearnTutorialProgress,
),
} satisfies LearnTutorialProgressList)
className,
tutorials,
students: students.map((student) => {
const opened: number[] = []
const done: number[] = []
let exerciseSolved = 0
let totalSeconds = 0
let lastViewedAt: string | null = null
for (const tutorial of tutorials) {
const key = `${student.userId}:${tutorial.id}`
const row = progress.get(key)
const solvedHere = solved.get(key) ?? 0
exerciseSolved += solvedHere
if (row) {
opened.push(tutorial.id)
totalSeconds += row.totalSeconds
if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt
}
const lesson = {
viewCount: row?.viewCount ?? 0,
exerciseTotal: tutorial.exerciseTotal,
exerciseSolved: solvedHere,
}
if (isLessonDone(lesson)) done.push(tutorial.id)
}
return { ...student, opened, done, exerciseSolved, totalSeconds, lastViewedAt }
}),
} satisfies LearnBoard)
})
/**
+180 -14
View File
@@ -1,34 +1,57 @@
import {
STUDENT_ROLES,
createExerciseRequestSchema,
createTutorialRequestSchema,
renameTutorialProjectRequestSchema,
reorderTutorialsRequestSchema,
setTutorialProblemsRequestSchema,
setTutorialVisibilityRequestSchema,
updateExerciseRequestSchema,
updateTutorialRequestSchema,
type AdminExercise,
type AdminTutorial,
type AdminTutorialGroups,
type AdminTutorialListItem,
type TutorialProblemCandidate,
} from "@oj2/contract"
import { asc, desc, eq } from "drizzle-orm"
import { and, asc, countDistinct, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, parseBody, success } from "../../http"
import { exerciseDataError } from "../../services/exercise"
import {
MUST_DO_TAG,
exerciseCountColumn,
mustDoColumn,
problemCountColumn,
problemTagNames,
projectFromContent,
tutorialProblems,
} from "../../services/tutorial"
import { asRecord, queryInteger, sampleUser } from "../helpers"
export const adminTutorialRoutes = new Hono<AppEnv>()
function serializeTutorial(row: {
async function serializeTutorial(row: {
tutorial: typeof schema.tutorial.$inferSelect
user: typeof schema.user.$inferSelect
realName: string | null
}) {
const problems = await tutorialProblems([row.tutorial.id], false)
return {
id: row.tutorial.id,
title: row.tutorial.title,
content: row.tutorial.content,
code: row.tutorial.code,
project: row.tutorial.project,
problems: problems.map(({ id, displayId, title, mustDo }) => ({
id,
displayId,
title,
mustDo,
})),
isPublic: row.tutorial.isPublic,
order: row.tutorial.order,
type: row.tutorial.type,
@@ -53,59 +76,174 @@ function selectTutorial(id: number) {
}
adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
const rows = await db
const [rows, readerRows] = await Promise.all([
db
.select({
tutorial: schema.tutorial,
user: schema.user,
realName: schema.userProfile.realName,
exerciseCount: exerciseCountColumn,
problemCount: problemCountColumn,
})
.from(schema.tutorial)
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.orderBy(asc(schema.tutorial.order), desc(schema.tutorial.createdAt))
const all = rows.map(serializeTutorial)
// 分组返回,形状对齐旧 TutorialAdminAPI.get;列表 schema omit 掉了 content/code,Zod 会 strip
.orderBy(asc(schema.tutorial.order), desc(schema.tutorial.createdAt)),
// 打开过的学生数:老师自己点开看的不算
db
.select({
tutorialId: schema.tutorialProgress.tutorialId,
readers: countDistinct(schema.tutorialProgress.userId),
})
.from(schema.tutorialProgress)
.innerJoin(schema.user, eq(schema.user.id, schema.tutorialProgress.userId))
.where(inArray(schema.user.adminType, [...STUDENT_ROLES]))
.groupBy(schema.tutorialProgress.tutorialId),
])
const readers = new Map(readerRows.map((row) => [row.tutorialId, row.readers]))
const all = rows.map(
(row) =>
({
id: row.tutorial.id,
title: row.tutorial.title,
project: row.tutorial.project,
isPublic: row.tutorial.isPublic,
order: row.tutorial.order,
type: row.tutorial.type,
createdBy: sampleUser(row.user, row.realName),
createdAt: row.tutorial.createdAt,
updatedAt: row.tutorial.updatedAt,
exerciseCount: row.exerciseCount,
problemCount: row.problemCount,
readers: readers.get(row.tutorial.id) ?? 0,
}) satisfies AdminTutorialListItem,
)
// 分组返回,形状对齐旧 TutorialAdminAPI.get
return success(c, {
python: all.filter((item) => item.type === "python"),
c: all.filter((item) => item.type === "c"),
} satisfies AdminTutorialGroups)
})
/**
* 拖动排序:把这一串 id 的 order 依次改成 1、2、3……
* 路径不放在 `/tutorials/` 下,理由同 learn.ts 顶上那段:`/tutorials/:id` 会先吃掉它。
*/
adminTutorialRoutes.put("/tutorial-order", requireSuperAdmin, async (c) => {
const parsed = await parseBody(c, reorderTutorialsRequestSchema)
if (!parsed.success) return parsed.response
const ids = parsed.data.ids
await db.transaction(async (tx) => {
for (const [index, id] of ids.entries()) {
// 排序不是内容修改,不动 updatedAt
await tx
.update(schema.tutorial)
.set({ order: index + 1 })
.where(eq(schema.tutorial.id, id))
}
})
return success(c, null)
})
/** 项目改名:这门课里挂在旧名字下的教程一起改 */
adminTutorialRoutes.put("/tutorial-projects", requireSuperAdmin, async (c) => {
const parsed = await parseBody(c, renameTutorialProjectRequestSchema)
if (!parsed.success) return parsed.response
const { type, from, to } = parsed.data
await db
.update(schema.tutorial)
.set({ project: to })
.where(and(eq(schema.tutorial.type, type), eq(schema.tutorial.project, from)))
return success(c, null)
})
/**
* 给一课挑题的候选。不带关键字列出全部必会题;带关键字按题号、标题找,
* 必会题排在前面。只给公开题库里的题(比赛题学生点不进去)。
*/
adminTutorialRoutes.get("/tutorial-problem-candidates", requireSuperAdmin, async (c) => {
const keyword = c.req.query("keyword")?.trim() ?? ""
const pattern = `%${keyword.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`
const rows = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
mustDo: mustDoColumn,
tags: problemTagNames,
})
.from(schema.problem)
.where(
and(
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
keyword
? or(eq(schema.problem.displayId, keyword), ilike(schema.problem.title, pattern))
: mustDoColumn,
),
)
.orderBy(
desc(sql`${schema.problem.displayId} = ${keyword}`),
desc(mustDoColumn),
asc(schema.problem.displayId),
)
.limit(keyword ? 20 : 60)
return success(
c,
rows.map(
(row) =>
({
...row,
tags: row.tags.filter((tag) => tag !== MUST_DO_TAG),
}) satisfies TutorialProblemCandidate,
),
)
})
adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => {
const parsed = await parseBody(c, createTutorialRequestSchema)
if (!parsed.success) return parsed.response
const now = new Date().toISOString()
const { project, ...data } = parsed.data
const [created] = await db
.insert(schema.tutorial)
.values({
...parsed.data,
...data,
project: project ?? projectFromContent(data.content),
createdAt: now,
updatedAt: now,
createdById: c.get("user")!.id,
})
.returning({ id: schema.tutorial.id })
const [row] = await selectTutorial(created!.id)
return success(c, serializeTutorial(row!), 201)
return success(c, await serializeTutorial(row!), 201)
})
adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => {
const [row] = await selectTutorial(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, serializeTutorial(row))
return success(c, await serializeTutorial(row))
})
adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = await parseBody(c, updateTutorialRequestSchema)
if (!parsed.success) return parsed.response
const { project, ...data } = parsed.data
// 没传 project(发布脚本)就从正文里抽;正文里也没写就不动原来的
const derived = project ?? (projectFromContent(data.content) || undefined)
const updated = await db
.update(schema.tutorial)
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
.set({
...data,
...(derived === undefined ? {} : { project: derived }),
updatedAt: new Date().toISOString(),
})
.where(eq(schema.tutorial.id, id))
.returning({ id: schema.tutorial.id })
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(id)
return success(c, serializeTutorial(row!))
return success(c, await serializeTutorial(row!))
})
adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c) => {
@@ -120,13 +258,41 @@ adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c
.returning({ id: schema.tutorial.id })
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(id)
return success(c, serializeTutorial(row!))
return success(c, await serializeTutorial(row!))
})
/** 一课挂哪几道题:整串替换,顺序就是数组顺序 */
adminTutorialRoutes.put("/tutorials/:id/problems", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = await parseBody(c, setTutorialProblemsRequestSchema)
if (!parsed.success) return parsed.response
const problemIds = [...new Set(parsed.data.problemIds)]
const [row] = await selectTutorial(id)
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
if (problemIds.length) {
const found = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(and(inArray(schema.problem.id, problemIds), isNull(schema.problem.contestId)))
if (found.length !== problemIds.length) {
return failure(c, 400, "problem-not-found", "有的题不存在,或者是比赛题")
}
}
await db.transaction(async (tx) => {
await tx.delete(schema.tutorialProblem).where(eq(schema.tutorialProblem.tutorialId, id))
if (problemIds.length) {
await tx
.insert(schema.tutorialProblem)
.values(problemIds.map((problemId, index) => ({ tutorialId: id, problemId, order: index })))
}
})
return success(c, await serializeTutorial(row))
})
adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 练习与学习留痕都随教程一起没:exercise.tutorial_id 与 tutorial_progress.tutorial_id
// 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**,
// 练习、学习留痕、挂的题都随教程一起没:exercise / tutorial_progress / tutorial_problem
// 的 tutorial_id 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**,
// 别默认新表会自己连坐 —— 0010 只改了当时存在的那批外键。
const deleted = await db
.delete(schema.tutorial)
+40 -7
View File
@@ -20,10 +20,12 @@ import {
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
import { optionalAuth, requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { problemStates } from "../services/problem-progress"
import { tutorialProblems } from "../services/tutorial"
import { asRecord, queryInteger, sampleUser } from "./helpers"
export const contentRoutes = new Hono<AppEnv>()
@@ -254,15 +256,20 @@ contentRoutes.post("/problems/:id/reaction", requireAuth, async (c) => {
contentRoutes.get("/tutorials", async (c) => {
const type = c.req.query("type") === "c" ? "c" : "python"
const rows = await db
.select({ id: schema.tutorial.id, title: schema.tutorial.title })
.select({
id: schema.tutorial.id,
title: schema.tutorial.title,
project: schema.tutorial.project,
})
.from(schema.tutorial)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.orderBy(asc(schema.tutorial.order))
return success(c, rows satisfies TutorialSummary[])
})
contentRoutes.get("/tutorials/:id", async (c) => {
contentRoutes.get("/tutorials/:id", optionalAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const user = c.get("user")
const [row] = await db
.select({
tutorial: schema.tutorial,
@@ -275,11 +282,22 @@ contentRoutes.get("/tutorials/:id", async (c) => {
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
.limit(1)
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [problems, states] = await Promise.all([
tutorialProblems([id], true),
user ? problemStates(user.id) : null,
])
return success(c, {
id: row.tutorial.id,
title: row.tutorial.title,
project: row.tutorial.project,
content: row.tutorial.content,
code: row.tutorial.code,
problems: problems.map(({ id, displayId, title, mustDo }) => ({
id,
displayId,
title,
mustDo,
solved: states?.solved.has(id) ?? false,
})),
isPublic: row.tutorial.isPublic,
order: row.tutorial.order,
type: row.tutorial.type,
@@ -475,8 +493,9 @@ contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
return success(c, null)
})
contentRoutes.get("/tutorials/:id/exercises", async (c) => {
contentRoutes.get("/tutorials/:id/exercises", optionalAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const user = c.get("user")
const [tutorial] = await db
.select({ id: schema.tutorial.id })
.from(schema.tutorial)
@@ -484,19 +503,33 @@ contentRoutes.get("/tutorials/:id/exercises", async (c) => {
.limit(1)
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const rows = await db
.select()
.select({
exercise: schema.exercise,
// 目录、大纲上标「做对过」,一课的练一练全对就算学完这课
solved: user
? sql<boolean>`coalesce(${schema.exerciseAttempt.solved}, false)`
: sql<boolean>`false`,
})
.from(schema.exercise)
.leftJoin(
schema.exerciseAttempt,
and(
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
eq(schema.exerciseAttempt.userId, user?.id ?? 0),
),
)
.where(eq(schema.exercise.tutorialId, id))
.orderBy(asc(schema.exercise.order))
return success(
c,
rows.map(
(row) =>
({ exercise: row, solved }) =>
({
id: row.id,
type: row.type,
data: asRecord(row.data),
order: row.order,
solved,
}) satisfies Exercise,
),
)
+2 -44
View File
@@ -11,7 +11,7 @@ import {
type SampleUser,
type WeeklyChampion,
} from "@oj2/contract"
import { and, countDistinct, desc, eq, gte, inArray, isNotNull, ne } from "drizzle-orm"
import { and, eq, inArray, isNotNull } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
@@ -23,10 +23,10 @@ import {
classBattle,
classDetail,
classPk,
defaultClassFor,
loadEntrants,
PK_MAX_CLASSES,
pkPartner,
rankedStudents,
standings,
WEEK_MS,
type Standing,
@@ -73,48 +73,6 @@ function periodStart(period: RankPeriod) {
return null
}
/**
* 老师打开排名页默认看哪个班:自己最近布置过作业的班 → 最近一周交题人数最多的班。
* 不记老师上次选的:统计面板吃过那个亏(上一节课的班悄悄留在框里)。
*/
async function defaultClassFor(teacherId: number) {
const [lesson] = await db
.select({ className: schema.classLesson.className })
.from(schema.classLesson)
.where(eq(schema.classLesson.createdBy, teacherId))
.orderBy(desc(schema.classLesson.day), desc(schema.classLesson.updatedAt))
.limit(1)
if (lesson) return lesson.className
const users = countDistinct(schema.submission.userId)
const [busiest] = await db
.select({ className: schema.user.className, users })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
rankedStudents,
isNotNull(schema.user.className),
ne(schema.user.className, ""),
gte(schema.submission.createTime, new Date(Date.now() - WEEK_MS).toISOString()),
),
)
.groupBy(schema.user.className)
.orderBy(desc(users))
.limit(1)
if (busiest?.className) return busiest.className
// 放假回来第一次打开:一周里没人交过题,就看最后一个交题的学生是哪个班
const [latest] = await db
.select({ className: schema.user.className })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(and(rankedStudents, isNotNull(schema.user.className), ne(schema.user.className, "")))
.orderBy(desc(schema.submission.createTime))
.limit(1)
return latest?.className ?? null
}
/**
* 这次请求看的是哪个班 / 哪个年级。学生只能看自己的班和年级;老师用 `className` 选,
* 不给就猜(`defaultClassFor`)。返回 undefined 表示该报「没有班级」。
+19 -1
View File
@@ -16,6 +16,9 @@ export const trialRunRoutes = new Hono<AppEnv>()
/** 后台生成测试点时题目可能还没存,没有题目的限制可用,按这两个跑 */
const ADMIN_TIME_LIMIT = 3000
const ADMIN_MEMORY_LIMIT = 256
/** 自学教程里的代码块:都是几行的例子,给和普通题一样的限制,死循环早点停 */
const TUTORIAL_TIME_LIMIT = 1000
const TUTORIAL_MEMORY_LIMIT = 256
/**
* 试运行,见 judge/trial.ts。不落库、不算提交、不走令牌桶 ——
@@ -37,7 +40,22 @@ trialRunRoutes.post("/trial-runs", requireAuth, async (c) => {
let memoryLimit = ADMIN_MEMORY_LIMIT
let source = body.code
let prependLines = 0
if (body.problemId === undefined) {
if (body.problemId === undefined && body.tutorialId !== undefined) {
// 教程正文里的代码块:只有一组输入(代码块下面那个输入框),教程得是公开的
if (body.cases.length !== 1) {
return failure(c, 400, "too-many-cases", "教程里的代码一次只能跑一组")
}
const [tutorial] = await db
.select({ isPublic: schema.tutorial.isPublic })
.from(schema.tutorial)
.where(eq(schema.tutorial.id, body.tutorialId))
.limit(1)
if (!tutorial || (!tutorial.isPublic && !admin)) {
return failure(c, 404, "tutorial-not-found", "教程不存在")
}
timeLimit = TUTORIAL_TIME_LIMIT
memoryLimit = TUTORIAL_MEMORY_LIMIT
} else if (body.problemId === undefined) {
if (!admin) return failure(c, 403, "permission-denied", "请从题目页试运行")
} else {
let contestId: number | null = null
+44
View File
@@ -11,6 +11,8 @@ import {
import {
and,
count,
countDistinct,
desc,
eq,
gte,
inArray,
@@ -534,3 +536,45 @@ export async function classPk(
solo,
} satisfies ClassPk
}
/**
* 老师打开排名页(和后台「自学情况」)默认看哪个班:自己最近布置过作业的班 → 最近一周交题人数最多的班。
* 不记老师上次选的:统计面板吃过那个亏(上一节课的班悄悄留在框里)。
*/
export async function defaultClassFor(teacherId: number) {
const [lesson] = await db
.select({ className: schema.classLesson.className })
.from(schema.classLesson)
.where(eq(schema.classLesson.createdBy, teacherId))
.orderBy(desc(schema.classLesson.day), desc(schema.classLesson.updatedAt))
.limit(1)
if (lesson) return lesson.className
const users = countDistinct(schema.submission.userId)
const [busiest] = await db
.select({ className: schema.user.className, users })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
rankedStudents,
isNotNull(schema.user.className),
ne(schema.user.className, ""),
gte(schema.submission.createTime, new Date(Date.now() - WEEK_MS).toISOString()),
),
)
.groupBy(schema.user.className)
.orderBy(desc(users))
.limit(1)
if (busiest?.className) return busiest.className
// 放假回来第一次打开:一周里没人交过题,就看最后一个交题的学生是哪个班
const [latest] = await db
.select({ className: schema.user.className })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(and(rankedStudents, isNotNull(schema.user.className), ne(schema.user.className, "")))
.orderBy(desc(schema.submission.createTime))
.limit(1)
return latest?.className ?? null
}
+70
View File
@@ -0,0 +1,70 @@
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"
import { db, schema } from "../db"
/** 「去做几道题」里优先推荐、要标出来的那批题挂的标签 */
export const MUST_DO_TAG = "必会题"
const PROJECT_RE = /\*\*所属项目[::]\s*([^*\n]+)\*\*/
/**
* 从正文第一段的「**所属项目:项目三 · 智慧农业·蜜橘园**」抽出项目名,抽不到给空串。
* 和 0031 迁移回填用的是同一个写法;发布脚本不传 `project` 时靠它。
*/
export function projectFromContent(content: string) {
return (content.match(PROJECT_RE)?.[1] ?? "").trim().slice(0, 64)
}
/**
* 放进 select 列表的关联子查询**一律手写表别名**:drizzle 在 select 列表里渲染列时
* 会省掉表名,子查询里的 `"id"` 要么报 ambiguous,要么就近绑到子查询自己的表上 ——
* 后一种不报错,只是数全错。外层表也写死成 `"problem"` / `"tutorial"`。
*/
/** 这道题带不带「必会题」标签,做成 select 里的一列 */
export const mustDoColumn = sql<boolean>`exists (
select 1 from "problem_tags" pt join "problem_tag" t on t."id" = pt."problemtag_id"
where pt."problem_id" = "problem"."id" and t."name" = ${MUST_DO_TAG}
)`
/** 这道题的全部标签名 */
export const problemTagNames = sql<string[]>`coalesce((
select array_agg(t."name" order by t."name")
from "problem_tags" pt join "problem_tag" t on t."id" = pt."problemtag_id"
where pt."problem_id" = "problem"."id"
), '{}')`
/** 一课有几道练一练 / 挂了几道题 */
export const exerciseCountColumn = sql<number>`(
select count(*) from "exercise" e where e."tutorial_id" = "tutorial"."id"
)`.mapWith(Number)
export const problemCountColumn = sql<number>`(
select count(*) from "tutorial_problem" tp where tp."tutorial_id" = "tutorial"."id"
)`.mapWith(Number)
/**
* 几课各挂了哪些题,按老师排的顺序。`publicOnly` 给学生端用:没公开的题、比赛题不出现
* (老师挑的时候公开、后来藏起来了的,学生点进去只会 404)。
*/
export async function tutorialProblems(tutorialIds: number[], publicOnly: boolean) {
if (tutorialIds.length === 0) return []
return db
.select({
tutorialId: schema.tutorialProblem.tutorialId,
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
mustDo: mustDoColumn,
})
.from(schema.tutorialProblem)
.innerJoin(schema.problem, eq(schema.problem.id, schema.tutorialProblem.problemId))
.where(
and(
inArray(schema.tutorialProblem.tutorialId, tutorialIds),
publicOnly
? and(eq(schema.problem.visible, true), isNull(schema.problem.contestId))
: undefined,
),
)
.orderBy(asc(schema.tutorialProblem.tutorialId), asc(schema.tutorialProblem.order))
}
+77 -36
View File
@@ -66,11 +66,22 @@ export type AdminAnnouncementListItem = z.infer<typeof adminAnnouncementListItem
export const tutorialTypeSchema = z.enum(["python", "c"])
/** 后台一课挂的一道「去做几道题」。按 tutorial_problem.order 排 */
export const adminTutorialProblemSchema = z.object({
id: z.number().int(),
displayId: z.string(),
title: z.string(),
mustDo: z.boolean(),
})
export const adminTutorialSchema = z.object({
id: z.number().int(),
title: z.string(),
content: z.string(),
// 「示例代码」字段界面上已经去掉(正文里的代码块能直接运行了),发布脚本还在传,照收照回
code: z.string().nullable(),
project: z.string(),
problems: z.array(adminTutorialProblemSchema),
isPublic: z.boolean(),
order: z.number().int(),
type: tutorialTypeSchema,
@@ -79,10 +90,18 @@ export const adminTutorialSchema = z.object({
updatedAt: z.string(),
})
export const adminTutorialListItemSchema = adminTutorialSchema.omit({
// 教程正文是整篇 markdown,列表页只排序和切换可见性,不需要它
export const adminTutorialListItemSchema = adminTutorialSchema
.omit({
// 教程正文是整篇 markdown,列表页用不上
content: true,
code: true,
problems: true,
})
.extend({
exerciseCount: z.number().int(),
problemCount: z.number().int(),
/** 打开过这一课的学生数(全部时间),老师判断哪课没人看 */
readers: z.number().int(),
})
/** 后台教程列表按语言分组返回,对齐旧 TutorialAdminAPI.get 的 `{python, c}` 形状 */
@@ -91,10 +110,15 @@ export const adminTutorialGroupsSchema = z.object({
c: z.array(adminTutorialListItemSchema),
})
/**
* `project` 不传就从正文第一段的「**所属项目:…**」抽 —— 发布脚本(docs/*-tutorials/
* publish.js)不认识这个字段,更新时也不能因为它没传就把项目清空。
*/
export const createTutorialRequestSchema = z.object({
title: z.string().trim().min(1).max(128),
content: z.string(),
code: z.string().nullable().default(null),
project: z.string().trim().max(64).optional(),
isPublic: z.boolean().default(false),
order: z.number().int().default(0),
type: tutorialTypeSchema,
@@ -105,6 +129,29 @@ export const setTutorialVisibilityRequestSchema = z.object({
isPublic: z.boolean(),
})
/** 一课挂哪几道题,整串替换,顺序就是数组顺序 */
export const TUTORIAL_PROBLEM_MAX = 6
export const setTutorialProblemsRequestSchema = z.object({
problemIds: z.array(z.number().int().positive()).max(TUTORIAL_PROBLEM_MAX),
})
/** 拖动排序:同一门课的教程 id 按新顺序排好整串交上来 */
export const reorderTutorialsRequestSchema = z.object({
ids: z.array(z.number().int().positive()).min(1).max(500),
})
/** 项目改名:这门课里所有挂在 `from` 下的教程一起改 */
export const renameTutorialProjectRequestSchema = z.object({
type: tutorialTypeSchema,
from: z.string().max(64),
to: z.string().trim().min(1).max(64),
})
/** 后台给一课挑题时的候选:必会题排在前面 */
export const tutorialProblemCandidateSchema = adminTutorialProblemSchema.extend({
tags: z.array(z.string()),
})
export const exerciseTypeSchema = z.enum([
"mcq",
"sort",
@@ -512,29 +559,36 @@ export const acTrendSchema = z.object({
// ---------------------------------------------------------------- 自学情况
/**
* 「自学情况」按学生的一行。**没读过任何一课的学生也要有一行**(readCount = 0)——
* 这张表首要回答的问题是「谁还没开始」,只列有记录的人等于把该看的人筛掉了。
* 「自学情况」的方块格:一个班的学生 × 这门课的每一课。
*
* **没打开过任何一课的学生也要有一行**(`opened` 为空)—— 页面上把他们收成一串名单,
* 这张表首要回答的就是「谁还没开始」。`done` 是学完的课(练一练全对,见 `isLessonDone`),
* 一定也在 `opened` 里或者是没打开却把练一练做完了的(老链接直达练习的情况)。
*/
export const learnStudentProgressSchema = z.object({
export const learnBoardStudentSchema = z.object({
userId: z.number().int(),
username: z.string(),
realName: z.string().nullable(),
className: z.string().nullable(),
readCount: z.number().int(),
opened: z.array(z.number().int()),
done: z.array(z.number().int()),
exerciseSolved: 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),
export const learnBoardSchema = z.object({
/** 实际看的是哪个班。老师没选时后端按排名页同一套规则猜,猜不出是 null */
className: z.string().nullable(),
tutorials: z.array(
z.object({
id: z.number().int(),
title: z.string(),
project: z.string(),
exerciseTotal: z.number().int(),
}),
),
students: z.array(learnBoardStudentSchema),
})
/**
@@ -577,22 +631,6 @@ export const learnExerciseAttemptSchema = z.object({
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()
@@ -905,10 +943,8 @@ 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 LearnBoard = z.infer<typeof learnBoardSchema>
export type LearnBoardStudent = z.infer<typeof learnBoardStudentSchema>
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
export type LearnExerciseProgressList = z.infer<typeof learnExerciseProgressListSchema>
export type LearnExerciseAttempt = z.infer<typeof learnExerciseAttemptSchema>
@@ -932,6 +968,11 @@ export type TutorialType = z.infer<typeof tutorialTypeSchema>
export type AdminTutorial = z.infer<typeof adminTutorialSchema>
export type AdminTutorialListItem = z.infer<typeof adminTutorialListItemSchema>
export type AdminTutorialGroups = z.infer<typeof adminTutorialGroupsSchema>
export type AdminTutorialProblem = z.infer<typeof adminTutorialProblemSchema>
export type TutorialProblemCandidate = z.infer<typeof tutorialProblemCandidateSchema>
export type SetTutorialProblemsRequest = z.infer<typeof setTutorialProblemsRequestSchema>
export type ReorderTutorialsRequest = z.infer<typeof reorderTutorialsRequestSchema>
export type RenameTutorialProjectRequest = z.infer<typeof renameTutorialProjectRequestSchema>
export type CreateTutorialRequest = z.infer<typeof createTutorialRequestSchema>
export type UpdateTutorialRequest = z.infer<typeof updateTutorialRequestSchema>
export type SetTutorialVisibilityRequest = z.infer<typeof setTutorialVisibilityRequestSchema>
+34 -9
View File
@@ -63,14 +63,31 @@ export const reactionStateSchema = z.object({
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(),
code: z.string().nullable(),
problems: z.array(tutorialProblemSchema),
isPublic: z.boolean(),
order: z.number().int(),
type: z.enum(["python", "c"]),
@@ -173,6 +190,8 @@ export const exerciseSchema = z.object({
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 Message = z.infer<typeof messageSchema>
@@ -188,20 +207,26 @@ export type ReactionCounts = z.infer<typeof reactionCountsSchema>
export type ReactionState = z.infer<typeof reactionStateSchema>
export type SetReactionRequest = z.infer<typeof setReactionRequestSchema>
export type Tutorial = z.infer<typeof tutorialSchema>
export type TutorialProblem = z.infer<typeof tutorialProblemSchema>
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>
/**
* 「已读」的门槛:累计停留满 3 分钟才算读过这一课。
* 「学完一课」的口径:**这课的练一练全做对**。没有练一练的课,打开过就算。
*
* 之前只要打开过(`viewCount > 0`)就记成已读,于是「点开看一眼就退」和「认真读完」
* 在老师那张表上长得一模一样,「已读 17/17」并不说明他学了。3 分钟是按最短的一课
* 定的下限——读不完还翻不动的课文,扫一眼是到不了这个数的。
* 原来按「累计停留满 3 分钟」算已读,学生看不见这条规则,只看见圆点变黄;而快照里
* 停留时长中位数是 0 秒 —— 一节课上十几分钟点开 17 课的大有人在。练一练是学生
* 自己做的、看得见的,「全对了」比「待够了」说得清。停留时长照样记,只给老师看。
*
* 门槛只管**「已读」这个口径**(学生端的 ✓、后台的已读课数与读过的人数),
* 不动累计时长:时长记的是真实停留,不满 3 分钟的那些秒数照样算进去,
* 「已读 0 课、累计 25 分钟」正是要让老师看见的一种情况。
* 前端目录、首页、后台「自学情况」都走这一个函数,别各写一份。
*/
export const TUTORIAL_READ_SECONDS = 180
export function isLessonDone(progress: {
viewCount: number
exerciseTotal: number
exerciseSolved: number
}) {
if (progress.exerciseTotal > 0) return progress.exerciseSolved >= progress.exerciseTotal
return progress.viewCount > 0
}
+3 -1
View File
@@ -558,7 +558,8 @@ export const TRIAL_ADMIN_MAX_CASES = 50
* 试运行:「运行例子」「自己输入」、后台「生成测试点」。走本站判题机,不落库、不算提交。
*
* - 带 `problemId`:按这道题的时间 / 内存限制和代码模板跑,和提交一个口径;
* - 不带:只有管理员能这么调(后台生成测试点时题目可能还没存)。
* - 带 `tutorialId`:自学教程正文里的代码块「运行」,只能跑一组、限制用默认的;
* - 都不带:只有管理员能这么调(后台生成测试点时题目可能还没存)。
*
* `output` 是这组的正确输出,给了就由判题机比对(和提交一样去掉末尾空白再比),
* 结果是通过 / 答案错误;不给(「自己输入」、生成测试点)就只看有没有跑出错。
@@ -566,6 +567,7 @@ export const TRIAL_ADMIN_MAX_CASES = 50
export const trialRunRequestSchema = z.object({
problemId: z.number().int().positive().optional(),
contestId: z.number().int().positive().optional(),
tutorialId: z.number().int().positive().optional(),
language: runnableLanguageSchema,
code: z
.string()