fix(题目页): 统计页签的老师版回到讲这道题本身,各班只列一张表,不选班、不列人名
Deploy / deploy (push) Canceled after 0s

按人看谁没做对是提交页「数据统计」的活;表里点一个班就去那边看。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-30 00:50:01 +08:00
1 parent 2fddf4a011
commit db493cebe4
5 files changed
+151 -324

No files matched your search

+42 -77
View File
@@ -1,5 +1,5 @@
import { type ProblemClassDetail, type ProblemStats } from "@oj2/contract"
import { and, eq, isNotNull, sql } from "drizzle-orm"
import { type ProblemStats } from "@oj2/contract"
import { and, count, eq, inArray, isNotNull, sql } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware"
@@ -9,7 +9,7 @@ import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/s
import { canAccessContest, contestDetailsAllowed, findAccessibleContest } from "../services/contest"
import { accepted } from "../services/learning-stats"
import { localTime } from "../time"
import { isAdminRole, isTeacherOrAbove, queryInteger, stripClassPrefix } from "./helpers"
import { isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
export const problemStatsRoutes = new Hono<AppEnv>()
@@ -29,7 +29,6 @@ const EMPTY: ProblemStats = {
me: null,
myClass: null,
classes: null,
classDetail: null,
}
// type 而不是 interface:db.execute 的泛型约束是 Record<string, unknown>,interface 不带索引签名
@@ -41,14 +40,13 @@ type UserRow = {
/** 做对之前(含做对那次)交了几次;没做对就是全部次数 */
tries: number
solved: boolean
last_result: JudgeStatusValue
last_time: string
}
/**
* 题目页「统计」页签。id 是内部题号(problem.id):比赛题的展示题号是 1、2、3,
* 拿 displayId 查会撞上同号的公开题(原来的「历年 AC 率」就是这么查错的)。
* `className` 只有老师用:选中哪个班看明细,不填就是最近做过这题的那个班。
* 题目页「统计」页签:讲的是**这道题**(难不难、几次做对、常错在哪),老师多一张各班的表。
* 看谁没做对是提交页「数据统计」的活,这里只到班级这一层。
* id 是内部题号(problem.id):比赛题的展示题号是 1、2、3,拿 displayId 查会撞上
* 同号的公开题(原来的「历年 AC 率」就是这么查错的)。
*/
problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
const user = c.get("user")
@@ -93,9 +91,7 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
select s.user_id, u.username, u.class_name,
count(*)::int as attempts,
count(*) filter (where s.first_ac is null or s.create_time <= s.first_ac)::int as tries,
(min(s.first_ac) is not null) as solved,
(array_agg(s.result order by s.create_time desc))[1] as last_result,
max(s.create_time) as last_time
(min(s.first_ac) is not null) as solved
from (
select user_id, result, create_time,
min(create_time) filter (where result in (${acceptedList}))
@@ -157,7 +153,6 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
const classmates = myClassName ? users.filter((row) => row.class_name === myClassName) : []
let classes: ProblemStats["classes"] = null
let classDetail: ProblemClassDetail | null = null
if (!inContest && isTeacherOrAbove(user)) {
const byClass = new Map<string, UserRow[]>()
for (const row of users) {
@@ -172,27 +167,41 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
peak.set(row.className!, { day: row.day, users: row.users })
}
}
const lessonClasses = [...byClass]
.filter(([className]) => (peak.get(className)?.users ?? 0) >= CLASS_MIN_TRIED)
.map(([className, rows]) => ({
className,
tried: rows.length,
solved: rows.filter((row) => row.solved).length,
day: peak.get(className)!.day,
}))
.sort((a, b) => b.day.localeCompare(a.day))
const lessonClasses = [...byClass].filter(
([className]) => (peak.get(className)?.users ?? 0) >= CLASS_MIN_TRIED,
)
// 花名册只数学生:老师、助教也可能挂着班级
const sizes = lessonClasses.length
? await db
.select({ className: schema.user.className, value: count() })
.from(schema.user)
.where(
and(
inArray(
schema.user.className,
lessonClasses.map(([className]) => className),
),
eq(schema.user.adminType, "Regular User"),
eq(schema.user.isDisabled, false),
),
)
.groupBy(schema.user.className)
: []
classes = lessonClasses
// 地址里指定的班不在列表里(零星几个人做过)也照样给明细
const wanted = c.req.query("className")?.trim()
const selected = wanted && byClass.has(wanted) ? wanted : lessonClasses[0]?.className
if (selected) {
classDetail = await buildClassDetail(
selected,
peak.get(selected)!.day,
byClass.get(selected)!,
failureRows,
)
}
.map(([className, rows]) => {
const top = failureRows
.filter((row) => row.class_name === className)
.sort((a, b) => b.n - a.n)[0]
return {
className,
day: peak.get(className)!.day,
tried: rows.length,
solved: rows.filter((row) => row.solved).length,
classSize: sizes.find((row) => row.className === className)?.value ?? 0,
topFailure: top ? { result: top.result, count: top.n } : null,
}
})
.sort((a, b) => b.day.localeCompare(a.day))
}
return success(c, {
@@ -211,49 +220,5 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => {
}
: null,
classes,
classDetail,
} satisfies ProblemStats)
})
async function buildClassDetail(
className: string,
day: string,
rows: UserRow[],
failureRows: { class_name: string | null; result: JudgeStatusValue; n: number }[],
): Promise<ProblemClassDetail> {
// 花名册只数学生:老师、助教也可能挂着班级
const roster = await db
.select({ id: schema.user.id, username: schema.user.username })
.from(schema.user)
.where(
and(
eq(schema.user.className, className),
eq(schema.user.adminType, "Regular User"),
eq(schema.user.isDisabled, false),
),
)
const triedIds = new Set(rows.map((row) => row.user_id))
const name = (username: string | null) => stripClassPrefix(username ?? "", className)
return {
className,
roster: roster.length,
day,
failures: failureRows
.filter((row) => row.class_name === className)
.map((row) => ({ result: row.result, count: row.n }))
.sort((a, b) => b.count - a.count),
unsolved: rows
.filter((row) => !row.solved)
.sort((a, b) => b.attempts - a.attempts)
.map((row) => ({
realName: name(row.username),
attempts: row.attempts,
lastResult: row.last_result,
})),
solved: rows
.filter((row) => row.solved)
.sort((a, b) => a.last_time.localeCompare(b.last_time))
.map((row) => name(row.username)),
untouched: roster.filter((row) => !triedIds.has(row.id)).map((row) => name(row.username)),
}
}