Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
import {
|
|
STUDENT_ROLES,
|
|
isLessonDone,
|
|
type LearnBoard,
|
|
type LearnExerciseAttempt,
|
|
type LearnExerciseProgress,
|
|
type LearnExerciseProgressList,
|
|
} 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 { defaultClassFor } from "../../services/ranking"
|
|
import { exerciseCountColumn } from "../../services/tutorial"
|
|
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),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 方块格:一个班的学生 × 这门课的每一课,打开过没有、学完没有(练一练全对)。
|
|
*
|
|
* 不选班就猜老师在教哪个班(`defaultClassFor`);也能传年级前缀,
|
|
* 但一个年级两三百行方块,页面上主要还是按班看。
|
|
*/
|
|
adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
|
|
const type = tutorialTypeOf(c.req.query("type"))
|
|
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,
|
|
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)
|
|
|
|
const students = await db
|
|
.select({
|
|
userId: schema.user.id,
|
|
username: schema.user.username,
|
|
realName: schema.userProfile.realName,
|
|
className: schema.user.className,
|
|
})
|
|
.from(schema.user)
|
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
|
.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,
|
|
tutorialId: schema.exercise.tutorialId,
|
|
solved: count(),
|
|
})
|
|
.from(schema.exerciseAttempt)
|
|
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId))
|
|
.where(
|
|
and(
|
|
inArray(schema.exerciseAttempt.userId, userIds),
|
|
inArray(schema.exercise.tutorialId, tutorialIds),
|
|
eq(schema.exerciseAttempt.solved, true),
|
|
),
|
|
)
|
|
.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, {
|
|
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)
|
|
})
|
|
|
|
/**
|
|
* 按练习:哪道练一练卡住了全班。
|
|
*
|
|
* 一道题一行,含做过/做对的人数、做对的人平均试了几次、一次就做对的人数。
|
|
* 没人做过的题也在列表里(一行零)—— 「这道题全班没一个人碰」同样是要看见的。
|
|
*/
|
|
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[])
|
|
})
|