diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 790be9e..4e2af34 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -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)。 diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index 0e48cd0..d64bf28 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -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, ), ) diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index 5d3176c..b269902 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -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() @@ -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!)) diff --git a/apps/web/src/oj/statistics/components/ByClass.vue b/apps/web/src/oj/statistics/components/ByClass.vue index 2bbd846..0ecf9d8 100644 --- a/apps/web/src/oj/statistics/components/ByClass.vue +++ b/apps/web/src/oj/statistics/components/ByClass.vue @@ -1,6 +1,6 @@