feat(自学情况): 看得到配套题做没做——每人一列、每课一个页签、学生明细里逐题列出
Deploy / deploy (push) Canceled after 0s

- 方块格多一列「配套题」:他打开过的那些课挂的题,做对几道 / 一共几道;格子提示里也带上
- 右栏分「练一练 / 配套题」两个页签:每道题读过这课的人里几人做对,点开看谁没交、谁交了没对,
  一键跳到这个人这道题的提交
- 学生明细每课多一段配套题:做对没有、交了几次、最后哪天交的
- 做对不论早晚,读教程之前在课上做对的也算;口径同学生端课文里那个勾(代码 AC 或流程图 A / S)
- 1366 宽下 C 语言 20 课也不用横着滑了:「最后学习」只留日期,几列收窄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-16 12:21:34 +08:00
1 parent 7839a65f59
commit 1ebb23cdac
5 files changed
+474 -33

No files matched your search

+113 -3
View File
@@ -1,4 +1,5 @@
import {
FLOWCHART_PASS_GRADES,
STUDENT_ROLES,
isLessonDone,
type LearnBoard,
@@ -14,8 +15,9 @@ import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { accepted } from "../../services/learning-stats"
import { defaultClassFor } from "../../services/ranking"
import { exerciseCountColumn } from "../../services/tutorial"
import { exerciseCountColumn, tutorialProblems } from "../../services/tutorial"
import { queryInteger, rounded } from "../helpers"
/**
@@ -65,6 +67,59 @@ function studentCondition(value: string | null) {
)
}
/**
* 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数,
* 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、
* 课堂看板(routes/classroom.ts 的 activityRows)同一个口径。不论早晚:读这课之前
* 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。
*/
async function problemActivity(userIds: number[], problemIds: number[]) {
if (!userIds.length || !problemIds.length) return []
const code = db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
createTime: schema.submission.createTime,
solved: sql<boolean>`${inArray(schema.submission.result, accepted)}`.as("solved"),
})
.from(schema.submission)
.where(
and(
isNull(schema.submission.contestId),
inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, problemIds),
),
)
const flowchart = db
.select({
userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId,
createTime: schema.flowchartSubmission.createTime,
solved: sql<boolean>`${and(
eq(schema.flowchartSubmission.status, 2),
inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES]),
)}`.as("solved"),
})
.from(schema.flowchartSubmission)
.where(
and(
inArray(schema.flowchartSubmission.userId, userIds),
inArray(schema.flowchartSubmission.problemId, problemIds),
),
)
const activity = code.unionAll(flowchart).as("activity")
return db
.select({
userId: activity.userId,
problemId: activity.problemId,
solved: sql<boolean>`bool_or(${activity.solved})`,
submissions: count(),
lastSubmitAt: sql<string>`max(${activity.createTime})`,
})
.from(activity)
.groupBy(activity.userId, activity.problemId)
}
/**
* 方块格:一个班的学生 × 这门课的每一课,打开过没有、学完没有(练一练全对)。
*
@@ -87,6 +142,18 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
.from(schema.tutorial)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.orderBy(asc(schema.tutorial.order))
.then(async (rows) => {
const problems = await tutorialProblems(
rows.map((row) => row.id),
true,
)
return rows.map((row) => ({
...row,
problems: problems
.filter((problem) => problem.tutorialId === row.id)
.map(({ id, displayId, title }) => ({ id, displayId, title })),
}))
})
const tutorialIds = tutorials.map((row) => row.id)
if (!className)
return success(c, { className: null, tutorials, students: [] } satisfies LearnBoard)
@@ -104,7 +171,15 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
.orderBy(asc(schema.user.className), asc(schema.user.username))
const userIds = students.map((row) => row.userId)
// 阅读和练习分两条查再拼:两个一对多挂在同一张表上 join,count 全是错的
// 阅读、练习、配套题分开查再拼:几个一对多挂在同一张表上 join,count 全是错的
const problemIds = [...new Set(tutorials.flatMap((row) => row.problems.map((p) => p.id)))]
const problemRows = await problemActivity(userIds, problemIds)
const problemsOf = new Map<number, LearnBoardStudent["problems"]>()
for (const { userId, ...row } of problemRows) {
const list = problemsOf.get(userId) ?? []
list.push(row)
problemsOf.set(userId, list)
}
const [progressRows, solvedRows] =
userIds.length && tutorialIds.length
? await Promise.all([
@@ -185,7 +260,16 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
}
if (isLessonDone(lesson)) done.push(tutorial.id)
}
return { ...student, opened, done, exerciseSolved, lessons, totalSeconds, lastViewedAt }
return {
...student,
opened,
done,
exerciseSolved,
lessons,
problems: problemsOf.get(student.userId) ?? [],
totalSeconds,
lastViewedAt,
}
}),
} satisfies LearnBoard)
})
@@ -378,6 +462,19 @@ adminLearnRoutes.get("/learn-analytics/students/:id", requireTeacher, async (c)
.orderBy(asc(schema.exercise.order)),
])
const related = await tutorialProblems(
tutorials.map((row) => row.tutorialId),
true,
)
const activity = new Map(
(
await problemActivity(
[id],
related.map((row) => row.id),
)
).map((row) => [row.problemId, row]),
)
const byLesson = new Map<number, LearnStudentDetail["lessons"][number]["exercises"]>()
for (const { tutorialId, ...row } of exercises) {
const list = byLesson.get(tutorialId) ?? []
@@ -399,6 +496,19 @@ adminLearnRoutes.get("/learn-analytics/students/:id", requireTeacher, async (c)
totalSeconds: row.totalSeconds ?? 0,
lastViewedAt: row.lastViewedAt,
exercises: byLesson.get(row.tutorialId) ?? [],
problems: related
.filter((problem) => problem.tutorialId === row.tutorialId)
.map(({ id: problemId, displayId, title }) => {
const done = activity.get(problemId)
return {
id: problemId,
displayId,
title,
solved: done?.solved ?? false,
submissions: done?.submissions ?? 0,
lastSubmitAt: done?.lastSubmitAt ?? null,
}
}),
}))
.filter((row) => row.viewCount > 0 || row.exercises.some((ex) => ex.attempts > 0)),
} satisfies LearnStudentDetail)