Files
OJ2/apps/api/src/routes/admin/learn.ts
T
xuyueandClaude Opus 5 f90d01338e
Deploy / deploy (push) Has been cancelled
feat(自学情况): 按学生加姓名/学号搜索,后端补稳定排序
搜索是纯前端过滤(整表本来就一次拉完),和班级筛选可以叠加。

学生查询原来没有 orderBy,前端默认按「已读」升序排之后,同分的一大批
(尤其一堆 0)落回聚合的任意顺序,刷新一次换一个样;按班级、学号兜底。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:23:01 -06:00

387 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
STUDENT_ROLES,
TUTORIAL_READ_SECONDS,
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"
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>()
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,
// 「已读」按 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)),
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, {
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,
),
} 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))
return success(c, {
studentCount,
results: rows.map(
({ readSeconds, ...row }) =>
({
...row,
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
}) satisfies LearnTutorialProgress,
),
} satisfies LearnTutorialProgressList)
},
)
/**
* 按练习:哪道练一练卡住了全班。
*
* 一道题一行,含做过/做对的人数、做对的人平均试了几次、一次就做对的人数。
* 没人做过的题也在列表里(一行零)—— 「这道题全班没一个人碰」同样是要看见的。
*/
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, {
studentCount: studentCountRow?.value ?? 0,
results: rows.map(
(row) =>
({
...row,
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
}) satisfies LearnExerciseProgress,
),
} satisfies LearnExerciseProgressList)
},
)
/** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */
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 satisfies LearnExerciseAttempt[])
},
)