From 921990285c4e052a20965a4df43a7cf90ac4e51a Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 30 Sep 2026 09:23:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BB=9F=E8=AE=A1):=20=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E8=83=BD=E6=8C=89=E3=80=8C=E4=B8=80=E8=8A=82=E8=AF=BE=E3=80=8D?= =?UTF-8?q?=E5=88=97=E4=BA=86=E2=80=94=E2=80=94=E5=93=AA=E5=A4=A9=E5=93=AA?= =?UTF-8?q?=E4=B8=AA=E7=8F=AD=E5=87=A0=E7=82=B9=E5=88=B0=E5=87=A0=E7=82=B9?= =?UTF-8?q?=E3=80=81=E6=9D=A5=E4=BA=86=E5=87=A0=E4=BA=BA=E3=80=81=E9=94=99?= =?UTF-8?q?=E5=BE=97=E6=9C=80=E5=A4=9A=E7=9A=84=E9=A2=98=EF=BC=8C=E9=80=89?= =?UTF-8?q?=E4=BB=80=E4=B9=88=E6=97=B6=E9=97=B4=E6=AE=B5=E9=83=BD=E6=9C=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来这些只有「今日统计」里有、而且只看今天;老师那一版并进统计之后,今日统计接口只留学生版。 课按「隔 30 分钟切开、一段里有一道题 5 人以上做过」切,26计算机0班 9 月 29 日是 14:04–15:38, 不再被课后两次零星提交拖成 14:04–18:42。统计接口多给一份结果分布,数字行画结果条用。 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/submission-statistics.ts | 411 ++++++++++++++----- packages/contract/src/submission.ts | 130 +++--- 2 files changed, 386 insertions(+), 155 deletions(-) diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index ef9820a..5d3176c 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -8,6 +8,7 @@ import { type ProblemLanguage, + type SubmissionLessons, type SubmissionStatistics, type SubmissionStatisticsGrid, type TodaySubmissionStatistics, @@ -21,15 +22,8 @@ import { failure, success } from "../http" import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status" import { type ContestEnv } from "../services/contest" import { getBooleanOption } from "../services/options" -import { dayStart } from "../time" -import { - isAdminRole, - isTeacherOrAbove, - parseDisplayIds, - rounded, - scopedUsers, - stripClassPrefix, -} from "./helpers" +import { dayStart, localTime } from "../time" +import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers" export const submissionStatisticsRoutes = new Hono() @@ -40,27 +34,19 @@ 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 } /** - * 「今日统计」(提交列表顶上那颗标签点开的)。一般是老师看:今天哪几个班上了课、几点到几点、 - * 错得最多的是哪几道;学生版从简。口径和那颗标签一致:东八区今天 + 非比赛提交。 + * 「今日统计」(提交列表顶上那颗标签点开的),只剩学生版 —— 老师那一版并进了统计的 + * 「一行一节课」(下面的 /submissions/statistics/lessons)。口径和那颗标签一致:东八区今天 + 非比赛提交。 * * 今天的提交一天也就几百到一千来条,整批拉回来在进程里分组,比写五六条各自 group by 的 * SQL 好读,也只扫一遍 `create_time` 索引。 @@ -74,15 +60,13 @@ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, as */ const showProblems = (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 + select s.problem_id, p._id as display_id, p.title, p.visible, + s.user_id, s.result, s.language 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 @@ -114,7 +98,7 @@ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, as 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) @@ -138,80 +122,6 @@ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, as .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, { asOf: new Date().toISOString(), total: rows.length, @@ -235,9 +145,6 @@ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, as } : null, problems, - classes, - scattered, - hardProblems, } satisfies TodaySubmissionStatistics) }) @@ -486,7 +393,7 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async */ const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})` - const [[totals], perUser] = await Promise.all([ + const [[totals], perUser, results] = await Promise.all([ db .select({ total: count(), @@ -529,6 +436,13 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async schema.user.adminType, ) .orderBy(desc(count())), + // 数字行里那根结果条(原来今日统计底部那根,并进来之后哪个时间段都有) + db + .select({ result: schema.submission.result, count: count() }) + .from(schema.submission) + .where(where) + .groupBy(schema.submission.result) + .orderBy(desc(count())), ]) const submissionCount = totals?.total ?? 0 @@ -624,6 +538,7 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async data, dataUnaccepted, dataAttempted, + results, } satisfies SubmissionStatistics) }) @@ -758,3 +673,295 @@ submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, a truncated, } satisfies SubmissionStatisticsGrid) }) + +/** 相邻两条提交隔这么久,就当下课了、后面的是另一段 */ +const LESSON_GAP_MINUTES = 30 +/** 一段里有一道题这么多人做过,才算一节课(和原来今日统计、首页「班里在做」同一个门槛) */ +const LESSON_MIN_USERS = 5 +/** 最后一条在这么多分钟内,算「还在上课」 */ +const LESSON_LIVE_MINUTES = 15 +/** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */ +const LESSONS_DEFAULT = 50 +const LESSONS_MAX = 500 + +type LessonAggRow = { + level: number + class_name: string | null + day: string | null + sid: number | null + problem_id: number | null + result: JudgeStatusValue | null + lesson: boolean | null + users: number + total: number + accepted: number + judging: number + from_ps: number + first: string + last: string + accepted_users: number +} + +/** + * 统计「一行一节课」的总览(口径见契约 submissionLessonsSchema)。 + * + * 切课、零散提交、错得最多的题全在一条 SQL 里:先按「班 + 东八区哪一天」隔 30 分钟切段, + * 再标出哪几段算课,最后用 grouping sets 一次聚合出下面五种粒度 —— 不走方块串那个明细 + * 接口在前端切,是因为那边最多给 5000 条,「这学期」就不止这个数,截断之后课会少算。 + */ +submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher, async (c) => { + const range = statisticsRange(c) + if (!range) return failure(c, 400, "invalid-request", "end is required") + const limit = Math.min( + LESSONS_MAX, + Math.max(1, Number.parseInt(c.req.query("limit") ?? "", 10) || LESSONS_DEFAULT), + ) + const accepted = sql.join( + ACCEPTED_RESULTS.map((value) => sql`${value}`), + sql`, `, + ) + const judging = sql.join( + UNJUDGED_RESULTS.map((value) => sql`${value}`), + sql`, `, + ) + + // grouping() 的位:class_name 32、day 16、sid 8、problem_id 4、result 2、lesson 1。 + // 没参与分组的那几列记 1,所以每种粒度对应一个固定的 level + const rows = await db.execute(sql` + with s as ( + select u.class_name, s.user_id, s.problem_id, s.result, s.problemset_id, s.create_time, + to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD') as day + from ${schema.submission} s + join ${schema.user} u on u.id = s.user_id + where s.contest_id is null + and s.create_time <= ${range.end} + ${range.start ? sql`and s.create_time >= ${range.start}` : sql``} + -- 只算普通学生:老师试题的提交不该把一段拼成「一节课」 + and u.admin_type = 'Regular User' and u.is_disabled = false + ), + g as ( + select *, + case when create_time - lag(create_time) over w > ${`${LESSON_GAP_MINUTES} minutes`}::interval + then 1 else 0 end as brk + from s + window w as (partition by class_name, day order by create_time) + ), + seg as ( + select *, + sum(brk) over (partition by class_name, day order by create_time rows unbounded preceding) as sid + from g + ), + lesson_seg as ( + 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 + group by class_name, day, sid, problem_id + ) p + group by class_name, day, sid + having max(n) >= ${LESSON_MIN_USERS} + ), + marked as ( + 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 + ) + select grouping(class_name, day, sid, problem_id, result, lesson) as level, + class_name, day, sid::int as sid, problem_id, result, lesson, + count(distinct user_id)::int as users, + count(*)::int as total, + (count(*) filter (where result in (${accepted})))::int as accepted, + (count(*) filter (where result in (${judging})))::int as judging, + (count(*) filter (where problemset_id is not null))::int as from_ps, + min(create_time) as first, + max(create_time) as last, + (count(distinct user_id) filter (where result in (${accepted})))::int as accepted_users + from marked + group by grouping sets ( + (class_name, day, sid, problem_id), + (class_name, day, sid, lesson), + (problem_id, class_name), + (problem_id, result), + (lesson, class_name, day) + ) + `) + + const LEVEL = { segProblem: 3, segment: 6, problemClass: 27, problemResult: 57, dayClass: 14 } + const segKey = (row: LessonAggRow) => `${row.class_name}|${row.day}|${row.sid}` + const failedOf = (row: { total: number; accepted: number; judging: number }) => + row.total - row.accepted - row.judging + + const problemsOfSeg = new Map() + for (const row of rows) { + if (row.level !== LEVEL.segProblem) continue + const key = segKey(row) + problemsOfSeg.set(key, [...(problemsOfSeg.get(key) ?? []), row]) + } + const allLessons = rows + .filter((row) => row.level === LEVEL.segment && row.lesson) + .sort((a, b) => b.first.localeCompare(a.first)) + const picked = allLessons.slice(0, limit) + const hasMore = allLessons.length > limit + + // 题号、标题一次查齐:课的题、每节课错得最多的那道、整段时间错得最多的几道 + const hardCandidates = new Map() + for (const row of rows) { + if (row.problem_id === null) continue + if (row.level === LEVEL.problemClass || row.level === LEVEL.problemResult) { + const entry = hardCandidates.get(row.problem_id) ?? { rows: [], failures: [] } + if (row.level === LEVEL.problemClass) entry.rows.push(row) + else entry.failures.push(row) + hardCandidates.set(row.problem_id, entry) + } + } + const hardRanked = [...hardCandidates.entries()] + .map(([problemId, entry]) => { + const sum = (key: "total" | "accepted" | "judging" | "users" | "accepted_users") => + entry.rows.reduce((acc, row) => acc + row[key], 0) + return { + problemId, + className: [...entry.rows].sort((a, b) => b.total - a.total)[0]?.class_name ?? null, + total: sum("total"), + accepted: sum("accepted"), + judging: sum("judging"), + userCount: sum("users"), + acceptedUsers: sum("accepted_users"), + failures: entry.failures + .filter( + (row) => + row.result !== null && + !ACCEPTED_RESULTS.includes(row.result) && + !UNJUDGED_RESULTS.includes(row.result), + ) + .sort((a, b) => b.total - a.total) + .map((row) => ({ result: row.result!, count: row.total })), + } + }) + .filter((row) => failedOf(row) > 0) + .sort((a, b) => failedOf(b) - failedOf(a)) + .slice(0, 6) + + const lessonProblemRows = picked.map((lesson) => + (problemsOfSeg.get(segKey(lesson)) ?? []) + .filter((row) => row.users >= LESSON_MIN_USERS) + .sort((a, b) => b.users - a.users), + ) + const problemIds = [ + ...new Set([ + ...lessonProblemRows.flat().map((row) => row.problem_id!), + ...hardRanked.map((row) => row.problemId), + ]), + ] + const problems = problemIds.length + ? await db + .select({ + id: schema.problem.id, + displayId: schema.problem.displayId, + title: schema.problem.title, + visible: schema.problem.visible, + }) + .from(schema.problem) + .where(inArray(schema.problem.id, problemIds)) + : [] + const problemById = new Map(problems.map((row) => [row.id, row])) + + const lessonClasses = [...new Set(picked.map((row) => row.class_name!))] + const sizes = lessonClasses.length + ? await db + .select({ className: schema.user.className, value: count() }) + .from(schema.user) + .where( + and( + inArray(schema.user.className, lessonClasses), + eq(schema.user.adminType, "Regular User"), + eq(schema.user.isDisabled, false), + ), + ) + .groupBy(schema.user.className) + : [] + + const sum = (list: LessonAggRow[], key: "total" | "accepted" | "judging") => + list.reduce((acc, row) => acc + row[key], 0) + const liveSince = new Date(Date.now() - LESSON_LIVE_MINUTES * 60_000).toISOString() + const lessons = picked.map((lesson, index) => { + const own = lessonProblemRows[index]! + 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!, + day: lesson.day!, + start: lesson.first, + end: lesson.last, + userCount: lesson.users, + classSize: sizes.find((row) => row.className === lesson.class_name)?.value ?? 0, + // 点进去就拿这几道去筛:统计接口只认公开题,一次最多 20 道 + problems: own + .map((row) => problemById.get(row.problem_id!)) + .filter((row) => row?.visible) + .map((row) => row!.displayId) + .slice(0, STATISTICS_MAX_PROBLEMS), + fromProblemSet: lesson.from_ps * 2 > lesson.total, + // 次数和正确率只算这节课的题:点进去筛的就是这几道,两边的数才对得上 + // (一段里零星一两个人顺手交的别的题不算) + total: sum(own, "total"), + correctRate: judgedRate(sum(own, "accepted"), sum(own, "total") - sum(own, "judging")), + live: lesson.last >= liveSince, + hardest: + hardest && hardestProblem && failedOf(hardest) > 0 + ? { + problemDisplayId: hardestProblem.displayId, + problemTitle: hardestProblem.title, + failed: failedOf(hardest), + } + : null, + } + }) + + // 零散提交只给列出来的这些课覆盖到的日子(「这学期」只列了最近 50 节时,更早的零散不给) + const earliestDay = hasMore ? (picked.at(-1)?.day ?? null) : null + const scattered = rows + .filter( + (row) => + row.level === LEVEL.dayClass && + row.lesson === false && + (earliestDay === null || row.day! >= earliestDay), + ) + .sort((a, b) => + a.day !== b.day + ? b.day!.localeCompare(a.day!) + : a.class_name === null + ? 1 + : b.class_name === null + ? -1 + : b.total - a.total, + ) + .map((row) => ({ + day: row.day!, + className: row.class_name, + userCount: row.users, + total: row.total, + })) + + return success(c, { + lessons, + scattered, + hardProblems: hardRanked.flatMap((row) => { + const problem = problemById.get(row.problemId) + if (!problem) return [] + return [ + { + problemDisplayId: problem.displayId, + problemTitle: problem.title, + className: row.className, + total: row.total, + accepted: row.accepted, + failures: row.failures, + userCount: row.userCount, + acceptedUsers: row.acceptedUsers, + }, + ] + }), + hasMore, + } satisfies SubmissionLessons) +}) diff --git a/packages/contract/src/submission.ts b/packages/contract/src/submission.ts index 1e6c206..71bc6b4 100644 --- a/packages/contract/src/submission.ts +++ b/packages/contract/src/submission.ts @@ -401,6 +401,79 @@ export const submissionStatisticsSchema = z.object({ * 就像统计只认成功的提交。dataUnaccepted 没有花名册就真的算不出来,仍然为空。 */ dataAttempted: z.array(attemptedStudentSchema), + /** 按判题结果,条数倒序 —— 数字行里那根结果条(含还在判的) */ + results: z.array(z.object({ result: judgeStatusSchema, count: z.number().int() })), +}) + +/** + * 统计「一行一节课」的总览(GET /submissions/statistics/lessons,设计稿「统计合二为一」B)。 + * 没选班、没填学生、没填题号时用它;原来「今日统计」老师那一版并进来了,换成任意时间段。 + * + * **一节课怎么切**:只算普通学生(和方块串同一个口径),按「班 + 东八区哪一天」分组, + * 相邻两条提交隔 30 分钟以上就切开;切出来的一段里有一道题 5 人以上做过,才算一节课。 + * 其余的是零散提交(补做、课后自己练)。这样 26计算机0班 9 月 29 日是 14:04–15:38, + * 不会被 16:24、18:42 那两条课后提交拖成「14:04–18:42」。 + */ +const lessonFailureSchema = z.object({ result: judgeStatusSchema, count: z.number().int() }) + +export const submissionLessonsSchema = z.object({ + /** 最近的 N 节课,按开始时间倒序(界面上按天分组,这节课在最上面) */ + lessons: z.array( + z.object({ + className: z.string(), + /** 东八区的日子,`2026-09-29` */ + day: z.string(), + /** 这一段第一条、最后一条提交 */ + start: z.string(), + end: z.string(), + userCount: z.number().int(), + /** 班里的学生数(未禁用的普通用户) */ + classSize: z.number().int(), + /** 这节课做的题(这一段里 5 人以上做过的),做的人多的在前,最多 20 道 —— 点进去就筛这几道 */ + problems: z.array(z.string()), + /** 多半是从题单里交的 —— 「题单 7 道」 */ + fromProblemSet: z.boolean(), + /** 这节课的题(上面 problems)交了几次 —— 和点进去看到的对得上 */ + total: z.number().int(), + /** 同上,判完的里面对了多少,百分数 */ + correctRate: z.number(), + /** 最后一条在 15 分钟内:还在上课,界面上引去课堂看板 */ + live: z.boolean(), + /** 这节课的题里没过次数最多的那道;一次都没错过为 null */ + hardest: z + .object({ + problemDisplayId: z.string(), + problemTitle: z.string(), + failed: z.number().int(), + }) + .nullable(), + }), + ), + /** 零散提交,按天、按班(null = 没填班级的号)。只给列出来的这些课覆盖到的日子 */ + scattered: z.array( + z.object({ + day: z.string(), + className: z.string().nullable(), + userCount: z.number().int(), + total: z.number().int(), + }), + ), + /** 整个时间段里错得最多的题,按没过的次数倒序,最多 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(lessonFailureSchema), + userCount: z.number().int(), + acceptedUsers: z.number().int(), + }), + ), + /** 更早还有课没列出来(「这学期」「全部」那种),界面上给「再往前看」 */ + hasMore: z.boolean(), }) /** @@ -416,9 +489,9 @@ export const submissionStatisticsSchema = z.object({ const todayFailureSchema = z.object({ result: judgeStatusSchema, count: z.number().int() }) /** - * 「今日统计」(提交列表顶上那颗标签点开的)。一般是老师在看(设计稿「今日统计重设计」老师 B): - * 今天哪几个班上了课、各在几点、错得最多的是哪几道。学生版从简:自己今天怎样、大家在做哪几道。 - * 口径:东八区今天 + 非比赛提交。 + * 「今日统计」(提交列表顶上那颗标签点开的),**只剩学生版**:自己今天怎样、大家在做哪几道。 + * 老师那一版(今天哪几个班上了课、错得最多的题)2026-09 并进了统计(submissionLessonsSchema), + * 老师的提交列表上不再有这颗标签。口径:东八区今天 + 非比赛提交。 */ export const todaySubmissionStatisticsSchema = z.object({ /** 这份数据是几点算的 —— 标题上的「截至 19:40」 */ @@ -456,56 +529,6 @@ export const todaySubmissionStatisticsSchema = z.object({ 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({ @@ -579,6 +602,7 @@ export type SubmissionDetail = z.infer export type SubmissionUpdate = z.infer export type SubmissionStatistics = z.infer export type TodaySubmissionStatistics = z.infer +export type SubmissionLessons = z.infer export type SubmissionStatisticsUser = z.infer export type SubmissionStatisticsGrid = z.infer export type UnacceptedStudent = z.infer