diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index c575f7c..ef9820a 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -7,6 +7,7 @@ */ import { + type ProblemLanguage, type SubmissionStatistics, type SubmissionStatisticsGrid, type TodaySubmissionStatistics, @@ -17,111 +18,226 @@ import { Hono } from "hono" import { optionalAuth, requireTeacher } from "../auth/middleware" import { db, schema } from "../db" import { failure, success } from "../http" -import { JudgeStatus, UNJUDGED_RESULTS } from "../judge/status" +import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status" import { type ContestEnv } from "../services/contest" import { getBooleanOption } from "../services/options" -import { localTime, dayStart } from "../time" -import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers" +import { dayStart } from "../time" +import { + isAdminRole, + isTeacherOrAbove, + parseDisplayIds, + rounded, + scopedUsers, + stripClassPrefix, +} from "./helpers" export const submissionStatisticsRoutes = new Hono() -const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] +const ACCEPTED_RESULTS: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] /** 正确率。分母是判完的条数,一条都还没判完时给 0 而不是 NaN */ function judgedRate(accepted: number, judged: number) { return judged > 0 ? rounded((accepted / judged) * 100) : 0 } +/** 同班这么多人做了同一道题,才算这个班今天上了课(和首页「班里在做」同一个门槛) */ +const TODAY_LESSON_MIN_USERS = 5 +/** 最后一条提交在这么多分钟内,算「正在上课」 */ +const TODAY_LIVE_MINUTES = 15 + +type TodayRow = { + class_name: string | null + problem_id: number + display_id: string + title: string + visible: boolean + problemset_id: number | null + user_id: number + result: JudgeStatusValue + language: ProblemLanguage + create_time: string +} + /** - * 「今日提交数」标签点开的统计。**公开、只出聚合数**(没有用户名、没有代码, - * 热门题只算公开可见的题),口径和那颗标签一致:东八区今天 + 非比赛提交。 + * 「今日统计」(提交列表顶上那颗标签点开的)。一般是老师看:今天哪几个班上了课、几点到几点、 + * 错得最多的是哪几道;学生版从简。口径和那颗标签一致:东八区今天 + 非比赛提交。 * - * 按钟点切用 `localTime()`,不能写 `extract(hour from create_time)` —— - * 后者按数据库会话时区算,容器是 UTC,整张分布图会整体左移 8 小时。 + * 今天的提交一天也就几百到一千来条,整批拉回来在进程里分组,比写五六条各自 group by 的 + * SQL 好读,也只扫一遍 `create_time` 索引。 */ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, async (c) => { + const user = c.get("user") /** - * 「提交列表对学生全开」关掉时(考试那种场合)不给热门题这张表 —— 总数、正确率 + * 「提交列表对学生全开」关掉时(考试那种场合)不给题目这张表 —— 总数、正确率 * 这些聚合数原本就从公开的 today-count 看得出来,但「哪几道题在被刷」已经贴近 * 提交列表本身的内容了,得跟着同一个开关走。数字照给,不然标签说 21、弹框说 0。 */ const showProblems = - (await getBooleanOption("submission_list_show_all", true)) || isAdminRole(c.get("user")) - const where = and( - isNull(schema.submission.contestId), - sql`${schema.submission.createTime} >= ${dayStart()}`, - ) - const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})` - const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})` - const hour = sql`extract(hour from ${localTime(schema.submission.createTime)})::int` - - const [[totals], hourRows, languageRows, resultRows, problemRows] = await Promise.all([ + (await getBooleanOption("submission_list_show_all", true)) || isAdminRole(user) + const teacher = isTeacherOrAbove(user) + const since = dayStart() + const [rows, [flow]] = await Promise.all([ + db.execute(sql` + select u.class_name, s.problem_id, p._id as display_id, p.title, p.visible, + s.problemset_id, s.user_id, s.result, s.language, s.create_time + from ${schema.submission} s + join ${schema.problem} p on p.id = s.problem_id + left join ${schema.user} u on u.id = s.user_id + where s.contest_id is null and s.create_time >= ${since} + `), db - .select({ - total: count(), - accepted: acceptedFilter.mapWith(Number), - judging: judgingFilter.mapWith(Number), - userCount: sql`count(distinct ${schema.submission.userId})`.mapWith(Number), - }) - .from(schema.submission) - .where(where), - db.select({ hour, value: count() }).from(schema.submission).where(where).groupBy(hour), - db - .select({ language: schema.submission.language, value: count() }) - .from(schema.submission) - .where(where) - .groupBy(schema.submission.language) - .orderBy(desc(count())), - db - .select({ result: schema.submission.result, value: count() }) - .from(schema.submission) - .where(where) - .groupBy(schema.submission.result) - .orderBy(desc(count())), - showProblems - ? db - .select({ - displayId: schema.problem.displayId, - title: schema.problem.title, - value: count(), - accepted: acceptedFilter.mapWith(Number), - }) - .from(schema.submission) - .innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId)) - // 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西 - .where(and(where, eq(schema.problem.visible, true))) - .groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title) - .orderBy(desc(count())) - .limit(10) - : [], + .select({ value: count() }) + .from(schema.flowchartSubmission) + .where(sql`${schema.flowchartSubmission.createTime} >= ${since}`), ]) - const total = totals?.total ?? 0 - const judging = totals?.judging ?? 0 - const hours = Array.from({ length: 24 }, () => 0) - for (const row of hourRows) hours[row.hour] = row.value + const isAccepted = (row: TodayRow) => ACCEPTED_RESULTS.includes(row.result) + const isJudging = (row: TodayRow) => UNJUDGED_RESULTS.includes(row.result) + const rate = (list: TodayRow[]) => { + const judging = list.filter(isJudging).length + return judgedRate(list.filter(isAccepted).length, list.length - judging) + } + const distinctUsers = (list: TodayRow[]) => new Set(list.map((row) => row.user_id)).size + const acceptedUsers = (list: TodayRow[]) => + new Set(list.filter(isAccepted).map((row) => row.user_id)).size + const groupBy = (list: TodayRow[], key: (row: TodayRow) => K) => { + const map = new Map() + for (const row of list) map.set(key(row), [...(map.get(key(row)) ?? []), row]) + return map + } + const countBy = (list: TodayRow[], key: (row: TodayRow) => K) => + [...groupBy(list, key)] + .map(([value, group]) => ({ value, count: group.length })) + .sort((a, b) => b.count - a.count) + + const judging = rows.filter(isJudging).length + const byProblem = groupBy(rows, (row) => row.problem_id) + const mineRows = user ? rows.filter((row) => row.user_id === user.id) : [] + + // ---------- 学生:大家都在做 ---------- + const problems = showProblems + ? [...byProblem.values()] + .filter((list) => list[0]!.visible) + .map((list) => { + const mine = user ? list.filter((row) => row.user_id === user.id) : [] + return { + problemDisplayId: list[0]!.display_id, + problemTitle: list[0]!.title, + userCount: distinctUsers(list), + acceptedUsers: acceptedUsers(list), + mine: !user + ? null + : mine.some(isAccepted) + ? ("accepted" as const) + : mine.length + ? ("tried" as const) + : ("none" as const), + } + }) + .sort((a, b) => b.userCount - a.userCount) + .slice(0, 8) + : [] + + // ---------- 老师:哪几个班上了课、错得最多的题 ---------- + let classes: TodaySubmissionStatistics["classes"] = null + let scattered: TodaySubmissionStatistics["scattered"] = null + let hardProblems: TodaySubmissionStatistics["hardProblems"] = null + if (teacher) { + const byClass = groupBy(rows, (row) => row.class_name) + const lessonClasses: { className: string; list: TodayRow[]; problemCount: number }[] = [] + scattered = [] + for (const [className, list] of byClass) { + const problemCount = className + ? [...groupBy(list, (row) => row.problem_id).values()].filter( + (group) => distinctUsers(group) >= TODAY_LESSON_MIN_USERS, + ).length + : 0 + if (className && problemCount) lessonClasses.push({ className, list, problemCount }) + else scattered.push({ className, userCount: distinctUsers(list), total: list.length }) + } + scattered.sort((a, b) => + a.className === null ? 1 : b.className === null ? -1 : b.total - a.total, + ) + const sizes = lessonClasses.length + ? await db + .select({ className: schema.user.className, value: count() }) + .from(schema.user) + .where( + and( + inArray( + schema.user.className, + lessonClasses.map((item) => item.className), + ), + eq(schema.user.adminType, "Regular User"), + eq(schema.user.isDisabled, false), + ), + ) + .groupBy(schema.user.className) + : [] + const liveSince = new Date(Date.now() - TODAY_LIVE_MINUTES * 60_000).toISOString() + classes = lessonClasses + .map(({ className, list, problemCount }) => { + const times = list.map((row) => row.create_time).sort() + return { + className, + start: times[0]!, + end: times.at(-1)!, + userCount: distinctUsers(list), + classSize: sizes.find((row) => row.className === className)?.value ?? 0, + problemCount, + fromProblemSet: list.filter((row) => row.problemset_id !== null).length * 2 > list.length, + total: list.length, + correctRate: rate(list), + live: times.at(-1)! >= liveSince, + } + }) + .sort((a, b) => a.start.localeCompare(b.start)) + const failedOf = (list: TodayRow[]) => list.filter((row) => !isAccepted(row) && !isJudging(row)) + hardProblems = [...byProblem.values()] + .filter((list) => failedOf(list).length > 0) + .sort((a, b) => failedOf(b).length - failedOf(a).length) + .slice(0, 6) + .map((list) => ({ + problemDisplayId: list[0]!.display_id, + problemTitle: list[0]!.title, + className: countBy(list, (row) => row.class_name)[0]?.value ?? null, + total: list.length, + accepted: list.filter(isAccepted).length, + failures: countBy(failedOf(list), (row) => row.result).map(({ value, count }) => ({ + result: value, + count, + })), + userCount: distinctUsers(list), + acceptedUsers: acceptedUsers(list), + })) + } return success(c, { - total, - accepted: totals?.accepted ?? 0, + asOf: new Date().toISOString(), + total: rows.length, + accepted: rows.filter(isAccepted).length, judging, - correctRate: judgedRate(totals?.accepted ?? 0, total - judging), - userCount: totals?.userCount ?? 0, - hours, - languages: languageRows.map((row) => ({ - language: row.language, - count: row.value, + correctRate: rate(rows), + userCount: distinctUsers(rows), + flowchartCount: flow?.value ?? 0, + languages: countBy(rows, (row) => row.language).map(({ value, count }) => ({ + language: value, + count, })), - results: resultRows.map((row) => ({ - result: row.result, - count: row.value, - })), - problems: problemRows.map((row) => ({ - problemDisplayId: row.displayId, - problemTitle: row.title, - count: row.value, - acceptedCount: row.accepted, + results: countBy(rows, (row) => row.result).map(({ value, count }) => ({ + result: value, + count, })), + me: user + ? { + total: mineRows.length, + solved: new Set(mineRows.filter(isAccepted).map((row) => row.problem_id)).size, + } + : null, + problems, + classes, + scattered, + hardProblems, } satisfies TodaySubmissionStatistics) }) diff --git a/apps/web/src/oj/submission/components/TodayStatistics.vue b/apps/web/src/oj/submission/components/TodayStatistics.vue index 137bd02..785b8fc 100644 --- a/apps/web/src/oj/submission/components/TodayStatistics.vue +++ b/apps/web/src/oj/submission/components/TodayStatistics.vue @@ -1,240 +1,771 @@ diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index 7010a4a..a88105d 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -82,6 +82,7 @@ export type ProblemStatus = "passed" | "failed" | "not_test" */ export type { Tag, TagCategory } from "@oj2/contract" export type { ProblemStats, ProblemClassDetail } from "@oj2/contract" +export type { TodaySubmissionStatistics } from "@oj2/contract" export type { AdminTag, diff --git a/packages/contract/src/submission.ts b/packages/contract/src/submission.ts index 0532705..1e6c206 100644 --- a/packages/contract/src/submission.ts +++ b/packages/contract/src/submission.ts @@ -413,7 +413,16 @@ export const submissionStatisticsSchema = z.object({ * 口径跟着那颗标签走:**东八区今天、非比赛提交、不分语言**(语言分布就是 * `languages` 这张表本身)。 */ +const todayFailureSchema = z.object({ result: judgeStatusSchema, count: z.number().int() }) + +/** + * 「今日统计」(提交列表顶上那颗标签点开的)。一般是老师在看(设计稿「今日统计重设计」老师 B): + * 今天哪几个班上了课、各在几点、错得最多的是哪几道。学生版从简:自己今天怎样、大家在做哪几道。 + * 口径:东八区今天 + 非比赛提交。 + */ export const todaySubmissionStatisticsSchema = z.object({ + /** 这份数据是几点算的 —— 标题上的「截至 19:40」 */ + asOf: z.string(), total: z.number().int(), /** 通过的条数,含 AST_CHECK_FAILED(那也是答案对了) */ accepted: z.number().int(), @@ -425,24 +434,78 @@ export const todaySubmissionStatisticsSchema = z.object({ correctRate: z.number(), /** 今天交过东西的人数,按 user_id 去重 */ userCount: z.number().int(), - /** 按东八区钟点分的 24 个桶,**下标就是钟点**,没有提交的钟点是 0 */ - hours: z.array(z.number().int()).length(24), - /** 按语言,提交数倒序。零提交的语言不在表里 */ + /** 今天画了几张流程图,0 就不提 */ + flowchartCount: z.number().int(), + /** 按语言,提交数倒序。只有一种语言时界面上缩成一句话 */ languages: z.array(z.object({ language: problemLanguageSchema, count: z.number().int() })), - /** 按判题结果,条数倒序 */ - results: z.array(z.object({ result: judgeStatusSchema, count: z.number().int() })), + /** 按判题结果,条数倒序 —— 画成底部那根结果条 */ + results: z.array(todayFailureSchema), + /** 我今天交了几次、做对几道。没登录为 null */ + me: z.object({ total: z.number().int(), solved: z.number().int() }).nullable(), /** - * 今天最热的几道题,提交数倒序,最多 10 道。 - * **只含公开可见的题目** —— 这个接口不需要登录,不能拿它探未发布题目的标题。 + * 学生版「今天大家都在做」:按做的人数倒序,最多 8 道。**只含公开可见的题目** —— + * 这个接口不需要登录,不能拿它探未发布题目的标题。「提交列表对学生全开」关掉时为空 */ problems: z.array( z.object({ problemDisplayId: z.string(), problemTitle: z.string(), - count: z.number().int(), - acceptedCount: z.number().int(), + userCount: z.number().int(), + acceptedUsers: z.number().int(), + /** 我自己今天在这道题上:做对了 / 交了没对 / 没交;没登录为 null */ + mine: z.enum(["accepted", "tried", "none"]).nullable(), }), ), + /** + * 老师:今天上了课的班(同班 5 人以上做了同一道题),按开始时间排。学生为 null。 + * 时段是这个班今天第一条到最后一条提交,`live` = 最后一条在 15 分钟内(正在上课, + * 界面上引去课堂看板 —— 盯人是看板的活) + */ + classes: z + .array( + z.object({ + className: z.string(), + start: z.string(), + end: z.string(), + userCount: z.number().int(), + /** 班里的学生数 */ + classSize: z.number().int(), + /** 这节课做的题数(班里 5 人以上做过的) */ + problemCount: z.number().int(), + /** 多半是从题单里交的 —— 「题单 9 道」 */ + fromProblemSet: z.boolean(), + total: z.number().int(), + correctRate: z.number(), + live: z.boolean(), + }), + ) + .nullable(), + /** 老师:没上课的零星提交,按班(null = 没填班级的号) */ + scattered: z + .array( + z.object({ + className: z.string().nullable(), + userCount: z.number().int(), + total: z.number().int(), + }), + ) + .nullable(), + /** 老师:错得最多的题,按没过的次数倒序,最多 6 道 */ + hardProblems: z + .array( + z.object({ + problemDisplayId: z.string(), + problemTitle: z.string(), + /** 这道题今天交得最多的那个班 */ + className: z.string().nullable(), + total: z.number().int(), + accepted: z.number().int(), + failures: z.array(todayFailureSchema), + userCount: z.number().int(), + acceptedUsers: z.number().int(), + }), + ) + .nullable(), }) export const formatCodeRequestSchema = z.object({