feat(统计): 没填班级的学生也能和班级一样看——班级下拉多一项「没填班级」,一起上的课能切出来、点进去看小方块;零散提交也能点了

- 班级筛选多一个取值 NO_CLASS("none"),统计、方块串、流程图、提交列表都认;只圈普通学生,
  老师管理员的号本来就不填班级,不挡的话试题的提交会算进去
- 切课时没填班级的号合在一起当一个班切(原来 class_name is null 直接排除,7 月 4 日那两节课看不到)
- 没填班级不是真班:不列「还没交」,课上只写几人不写几人之几
- 「另有零散提交」每一项可点,进那个班那一整天

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-30 12:18:06 +08:00
1 parent c2ef55b10b
commit 923f3efeca
10 files changed
+153 -66

No files matched your search

+10 -5
View File
@@ -10,6 +10,7 @@ import {
type FlowchartListItem,
type FlowchartStatistics,
type FlowchartSubmission,
NO_CLASS,
} from "@oj2/contract"
import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
import { Hono } from "hono"
@@ -27,6 +28,7 @@ import { buildWordFrequencies } from "../services/word-frequency"
import { dayStart } from "../time"
import { unknownDisplayIds } from "./submission-statistics"
import {
classCondition,
isAdminRole,
matchedUsers,
asRecord,
@@ -208,7 +210,7 @@ async function flowchartClassFilter(className: string) {
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.className, className))
.where(classCondition(className))
return users.length
? inArray(
schema.flowchartSubmission.userId,
@@ -422,10 +424,13 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
}
const where = and(...filters)
// 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。
// 未禁用的普通用户才进分母,教师和管理员不算
const roster = scoped
? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
: []
// 未禁用的普通用户才进分母,教师和管理员不算。
// 「没填班级」不是一个真的班(两百来个夏令营、自己注册的号混在一起),「谁没做」列出来全是
// 不相干的人,当没有花名册
const roster =
scoped && c.req.query("className")?.trim() !== NO_CLASS
? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
: []
/**
* 五条查询,每条的代价都和窗口里的行数脱钩(词云那条卡了 limit)。
+16 -3
View File
@@ -1,6 +1,6 @@
import { ADMIN_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
import { ADMIN_ROLES, NO_CLASS, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
import { and, count, eq, ilike, notInArray, sql } from "drizzle-orm"
import { and, count, eq, ilike, isNull, notInArray, sql } from "drizzle-orm"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
@@ -159,6 +159,19 @@ export async function countFailedSubmissions(userId: number, problemId: number)
* 代码提交和流程图两条统计都走这里。流程图那张表连冻结用户名都没有(只有
* `user_id`),更是只能从这儿拿 id。
*/
/**
* 「这个班的人」:`user.class_name` 精确匹配;NO_CLASS 是没填班级的学生(class_name 为 null)。
* 统计、提交列表、流程图按班级筛都走它,没填班级的才能和普通班一样被圈出来。
*
* NO_CLASS 只圈普通用户:老师、管理员的号本来就都不填班级,不挡的话老师自己试题的提交
* 会混进「没填班级」,还算进做完的人数里(切课那边也只算普通学生,两边口径一致)
*/
export function classCondition(className: string) {
return className === NO_CLASS
? and(isNull(schema.user.className), eq(schema.user.adminType, "Regular User"))
: eq(schema.user.className, className)
}
/**
* 统计接口「圈哪些人」:班级按 `user.class_name` **精确**匹配,学生名按包含匹配,两个都给
* 就取交集,都没给返回 null(不圈人)。
@@ -179,7 +192,7 @@ export async function scopedUsers(username: string | undefined, className: strin
.from(schema.user)
.where(
and(
className ? eq(schema.user.className, className) : undefined,
className ? classCondition(className) : undefined,
username ? ilike(schema.user.username, `%${username}%`) : undefined,
),
)
+30 -8
View File
@@ -7,6 +7,7 @@
*/
import {
NO_CLASS,
type ProblemLanguage,
type SubmissionLessons,
type SubmissionStatistics,
@@ -23,7 +24,14 @@ import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/s
import { type ContestEnv } from "../services/contest"
import { getBooleanOption } from "../services/options"
import { dayStart, localTime } from "../time"
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers"
import {
classCondition,
isAdminRole,
parseDisplayIds,
rounded,
scopedUsers,
stripClassPrefix,
} from "./helpers"
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
@@ -240,7 +248,7 @@ export async function classNameFilter(className: string) {
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.className, className))
.where(classCondition(className))
return users.length
? inArray(
schema.submission.userId,
@@ -381,7 +389,7 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
}
const where = and(...filters)
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
const rosterRows = matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
const enrolled = matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
@@ -474,6 +482,13 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
const doneCount = perUser.filter(isDone).length
const submittedUserIds = new Set(perUser.map((row) => row.userId))
/**
* 「没填班级」不是一个真的班:两百来个夏令营、兴趣班、自己注册的号混在一起,「还没交」
* 列出来全是不相干的人,分母也跟着失真。花名册只取这段时间交过的人 —— 「交了没对」照常有,
* 「还没交」为空,和不选班时一样
*/
const rosterRows =
className === NO_CLASS ? enrolled.filter((row) => submittedUserIds.has(row.id)) : enrolled
const data = perUser.map((row) => ({
username: row.username,
@@ -755,7 +770,7 @@ submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher
select class_name, day, sid
from (
select class_name, day, sid, problem_id, count(distinct user_id) as n
from seg where class_name is not null
from seg
group by class_name, day, sid, problem_id
) p
group by class_name, day, sid
@@ -765,7 +780,8 @@ submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher
select seg.*, (l.sid is not null) as lesson
from seg
left join lesson_seg l
on l.class_name = seg.class_name and l.day = seg.day and l.sid = seg.sid
-- 没填班级的号(class_name 为 null)合在一起当一个班切,= 会把它们全漏掉
on l.class_name is not distinct from seg.class_name and l.day = seg.day and l.sid = seg.sid
)
select grouping(class_name, day, sid, problem_id, result, lesson) as level,
class_name, day, sid::int as sid, problem_id, result, lesson,
@@ -866,7 +882,9 @@ submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher
: []
const problemById = new Map(problems.map((row) => [row.id, row]))
const lessonClasses = [...new Set(picked.map((row) => row.class_name!))]
const lessonClasses = [
...new Set(picked.flatMap((row) => (row.class_name === null ? [] : [row.class_name]))),
]
const sizes = lessonClasses.length
? await db
.select({ className: schema.user.className, value: count() })
@@ -889,12 +907,16 @@ submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher
const hardest = [...own].sort((a, b) => failedOf(b) - failedOf(a))[0]
const hardestProblem = hardest ? problemById.get(hardest.problem_id!) : undefined
return {
className: lesson.class_name!,
className: lesson.class_name,
day: lesson.day!,
start: lesson.first,
end: lesson.last,
userCount: lesson.users,
classSize: sizes.find((row) => row.className === lesson.class_name)?.value ?? 0,
// 没填班级的谈不上花名册,给 0(界面上就只写「几人」)
classSize:
lesson.class_name === null
? 0
: (sizes.find((row) => row.className === lesson.class_name)?.value ?? 0),
// 点进去就拿这几道去筛:统计接口只认公开题,一次最多 20 道
problems: own
.map((row) => problemById.get(row.problem_id!))