From db493cebe41d19f92eda0ca0401be3ddce6d4aa7 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 30 Sep 2026 00:50:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E9=A2=98=E7=9B=AE=E9=A1=B5):=20=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E9=A1=B5=E7=AD=BE=E7=9A=84=E8=80=81=E5=B8=88=E7=89=88?= =?UTF-8?q?=E5=9B=9E=E5=88=B0=E8=AE=B2=E8=BF=99=E9=81=93=E9=A2=98=E6=9C=AC?= =?UTF-8?q?=E8=BA=AB=EF=BC=8C=E5=90=84=E7=8F=AD=E5=8F=AA=E5=88=97=E4=B8=80?= =?UTF-8?q?=E5=BC=A0=E8=A1=A8=EF=BC=8C=E4=B8=8D=E9=80=89=E7=8F=AD=E3=80=81?= =?UTF-8?q?=E4=B8=8D=E5=88=97=E4=BA=BA=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按人看谁没做对是提交页「数据统计」的活;表里点一个班就去那边看。 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/problem-stats.ts | 119 +++---- apps/web/src/oj/api.ts | 11 +- .../src/oj/problem/components/ProblemInfo.vue | 314 ++++++------------ apps/web/src/utils/types.ts | 2 +- packages/contract/src/problem.ts | 29 +- 5 files changed, 151 insertions(+), 324 deletions(-) diff --git a/apps/api/src/routes/problem-stats.ts b/apps/api/src/routes/problem-stats.ts index 74b2777..3d62359 100644 --- a/apps/api/src/routes/problem-stats.ts +++ b/apps/api/src/routes/problem-stats.ts @@ -1,5 +1,5 @@ -import { type ProblemClassDetail, type ProblemStats } from "@oj2/contract" -import { and, eq, isNotNull, sql } from "drizzle-orm" +import { type ProblemStats } from "@oj2/contract" +import { and, count, eq, inArray, isNotNull, sql } from "drizzle-orm" import { Hono } from "hono" import { optionalAuth, type AppEnv } from "../auth/middleware" @@ -9,7 +9,7 @@ import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/s import { canAccessContest, contestDetailsAllowed, findAccessibleContest } from "../services/contest" import { accepted } from "../services/learning-stats" import { localTime } from "../time" -import { isAdminRole, isTeacherOrAbove, queryInteger, stripClassPrefix } from "./helpers" +import { isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers" export const problemStatsRoutes = new Hono() @@ -29,7 +29,6 @@ const EMPTY: ProblemStats = { me: null, myClass: null, classes: null, - classDetail: null, } // type 而不是 interface:db.execute 的泛型约束是 Record,interface 不带索引签名 @@ -41,14 +40,13 @@ type UserRow = { /** 做对之前(含做对那次)交了几次;没做对就是全部次数 */ tries: number solved: boolean - last_result: JudgeStatusValue - last_time: string } /** - * 题目页「统计」页签。id 是内部题号(problem.id):比赛题的展示题号是 1、2、3, - * 拿 displayId 查会撞上同号的公开题(原来的「历年 AC 率」就是这么查错的)。 - * `className` 只有老师用:选中哪个班看明细,不填就是最近做过这题的那个班。 + * 题目页「统计」页签:讲的是**这道题**(难不难、几次做对、常错在哪),老师多一张各班的表。 + * 看谁没做对是提交页「数据统计」的活,这里只到班级这一层。 + * id 是内部题号(problem.id):比赛题的展示题号是 1、2、3,拿 displayId 查会撞上 + * 同号的公开题(原来的「历年 AC 率」就是这么查错的)。 */ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => { const user = c.get("user") @@ -93,9 +91,7 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => { select s.user_id, u.username, u.class_name, count(*)::int as attempts, count(*) filter (where s.first_ac is null or s.create_time <= s.first_ac)::int as tries, - (min(s.first_ac) is not null) as solved, - (array_agg(s.result order by s.create_time desc))[1] as last_result, - max(s.create_time) as last_time + (min(s.first_ac) is not null) as solved from ( select user_id, result, create_time, min(create_time) filter (where result in (${acceptedList})) @@ -157,7 +153,6 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => { const classmates = myClassName ? users.filter((row) => row.class_name === myClassName) : [] let classes: ProblemStats["classes"] = null - let classDetail: ProblemClassDetail | null = null if (!inContest && isTeacherOrAbove(user)) { const byClass = new Map() for (const row of users) { @@ -172,27 +167,41 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => { peak.set(row.className!, { day: row.day, users: row.users }) } } - const lessonClasses = [...byClass] - .filter(([className]) => (peak.get(className)?.users ?? 0) >= CLASS_MIN_TRIED) - .map(([className, rows]) => ({ - className, - tried: rows.length, - solved: rows.filter((row) => row.solved).length, - day: peak.get(className)!.day, - })) - .sort((a, b) => b.day.localeCompare(a.day)) + 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 - // 地址里指定的班不在列表里(零星几个人做过)也照样给明细 - const wanted = c.req.query("className")?.trim() - const selected = wanted && byClass.has(wanted) ? wanted : lessonClasses[0]?.className - if (selected) { - classDetail = await buildClassDetail( - selected, - peak.get(selected)!.day, - byClass.get(selected)!, - failureRows, - ) - } + .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)) } return success(c, { @@ -211,49 +220,5 @@ problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => { } : null, classes, - classDetail, } satisfies ProblemStats) }) - -async function buildClassDetail( - className: string, - day: string, - rows: UserRow[], - failureRows: { class_name: string | null; result: JudgeStatusValue; n: number }[], -): Promise { - // 花名册只数学生:老师、助教也可能挂着班级 - const roster = await db - .select({ id: schema.user.id, username: schema.user.username }) - .from(schema.user) - .where( - and( - eq(schema.user.className, className), - eq(schema.user.adminType, "Regular User"), - eq(schema.user.isDisabled, false), - ), - ) - const triedIds = new Set(rows.map((row) => row.user_id)) - const name = (username: string | null) => stripClassPrefix(username ?? "", className) - return { - className, - roster: roster.length, - day, - failures: failureRows - .filter((row) => row.class_name === className) - .map((row) => ({ result: row.result, count: row.n })) - .sort((a, b) => b.count - a.count), - unsolved: rows - .filter((row) => !row.solved) - .sort((a, b) => b.attempts - a.attempts) - .map((row) => ({ - realName: name(row.username), - attempts: row.attempts, - lastResult: row.last_result, - })), - solved: rows - .filter((row) => row.solved) - .sort((a, b) => a.last_time.localeCompare(b.last_time)) - .map((row) => name(row.username)), - untouched: roster.filter((row) => !triedIds.has(row.id)).map((row) => name(row.username)), - } -} diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 50f0f1c..e35bc2d 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -115,14 +115,9 @@ export async function getProblem(problemID: string, contestID: string) { return problem } -/** - * 题目页「统计」页签。problemID 是内部题号(problem.id),比赛题也能查。 - * className 只有老师用:看哪个班的明细,不填就是最近做过这题的班 - */ -export function getProblemStats(problemID: number, className?: string) { - return api.get(`problems/${problemID}/stats`, { - params: className ? { className } : {}, - }) +/** 题目页「统计」页签。problemID 是内部题号(problem.id),比赛题也能查 */ +export function getProblemStats(problemID: number) { + return api.get(`problems/${problemID}/stats`) } export async function getSubmission(id: string): Promise { diff --git a/apps/web/src/oj/problem/components/ProblemInfo.vue b/apps/web/src/oj/problem/components/ProblemInfo.vue index 6f9acbc..3f5fa54 100644 --- a/apps/web/src/oj/problem/components/ProblemInfo.vue +++ b/apps/web/src/oj/problem/components/ProblemInfo.vue @@ -13,11 +13,10 @@ import type { ProblemStats, SUBMISSION_RESULT } from "utils/types" import { useProblemPageContext } from "../composables/problemPageContext" /** - * 「统计」页签(设计稿「题目页统计重设计」:学生 A、老师 B)。 - * - * 学生看的是「这题难不难 → 大家几次做对 → 常错在哪 → 你们班」,全部按人算;自己卡住了 - * 再多一张卡。老师看的是一个班:谁做对了、谁交了没对(卡在哪)、谁还没交 —— 原来工具栏上 - * 的「课堂统计」并进了这里。原来的描述表、击败用户、饼图、历年 AC 率都去掉了。 + * 「统计」页签(设计稿「题目页统计重设计」学生 A)。讲的是**这道题**:难不难 → 大家几次 + * 做对 → 常错在哪,全部按人算。学生多一行「你们班」、自己卡住了再多一张卡;老师多一张 + * 各班的表(只到班级这一层,不选班、不列人 —— 按人看是提交页「数据统计」的活)。 + * 原来的描述表、击败用户、饼图、历年 AC 率都去掉了。 */ const emit = defineEmits<{ openSubmissions: [] }>() @@ -31,15 +30,12 @@ const tone = useTone() const stats = ref(null) const failed = ref(false) -const className = ref(null) async function load() { if (!problem.value) return failed.value = false try { - const res = await getProblemStats(problem.value.id, className.value ?? undefined) - stats.value = res - if (res.classDetail) className.value = res.classDetail.className + stats.value = await getProblemStats(problem.value.id) } catch { failed.value = true } @@ -47,13 +43,6 @@ async function load() { onMounted(load) -function changeClass(value: string) { - className.value = value - load() -} - -const teacherView = computed(() => stats.value?.classes != null) - function resultName(result: number) { return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? "其他" } @@ -139,53 +128,13 @@ const stuck = computed(() => { return me && !me.solved && me.attempts >= 3 ? me.attempts : 0 }) -// ---------- 老师 ---------- +// ---------- 老师:各班的表 ---------- -const detail = computed(() => stats.value?.classDetail ?? null) - -const classOptions = computed(() => - (stats.value?.classes ?? []).map((item) => ({ - label: `${classLabel(item.className)} · ${item.solved}/${item.tried}`, - value: item.className, - })), -) - -const classSegments = computed(() => { - const d = detail.value - if (!d) return [] - return [ - { key: "solved", label: "做对", value: d.solved.length, color: theme.value.successColor }, - { key: "unsolved", label: "交了没对", value: d.unsolved.length, color: theme.value.errorColor }, - { key: "untouched", label: "没交", value: d.untouched.length, color: theme.value.dividerColor }, - ] -}) - -const classFailedTotal = computed(() => - (detail.value?.failures ?? []).reduce((sum, item) => sum + item.count, 0), -) - -/** 这个班错得最多的和全站不一样时点出来:老师该看的就是这种差别 */ -const siteDiffers = computed(() => { - const site = stats.value?.failures[0] - const mine = detail.value?.failures[0] - return site && mine && site.result !== mine.result ? resultName(site.result) : "" -}) - -const showSolved = ref(false) -const showUntouched = ref(false) - -function openStatistics() { +/** 点一个班、或者表下面那行:去提交页的「数据统计」按人看(新标签,题目页不动) */ +function openStatistics(className?: string) { const href = router.resolve({ name: "statistics", - query: { problem: problem.value!._id, className: className.value ?? "", period: "all" }, - }).href - window.open(href, "_blank") -} - -function openClassSubmissions() { - const href = router.resolve({ - name: "submissions", - query: { problem: problem.value!._id, className: className.value ?? "" }, + query: { problem: problem.value!._id, period: "all", ...(className ? { className } : {}) }, }).href window.open(href, "_blank") } @@ -251,105 +200,7 @@ function segmentWidth(value: number, total: number) { - - - - +
还没有人交过这道题 @@ -419,6 +270,66 @@ function segmentWidth(value: number, total: number) {
+ +
+
+ 各班做得怎样 + + 同一天 5 人以上交过才算这个班做过 +
+ + + + + + + + + + + + + + + + + + + +
班级哪天做的做对 / 交过没交错得最多
+ {{ classLabel(item.className) }} + + {{ dayText(item.day) }} + + {{ item.solved }} + / {{ item.tried }} + + {{ Math.max(0, item.classSize - item.tried) }} + + + {{ resultName(item.topFailure.result) }} {{ item.topFailure.count }} + + — +
+ 还没有哪个班一起做过这道题 + +
+
你们班 · {{ classLabel(stats.myClass.className) }} @@ -652,65 +563,36 @@ function segmentWidth(value: number, total: number) { gap: 6px; } -.class-head { - display: flex; - align-items: center; - gap: 10px; -} - -.class-select { - width: 190px; -} - -.people { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 6px; -} - -.person { - display: flex; - align-items: center; - gap: 6px; - padding: 5px 10px; - border-radius: 6px; - border: 1px solid v-bind("theme.dividerColor"); - font-size: 13px; - white-space: nowrap; - overflow: hidden; -} - -.fold { - border-radius: 6px; - background: v-bind("theme.actionColor"); - font-size: 13px; -} - -.fold-head { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - cursor: pointer; -} - -.fold-names { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: v-bind("theme.textColor2"); -} - -.fold-body { - padding: 0 12px 10px; - line-height: 1.8; - color: v-bind("theme.textColor2"); -} - .foot { display: flex; justify-content: flex-end; } + +.classes { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.classes th { + text-align: left; + font-weight: normal; + color: v-bind("theme.textColor3"); + padding: 4px 8px 6px 0; + border-bottom: 1px solid v-bind("theme.dividerColor"); +} + +.classes td { + padding: 8px 8px 8px 0; + border-bottom: 1px solid v-bind("theme.dividerColor"); + white-space: nowrap; +} + +.classes tbody tr { + cursor: pointer; +} + +.classes tbody tr:hover { + background: v-bind("theme.hoverColor"); +} diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index a88105d..673d812 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -81,7 +81,7 @@ export type ProblemStatus = "passed" | "failed" | "not_test" * shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。 */ export type { Tag, TagCategory } from "@oj2/contract" -export type { ProblemStats, ProblemClassDetail } from "@oj2/contract" +export type { ProblemStats } from "@oj2/contract" export type { TodaySubmissionStatistics } from "@oj2/contract" export type { diff --git a/packages/contract/src/problem.ts b/packages/contract/src/problem.ts index 1487eb3..9391447 100644 --- a/packages/contract/src/problem.ts +++ b/packages/contract/src/problem.ts @@ -449,23 +449,6 @@ export const problemAuthorSchema = z.object({ const failureCountSchema = z.object({ result: judgeStatusSchema, count: z.number().int() }) -/** 老师选中一个班时的明细:谁做对了、谁交了没对(卡在哪)、谁还没交 */ -export const problemClassDetailSchema = z.object({ - className: z.string(), - /** 班里的学生数(学生角色、没禁用) */ - roster: z.number().int(), - /** 这个班一起做这道题的那天(东八区 YYYY-MM-DD,交的人最多的那天)—— 「2025年12月5日做的」 */ - day: z.string(), - failures: z.array(failureCountSchema), - /** 交了没对,次数多的在前。名字是剥掉 ks<班级号> 前缀的那一段 */ - unsolved: z.array( - z.object({ realName: z.string(), attempts: z.number().int(), lastResult: judgeStatusSchema }), - ), - solved: z.array(z.string()), - /** 班里还没交过的 */ - untouched: z.array(z.string()), -}) - /** * 题目页「统计」页签。全部**按人**算,不按提交条数 —— 原来的「通过数」是 AC 条数 * (3092 是 219 条、202 个人),「通过率」是按提交算的,学生看了不知道这题难不难。 @@ -491,20 +474,23 @@ export const problemStatsSchema = z.object({ .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(), - day: z.string(), + /** 班里的学生数(学生角色、没禁用),没交的 = classSize - tried */ + classSize: z.number().int(), + /** 这个班错得最多的那种,一次没错过为 null */ + topFailure: failureCountSchema.nullable(), }), ) .nullable(), - /** 老师选中的那个班(默认 classes 第一个)的明细 */ - classDetail: problemClassDetailSchema.nullable(), }) export type AstRuleEngine = z.infer @@ -523,4 +509,3 @@ export type SqlDisplay = z.infer export type SqlDisplayTable = z.infer export type SqlDisplayColumn = z.infer export type ProblemStats = z.infer -export type ProblemClassDetail = z.infer