diff --git a/apps/api/src/routes/problem-stats.ts b/apps/api/src/routes/problem-stats.ts index 3d62359..7a065f4 100644 --- a/apps/api/src/routes/problem-stats.ts +++ b/apps/api/src/routes/problem-stats.ts @@ -1,5 +1,5 @@ import { type ProblemStats } from "@oj2/contract" -import { and, count, eq, inArray, isNotNull, sql } from "drizzle-orm" +import { and, count, eq, inArray, sql } from "drizzle-orm" import { Hono } from "hono" import { optionalAuth, type AppEnv } from "../auth/middleware" @@ -8,17 +8,10 @@ import { failure, success } from "../http" import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status" import { canAccessContest, contestDetailsAllowed, findAccessibleContest } from "../services/contest" import { accepted } from "../services/learning-stats" -import { localTime } from "../time" import { isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers" export const problemStatsRoutes = new Hono() -/** - * 同一天班里至少这么多人交过,才算「这个班一起做过这题」。只看总人数不行: - * 2079 有个班 2 月里零星补做了 6 个人,比 12 月全班 26 人做的那次还「近」,默认就选成了它 - */ -const CLASS_MIN_TRIED = 5 - const EMPTY: ProblemStats = { locked: false, tried: 0, @@ -85,8 +78,7 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => { sql`, `, ) // 每人一行:窗口函数先给每条提交标上这个人第一次做对的时刻,再按人数到那一刻为止交了几次 - const day = sql`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')` - const [users, failureRows, firstCase, classDays] = await Promise.all([ + const [users, failureRows, firstCase] = await Promise.all([ db.execute(sql` select s.user_id, u.username, u.class_name, count(*)::int as attempts, @@ -121,17 +113,6 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => { where e->>'test_case' = '1' and (e->>'result')::int <> 0 ) `), - // 每个班每天几个人交过,取人最多的那天当「这个班做这题的那天」 - db - .select({ - className: schema.user.className, - day, - users: sql`count(distinct ${schema.submission.userId})::int`, - }) - .from(schema.submission) - .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) - .where(and(eq(schema.submission.problemId, problem.id), isNotNull(schema.user.className))) - .groupBy(schema.user.className, day), ]) const solvedUsers = users.filter((row) => row.solved) @@ -153,56 +134,7 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => { const classmates = myClassName ? users.filter((row) => row.class_name === myClassName) : [] let classes: ProblemStats["classes"] = null - if (!inContest && isTeacherOrAbove(user)) { - const byClass = new Map() - for (const row of users) { - if (!row.class_name) continue - byClass.set(row.class_name, [...(byClass.get(row.class_name) ?? []), row]) - } - const peak = new Map() - for (const row of classDays) { - const current = peak.get(row.className!) - const later = current && row.users === current.users && row.day > current.day - if (!current || row.users > current.users || later) { - peak.set(row.className!, { day: row.day, users: row.users }) - } - } - 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 - .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)) - } + if (!inContest && isTeacherOrAbove(user)) classes = await classRows(problem.id) return success(c, { locked: false, @@ -222,3 +154,69 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => { classes, } satisfies ProblemStats) }) + +/** + * 各班一行,和数据统计「按班级汇总」(statistics/components/ByClass.vue)同一个口径: + * 只算学生角色、没禁用的号,班级人数也这么数。那边是前端拿逐条提交现算的(最多 5000 条), + * 这里在库里直接聚合,数是一样的。 + */ +async function classRows(problemId: number): Promise> { + const acceptedList = sql.join( + accepted.map((value) => sql`${value}`), + sql`, `, + ) + const unjudgedList = sql.join( + UNJUDGED_RESULTS.map((value) => sql`${value}`), + sql`, `, + ) + const rows = await db.execute<{ + class_name: string | null + tried: number + solved: number + accepted: number + judged: number + last_time: string + }>(sql` + select u.class_name, + count(distinct s.user_id)::int as tried, + count(distinct s.user_id) filter (where s.result in (${acceptedList}))::int as solved, + count(*) filter (where s.result in (${acceptedList}))::int as accepted, + count(*) filter (where s.result not in (${unjudgedList}))::int as judged, + max(s.create_time) as last_time + from ${schema.submission} s + join ${schema.user} u on u.id = s.user_id + where s.problem_id = ${problemId} and s.contest_id is null + and u.admin_type = 'Regular User' and u.is_disabled = false + group by u.class_name + `) + const names = rows.map((row) => row.class_name).filter((name) => name !== null) + const sizes = names.length + ? await db + .select({ className: schema.user.className, value: count() }) + .from(schema.user) + .where( + and( + inArray(schema.user.className, names), + eq(schema.user.adminType, "Regular User"), + eq(schema.user.isDisabled, false), + ), + ) + .groupBy(schema.user.className) + : [] + return rows + .map((row) => { + const classSize = + (row.class_name && sizes.find((item) => item.className === row.class_name)?.value) || + row.tried + return { + className: row.class_name, + classSize, + solved: row.solved, + unsolved: row.tried - row.solved, + untouched: Math.max(0, classSize - row.tried), + correctRate: row.judged ? Math.round((row.accepted / row.judged) * 100) : null, + lastTime: row.last_time, + } + }) + .sort((a, b) => b.lastTime.localeCompare(a.lastTime)) +} diff --git a/apps/web/src/oj/problem/components/ProblemInfo.vue b/apps/web/src/oj/problem/components/ProblemInfo.vue index 3f5fa54..a926e1f 100644 --- a/apps/web/src/oj/problem/components/ProblemInfo.vue +++ b/apps/web/src/oj/problem/components/ProblemInfo.vue @@ -6,7 +6,7 @@ import { getProblemStats } from "oj/api" import { useContestStore } from "oj/store/contest" import { useProblemStore } from "oj/store/problem" import { useTone, type Tone } from "oj/submission/composables/tone" -import { classLabel } from "oj/submission/utils" +import { classLabel, submissionDayText } from "oj/submission/utils" import { DIFFICULTY, JUDGE_STATUS } from "utils/constants" import { getTagColor, parseTime } from "utils/functions" import type { ProblemStats, SUBMISSION_RESULT } from "utils/types" @@ -130,6 +130,15 @@ const stuck = computed(() => { // ---------- 老师:各班的表 ---------- +/** 班多的老题(1001 这种十几个班都做过)先给 6 个,最近做的在前 */ +const allClasses = ref(false) +const shownClasses = computed(() => + allClasses.value ? (stats.value?.classes ?? []) : (stats.value?.classes ?? []).slice(0, 6), +) +const hiddenClasses = computed( + () => (stats.value?.classes?.length ?? 0) - shownClasses.value.length, +) + /** 点一个班、或者表下面那行:去提交页的「数据统计」按人看(新标签,题目页不动) */ function openStatistics(className?: string) { const href = router.resolve({ @@ -139,12 +148,6 @@ function openStatistics(className?: string) { window.open(href, "_blank") } -/** 「2025-12-05」→「2025年12月5日」。后端给的已经是东八区的日子,不用再换算 */ -function dayText(day: string) { - const [year, month, date] = day.split("-").map(Number) - return `${year}年${month}月${date}日` -} - function segmentWidth(value: number, total: number) { return total ? `${(value / total) * 100}%` : "0" } @@ -270,60 +273,64 @@ function segmentWidth(value: number, total: number) { - -
-
- 各班做得怎样 - - 同一天 5 人以上交过才算这个班做过 -
- + +
+ 各班做得怎样 +
- - + + - + + - - - + + + +
班级哪天做的做对 / 交过做完交了没对 没交错得最多正确率最近一次
- {{ classLabel(item.className) }} + {{ item.className ? classLabel(item.className) : "没有班级" }} - {{ dayText(item.day) }} - - {{ item.solved }} - / {{ item.tried }} - - {{ Math.max(0, item.classSize - item.tried) }} - - - {{ resultName(item.topFailure.result) }} {{ item.topFailure.count }} + + - — + {{ item.solved }}/{{ item.classSize }} + + {{ item.unsolved }} + + {{ item.untouched }} + + {{ + item.correctRate === null ? "—" : `${item.correctRate}%` + }} + + {{ submissionDayText(item.lastTime) }}
- 还没有哪个班一起做过这道题
+ + 还有 {{ hiddenClasses }} 个班 › + + 按人看谁没做对,去数据统计 @@ -575,6 +582,7 @@ function segmentWidth(value: number, total: number) { } .classes th { + white-space: nowrap; text-align: left; font-weight: normal; color: v-bind("theme.textColor3"); @@ -588,11 +596,22 @@ function segmentWidth(value: number, total: number) { white-space: nowrap; } -.classes tbody tr { +.classes tbody tr.pickable { cursor: pointer; } -.classes tbody tr:hover { +.done-bar { + display: inline-flex; + width: 60px; + height: 6px; + margin-right: 6px; + border-radius: 3px; + overflow: hidden; + vertical-align: middle; + background: v-bind("theme.dividerColor"); +} + +.classes tbody tr.pickable:hover { background: v-bind("theme.hoverColor"); } diff --git a/packages/contract/src/problem.ts b/packages/contract/src/problem.ts index 9391447..0ffe52b 100644 --- a/packages/contract/src/problem.ts +++ b/packages/contract/src/problem.ts @@ -473,21 +473,26 @@ export const problemStatsSchema = z.object({ .object({ className: z.string(), tried: z.number().int(), solved: z.number().int() }) .nullable(), /** - * 老师:一起做过这题的班(同一天至少 5 个人交过 —— 零星几个人自己刷到、补做的不算), - * 按那天倒序。只到班级这一层:看谁没做对是提交页「数据统计」的活。学生、比赛里为 null + * 老师:各班做这道题的情况,和数据统计「按班级汇总」**同一个口径**(只算学生的提交; + * 做完 / 班级人数、交了没对、没交、正确率、最近一次),按最近一次倒序。 + * 两边数字要一模一样 —— 老师从这里点过去,看到的数「变了」就不会再信任何一边。 + * 学生、比赛里为 null */ classes: z .array( z.object({ - className: z.string(), - /** 这个班一起做这道题的那天(东八区 YYYY-MM-DD,交的人最多的那天) */ - day: z.string(), - tried: z.number().int(), - solved: z.number().int(), - /** 班里的学生数(学生角色、没禁用),没交的 = classSize - tried */ + /** null = 没有班级的号(数据统计那边写「没有班级」) */ + className: z.string().nullable(), + /** 班里的学生数(学生角色、没禁用);没有班级的那一行就是交过的人数 */ classSize: z.number().int(), - /** 这个班错得最多的那种,一次没错过为 null */ - topFailure: failureCountSchema.nullable(), + solved: z.number().int(), + /** 交了但没做对的人 */ + unsolved: z.number().int(), + /** 班里还没交过的人 */ + untouched: z.number().int(), + /** 提交正确率,分母不算还在判的;一条都没判完为 null */ + correctRate: z.number().nullable(), + lastTime: z.string(), }), ) .nullable(),