diff --git a/apps/api/src/routes/admin/analytics.ts b/apps/api/src/routes/admin/analytics.ts new file mode 100644 index 0000000..6c3bf1e --- /dev/null +++ b/apps/api/src/routes/admin/analytics.ts @@ -0,0 +1,367 @@ +/** + * 学情分析里的「卡点分析」和「历年概况」。 + * + * 两边共用一个口径:**人 × 题**。原来的卡点数的是「错过一次的人」,全部时间、不分班 —— + * 阿福讲白搭显示 325 人卡住,交过的 782 人里其实 753 人后来做对了,排在前面的只是做的人多。 + * 课堂驱动的题最后几乎都会做对(这学期「交过还没做对」最多的一道也就 10 人),真正卡人的是 + * 「要交好几次才对」,所以这里给的是:第一次交就对的人、做对的人、做对之前交了几次。 + * + * - 只算学生(STUDENT_ROLES),只算非比赛提交;没判完的、判题机自己崩了的(SYSTEM_ERROR) + * 不算一次「交」。 + * - 选了时间段时,只算**第一次交**落在这段时间里的人题对,指标按这对的全部历史算 —— + * 否则「最近 7 天」会把上周错、这周对的人算成一次就对。 + * - 「做对平均交几次」只数做对那次及之前的,做对以后又交的不算。 + * + * 路径挂在 /problem-analytics 下,不放进 /problems:Hono 按注册顺序匹配, + * problem.ts 先注册的 `/problems/:id` 会把它整个吃掉(以前真发生过,见 git 历史)。 + */ + +import { + STUDENT_ROLES, + type StuckProblem, + type StuckProblemDetail, + type YearlyOverview, + type YearlySummary, +} from "@oj2/contract" +import { and, eq, inArray, sql, type SQL } from "drizzle-orm" +import { Hono } from "hono" + +import { requireTeacher, type AppEnv } from "../../auth/middleware" +import { db, schema } from "../../db" +import { failure, success } from "../../http" +import { JudgeStatus } from "../../judge/status" +import { schoolYearOf, schoolYearStart, schoolYearStartOf, shiftMonthsByCalendar } from "../../time" +import { queryInteger } from "../helpers" + +export const adminAnalyticsRoutes = new Hono() + +const ACCEPTED = sql.join([JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED], sql`, `) +const NOT_COUNTED = sql.join( + [JudgeStatus.PENDING, JudgeStatus.JUDGING, JudgeStatus.SYSTEM_ERROR], + sql`, `, +) +const STUDENTS = sql.join( + STUDENT_ROLES.map((role) => sql`${role}`), + sql`, `, +) + +/** `start` 是 ISO 时刻,解析不了就当没给(= 全部时间) */ +function parseStart(value: string | undefined) { + if (!value) return null + const time = Date.parse(value) + return Number.isNaN(time) ? null : new Date(time).toISOString() +} + +/** + * 学生的非比赛提交,每条带上「这对人题」第一次做对的时刻和序号。 + * 后面的查询都从这张表聚合成人题对。 + */ +function studentSubmissions(where: SQL[]) { + return sql` + select s.id, s.user_id, s.problem_id, s.create_time, s.result, s.language, u.class_name, + min(s.create_time) filter (where s.result in (${ACCEPTED})) + over (partition by s.user_id, s.problem_id) as ac_at, + row_number() over (partition by s.user_id, s.problem_id order by s.create_time, s.id) as rn + from ${schema.submission} s + join ${schema.user} u on u.id = s.user_id + where s.contest_id is null + and s.result not in (${NOT_COUNTED}) + and u.admin_type in (${STUDENTS}) + ${sql.join( + where.map((condition) => sql`and ${condition}`), + sql` `, + )} + ` +} + +/** 班级精确匹配、时间段只留「在这段时间里交过」的人题对(第一次交在不在段里由 having 再卡) */ +function scope(className: string | undefined, start: string | null, problemId?: number) { + const where: SQL[] = [] + if (problemId) where.push(sql`s.problem_id = ${problemId}`) + if (className) where.push(sql`u.class_name = ${className}`) + if (start) + where.push(sql`(s.user_id, s.problem_id) in ( + select user_id, problem_id from ${schema.submission} + where contest_id is null and create_time >= ${start} + ${problemId ? sql`and problem_id = ${problemId}` : sql``} + )`) + return where +} + +async function knowledgeTags(problemIds: number[]) { + const tags = new Map() + if (!problemIds.length) return tags + const rows = await db + .select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }) + .from(schema.problemTags) + .innerJoin(schema.problemTag, eq(schema.problemTag.id, schema.problemTags.problemtagId)) + .where( + and( + inArray(schema.problemTags.problemId, problemIds), + eq(schema.problemTag.category, "knowledge"), + ), + ) + for (const row of rows) tags.set(row.problemId, [...(tags.get(row.problemId) ?? []), row.name]) + return tags +} + +// ---------------------------------------------------------------- 卡点:每道题一行 + +adminAnalyticsRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => { + const className = c.req.query("className")?.trim() || undefined + const start = parseStart(c.req.query("start")) + const rows = await db.execute<{ + id: number + display_id: string + title: string + users: number + first_try: number + solved: number + solved_tries: number + }>(sql` + with s as (${studentSubmissions(scope(className, start))}), + pairs as ( + select problem_id, + bool_or(rn = 1 and result in (${ACCEPTED})) as first_try, + min(ac_at) as ac_at, + count(*) filter (where ac_at is null or create_time <= ac_at) as tries + from s + group by user_id, problem_id + ${start ? sql`having min(create_time) >= ${start}` : sql``} + ) + select p.id, p._id as display_id, p.title, + count(*)::int as users, + count(*) filter (where first_try)::int as first_try, + count(*) filter (where ac_at is not null)::int as solved, + coalesce(sum(tries) filter (where ac_at is not null), 0)::int as solved_tries + from pairs + join ${schema.problem} p on p.id = pairs.problem_id + group by p.id + `) + const tags = await knowledgeTags(rows.map((row) => row.id)) + return success( + c, + rows.map( + (row) => + ({ + id: row.id, + displayId: row.display_id, + title: row.title, + tags: tags.get(row.id) ?? [], + users: row.users, + firstTry: row.first_try, + solved: row.solved, + solvedTries: row.solved_tries, + }) satisfies StuckProblem, + ), + ) +}) + +// ---------------------------------------------------------------- 卡点:一道题 + +adminAnalyticsRoutes.get("/problem-analytics/stuck/:id", requireTeacher, async (c) => { + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const [problem] = await db + .select({ + id: schema.problem.id, + displayId: schema.problem.displayId, + title: schema.problem.title, + }) + .from(schema.problem) + .where(eq(schema.problem.id, id)) + if (!problem) return failure(c, 404, "problem-not-found", "题目不存在") + + const className = c.req.query("className")?.trim() || undefined + const start = parseStart(c.req.query("start")) + const pairs = sql` + with s as (${studentSubmissions(scope(className, start, id))}), + pairs as ( + select user_id, min(create_time) as first_at, min(ac_at) as ac_at, + bool_or(rn = 1 and result in (${ACCEPTED})) as first_try, + count(*) filter (where ac_at is null or create_time <= ac_at) as tries, + (array_agg(result order by create_time desc, id desc))[1] as last_result, + (array_agg(id order by create_time desc, id desc))[1] as last_id + from s + group by user_id + ${start ? sql`having min(create_time) >= ${start}` : sql``} + ) + ` + const [students, errors, history, tags] = await Promise.all([ + db.execute<{ + username: string + class_name: string | null + first_try: boolean + solved: boolean + tries: number + minutes: number | null + last_result: number + last_id: string + }>(sql` + ${pairs} + select u.username, u.class_name, pairs.first_try, pairs.ac_at is not null as solved, + pairs.tries::int as tries, + round(extract(epoch from pairs.ac_at - pairs.first_at) / 60)::int as minutes, + pairs.last_result, pairs.last_id + from pairs join ${schema.user} u on u.id = pairs.user_id + `), + db.execute<{ result: number; count: number }>(sql` + ${pairs} + select s.result, count(*)::int as count + from s join pairs using (user_id) + where s.result not in (${ACCEPTED}) + group by s.result + order by count desc + `), + // 历年:全站、全部时间,按第一次交落在哪个学年分 + db.execute<{ year: number; users: number; first_try: number }>(sql` + with s as (${studentSubmissions([sql`s.problem_id = ${id}`])}), + pairs as ( + select min(create_time) as first_at, bool_or(rn = 1 and result in (${ACCEPTED})) as first_try + from s group by user_id + ) + select ${schoolYearOf(sql`first_at`)} as year, count(*)::int as users, + count(*) filter (where first_try)::int as first_try + from pairs group by 1 order by 1 + `), + knowledgeTags([id]), + ]) + return success(c, { + problem: { ...problem, tags: tags.get(id) ?? [] }, + students: students.map((row) => ({ + username: row.username, + className: row.class_name, + firstTry: row.first_try, + solved: row.solved, + tries: row.tries, + minutes: row.minutes, + lastResult: row.last_result, + lastSubmissionId: row.last_id, + })), + errors: errors.map((row) => ({ result: row.result, count: row.count })), + history: history.map((row) => ({ year: row.year, users: row.users, firstTry: row.first_try })), + } satisfies StuckProblemDetail) +}) + +// ---------------------------------------------------------------- 历年概况 + +/** 「在用的年级」:这段时间里至少这么多人交过题的年级才列 */ +const GRADE_MIN_USERS = 20 + +adminAnalyticsRoutes.get("/problem-analytics/yearly", requireTeacher, async (c) => { + const now = new Date() + const current = schoolYearStart(now) + const [first] = await db.execute<{ year: number | null }>(sql` + select ${schoolYearOf(sql`min(create_time)`)} as year + from ${schema.submission} where contest_id is null + `) + const firstYear = Math.min(first?.year ?? current.year, current.year) + + // 每个完整学年、进行中的学年(到现在),外加去年同期(上一学年开学到去年的今天) + const ranges: { key: number; year: number; from: string; to: string }[] = [] + for (let year = firstYear; year <= current.year; year += 1) { + const to = year === current.year ? now.toISOString() : schoolYearStartOf(year + 1) + ranges.push({ key: year, year, from: schoolYearStartOf(year), to }) + } + const hasLastYear = current.year - 1 >= firstYear + if (hasLastYear) + ranges.push({ + key: -1, + year: current.year - 1, + from: schoolYearStartOf(current.year - 1), + to: shiftMonthsByCalendar(now, -12).toISOString(), + }) + + const values = sql.join( + ranges.map( + (range) => sql`(${range.key}::int, ${range.from}::timestamptz, ${range.to}::timestamptz)`, + ), + sql`, `, + ) + const rows = await db.execute<{ + key: number + users: number + submissions: number + problems: number + c_submissions: number + solved_pairs: number + started_pairs: number + first_try_pairs: number + solved_started: number + solved_tries: number + grades: string[] | null + }>(sql` + with ranges(key, f, t) as (values ${values}), + s as (${studentSubmissions([])}), + pairs as ( + select min(create_time) as first_at, min(ac_at) as ac_at, + bool_or(rn = 1 and result in (${ACCEPTED})) as first_try, + count(*) filter (where ac_at is null or create_time <= ac_at) as tries + from s group by user_id, problem_id + ), + sub_stats as ( + select r.key, count(distinct s.user_id)::int as users, count(*)::int as submissions, + count(distinct s.problem_id)::int as problems, + count(*) filter (where s.language in ('C', 'C++'))::int as c_submissions + from s join ranges r on s.create_time >= r.f and s.create_time < r.t + group by r.key + ), + pair_stats as ( + select r.key, + count(*) filter (where p.ac_at >= r.f and p.ac_at < r.t)::int as solved_pairs, + count(*) filter (where p.first_at >= r.f and p.first_at < r.t)::int as started_pairs, + count(*) filter (where p.first_at >= r.f and p.first_at < r.t and p.first_try)::int + as first_try_pairs, + count(*) filter (where p.first_at >= r.f and p.first_at < r.t and p.ac_at is not null)::int + as solved_started, + coalesce(sum(p.tries) filter ( + where p.first_at >= r.f and p.first_at < r.t and p.ac_at is not null + ), 0)::int as solved_tries + from pairs p join ranges r + on (p.first_at >= r.f and p.first_at < r.t) or (p.ac_at >= r.f and p.ac_at < r.t) + group by r.key + ), + grade_stats as ( + select key, json_agg(grade order by grade) as grades from ( + select r.key, left(s.class_name, 2) as grade + from s join ranges r on s.create_time >= r.f and s.create_time < r.t + where s.class_name ~ '^[0-9]{3,}$' + group by r.key, left(s.class_name, 2) + having count(distinct s.user_id) >= ${GRADE_MIN_USERS} + ) g group by key + ) + select r.key, coalesce(sub.users, 0) as users, coalesce(sub.submissions, 0) as submissions, + coalesce(sub.problems, 0) as problems, coalesce(sub.c_submissions, 0) as c_submissions, + coalesce(ps.solved_pairs, 0) as solved_pairs, coalesce(ps.started_pairs, 0) as started_pairs, + coalesce(ps.first_try_pairs, 0) as first_try_pairs, + coalesce(ps.solved_started, 0) as solved_started, + coalesce(ps.solved_tries, 0) as solved_tries, gs.grades + from ranges r + left join sub_stats sub on sub.key = r.key + left join pair_stats ps on ps.key = r.key + left join grade_stats gs on gs.key = r.key + `) + + const byKey = new Map(rows.map((row) => [row.key, row])) + const summary = (range: (typeof ranges)[number]): YearlySummary => { + const row = byKey.get(range.key) + return { + year: range.year, + until: range.to, + grades: row?.grades ?? [], + users: row?.users ?? 0, + submissions: row?.submissions ?? 0, + problems: row?.problems ?? 0, + solvedPairs: row?.solved_pairs ?? 0, + startedPairs: row?.started_pairs ?? 0, + firstTryPairs: row?.first_try_pairs ?? 0, + solvedStarted: row?.solved_started ?? 0, + solvedTries: row?.solved_tries ?? 0, + cSubmissions: row?.c_submissions ?? 0, + } + } + const lastYear = ranges.find((range) => range.key === -1) + return success(c, { + years: ranges.filter((range) => range.key !== -1).map(summary), + lastYearToDate: lastYear ? summary(lastYear) : null, + } satisfies YearlyOverview) +}) diff --git a/apps/api/src/routes/admin/index.ts b/apps/api/src/routes/admin/index.ts index c557c75..b77e1c7 100644 --- a/apps/api/src/routes/admin/index.ts +++ b/apps/api/src/routes/admin/index.ts @@ -4,6 +4,7 @@ import type { AppEnv } from "../../auth/middleware" import { adminAccountRoutes } from "./account" import { adminAchievementRoutes } from "./achievement" import { adminAiRoutes } from "./ai" +import { adminAnalyticsRoutes } from "./analytics" import { adminConfRoutes } from "./conf" import { adminContestRoutes } from "./contest" import { adminLearnRoutes } from "./learn" @@ -26,6 +27,7 @@ export const adminRoutes = new Hono() adminRoutes.route("/", adminAccountRoutes) adminRoutes.route("/", adminAchievementRoutes) adminRoutes.route("/", adminAiRoutes) +adminRoutes.route("/", adminAnalyticsRoutes) adminRoutes.route("/", adminConfRoutes) adminRoutes.route("/", adminContestRoutes) adminRoutes.route("/", adminLearnRoutes) diff --git a/apps/api/src/routes/admin/tag.ts b/apps/api/src/routes/admin/tag.ts index 1daad14..f86b4b0 100644 --- a/apps/api/src/routes/admin/tag.ts +++ b/apps/api/src/routes/admin/tag.ts @@ -3,45 +3,24 @@ import { generateFlowchartRequestSchema, renameTagRequestSchema, setTagCategoryRequestSchema, - type AcTrend, type AdminTag, type BatchProblemTagResponse, type GenerateFlowchartResponse, type RenameTagResponse, - type StuckProblem, } from "@oj2/contract" -import { - and, - asc, - countDistinct, - count, - desc, - eq, - gte, - ilike, - inArray, - isNull, - lte, - ne, - sql, -} from "drizzle-orm" +import { and, asc, countDistinct, desc, eq, ilike, inArray, isNull, ne, sql } from "drizzle-orm" import { Hono } from "hono" -import { requireProblemPermission, requireTeacher, type AppEnv } from "../../auth/middleware" +import { requireProblemPermission, type AppEnv } from "../../auth/middleware" import type { AuthUser } from "../../auth/session" import { db, schema } from "../../db" import { failure, parseBody, success } from "../../http" -import { JudgeStatus } from "../../judge/status" import { completeChat } from "../../services/ai" -import { localTime, localYear } from "../../time" -import { queryInteger, rounded } from "../helpers" +import { queryInteger } from "../helpers" import { canEdit, findTagsByName, normalizeTagNames } from "./problem" export const adminTagRoutes = new Hono() -const ACCEPTED = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] -const FAILED = [JudgeStatus.WRONG_ANSWER, JudgeStatus.COMPILE_ERROR, JudgeStatus.RUNTIME_ERROR] - /** 能管所有题目:超管,或 problemPermission 为 All */ function canManageAllProblems(user: AuthUser) { return user.adminType === "Super Admin" || user.problemPermission === "All" @@ -273,132 +252,6 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async ( return success(c, { visible: !problem.visible }) }) -// ---------------------------------------------------------------- 卡点题目 / AC 趋势 - -// 路径特意不放在 /problems 下:Hono 按**注册顺序**匹配(不是静态优先), -// 而 problem.ts 的 `GET /problems/:id` 先注册,会把 `/problems/stuck` 整个吃掉 —— -// 这两个 handler 曾经从未执行过,生效的还是那边的 requireProblemPermission 而非这里的 -// requireTeacher,而且完全没有报错。换个前缀,结构上就不可能再被遮蔽。 -adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => { - const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})` - const rows = await db - .select({ - displayId: schema.problem.displayId, - title: schema.problem.title, - total: count(), - accepted: - sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith( - Number, - ), - failed: sql`count(*) ${failedFilter}`.mapWith(Number), - failedUsers: sql`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith( - Number, - ), - }) - .from(schema.submission) - .innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) - /** - * 只看公共题,和隔壁 ac-trend 同一个口径。原来这里一个 where 都没有,比赛题 - * 也进榜 —— 而比赛题的题号是每场比赛各自从 1 开始编的(快照里 61 道不同的题 - * 都叫「1」),一旦挤进前 40,那一行显示的题号会指向一道根本不存在的公共题。 - * - * 眼下还没发生:前 40 的门槛是 97 人卡住,比赛题最多的一道是 59 人。但两个班 - * 一起考的场次有 95 人,撞上一道难题就够得着了。 - * - * 加了这条对公共题的数字**没有任何影响**:比赛提交挂的是比赛自己的 problem 行 - * (快照实测两个方向的交叉都是 0 条),公共题那一行本来就只统计自己的提交。 - * 顺带让这条查询能用上 0013 的 submission_public_metrics_idx,173ms → 82ms。 - */ - .where(isNull(schema.submission.contestId)) - .groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title) - .having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`) - .orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`)) - .limit(40) - return success( - c, - rows.map( - (row) => - ({ - problemId: row.displayId, - problemTitle: row.title, - total: row.total, - failed: row.failed, - failedUsers: row.failedUsers, - acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0, - }) satisfies StuckProblem, - ), - ) -}) - -adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => { - const currentYear = localYear() - // 参数按旧后端的口径夹逼:越界一律回落到默认值,不报错 - let sinceYear = queryInteger(c.req.query("sinceYear"), 2023) - if (sinceYear < 2022 || sinceYear > currentYear) sinceYear = 2023 - let untilYear = queryInteger(c.req.query("untilYear"), currentYear) - if (untilYear < sinceYear || untilYear > currentYear) untilYear = currentYear - 1 - let minPerYear = queryInteger(c.req.query("minPerYear"), 100) - if (![50, 100, 200].includes(minPerYear)) minPerYear = 100 - - // 年份按东八区切,和上面 `currentYear` 的夹逼同口径 - const year = sql`extract(year from ${localTime(schema.submission.createTime)})`.mapWith( - Number, - ) - const rows = await db - .select({ - problemId: schema.problem.id, - displayId: schema.problem.displayId, - title: schema.problem.title, - year, - total: count(), - accepted: - sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith( - Number, - ), - }) - .from(schema.submission) - .innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) - .where(and(isNull(schema.submission.contestId), gte(year, sinceYear), lte(year, untilYear))) - .groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title, year) - .orderBy(asc(schema.problem.id), asc(year)) - - const required = new Set() - for (let y = sinceYear; y <= untilYear; y += 1) required.add(y) - - const grouped = new Map() - for (const row of rows) { - const bucket = grouped.get(row.problemId) - if (bucket) bucket.yearly.push(row) - else - grouped.set(row.problemId, { - displayId: row.displayId, - title: row.title, - yearly: [row], - }) - } - - const result = [] - for (const entry of grouped.values()) { - const years = new Set(entry.yearly.map((row) => row.year)) - // 每一年都得有数据,且每年提交量都超过门槛 —— 否则趋势没有可比性 - if (![...required].every((y) => years.has(y))) continue - if (!entry.yearly.every((row) => row.total > minPerYear)) continue - result.push({ - problemId: entry.displayId, - problemTitle: entry.title, - yearly: entry.yearly - .map((row) => ({ - year: row.year, - total: row.total, - accepted: row.accepted, - acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0, - })) - .sort((left, right) => left.year - right.year), - } satisfies AcTrend) - } - return success(c, result) -}) - // ---------------------------------------------------------------- Python → 流程图 adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) => { diff --git a/apps/api/src/time.ts b/apps/api/src/time.ts index dde0762..8e3193d 100644 --- a/apps/api/src/time.ts +++ b/apps/api/src/time.ts @@ -95,6 +95,21 @@ export function termStart(now: Date | number | string = new Date()): string { return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString() } +/** + * 学年从哪天零点(东八区)算起:9 月 1 日。返回 `{ year, start }`,`year` 是起始年 + * (2025 = 25–26 学年),`start` 是 ISO 字符串。SQL 里按学年分组用 `schoolYearOf()`,两边同一个口径。 + */ +export function schoolYearStart(now: Date | number | string = new Date()) { + const wall = toWallClock(now) + const year = wall.getUTCMonth() + 1 >= 9 ? wall.getUTCFullYear() : wall.getUTCFullYear() - 1 + return { year, start: schoolYearStartOf(year) } +} + +/** 某个学年起始年的 9 月 1 日零点(东八区),ISO 字符串 */ +export function schoolYearStartOf(year: number): string { + return fromWallClock(new Date(Date.UTC(year, 8, 1))).toISOString() +} + /** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */ export function shiftMonthsByCalendar(instant: Date, months: number): Date { const wall = toWallClock(instant) @@ -121,3 +136,8 @@ const TIME_ZONE_SQL = sql.raw(`'${TIME_ZONE}'`) export function localTime(column: SQLWrapper) { return sql`(${column} at time zone ${TIME_ZONE_SQL})` } + +/** 时刻所在学年的起始年(9 月起算),和 `schoolYearStart()` 同一个口径 */ +export function schoolYearOf(column: SQLWrapper) { + return sql`extract(year from ${localTime(column)} - interval '8 months')::int` +} diff --git a/apps/web/src/admin/problem/TopACTrend.vue b/apps/web/src/admin/problem/TopACTrend.vue deleted file mode 100644 index fef4ae0..0000000 --- a/apps/web/src/admin/problem/TopACTrend.vue +++ /dev/null @@ -1,189 +0,0 @@ - - - - - diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index f0fadb1..490e274 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -534,26 +534,81 @@ export const batchProblemTagResponseSchema = z.object({ tagCount: z.number().int(), }) +/** + * 卡点分析的一道题(一行)。口径是「人 × 题」:只算**第一次交**落在所选时间段里的那些 + * 人题对,指标按这对的全部历史算(做没做对、交了几次才对)。比率由前端按人数算, + * 这样知识点的汇总能按所选门槛过滤后再加权。 + */ export const stuckProblemSchema = z.object({ - problemId: z.string(), - problemTitle: z.string(), - total: z.number().int(), - failed: z.number().int(), - failedUsers: z.number().int(), - acRate: z.number(), + id: z.number().int(), + displayId: z.string(), + title: z.string(), + /** 知识点(category = knowledge 的标签) */ + tags: z.array(z.string()), + /** 做过的人 */ + users: z.number().int(), + /** 第一次交就对的人 */ + firstTry: z.number().int(), + /** 做对了的人 */ + solved: z.number().int(), + /** 做对的人「做对那次及之前」一共交了几次,加总 —— 除以 solved 就是平均 */ + solvedTries: z.number().int(), }) -export const acTrendYearSchema = z.object({ +export const stuckStudentSchema = z.object({ + username: z.string(), + className: z.string().nullable(), + firstTry: z.boolean(), + solved: z.boolean(), + /** 做对了:做对那次及之前交了几次;没做对:一共交了几次 */ + tries: z.number().int(), + /** 第一次交到做对隔了几分钟,没做对是 null */ + minutes: z.number().int().nullable(), + lastResult: z.number().int(), + lastSubmissionId: z.string(), +}) + +export const stuckProblemDetailSchema = z.object({ + problem: z.object({ + id: z.number().int(), + displayId: z.string(), + title: z.string(), + tags: z.array(z.string()), + }), + students: z.array(stuckStudentSchema), + /** 这些人错的那些次,按判题结果分 */ + errors: z.array(z.object({ result: z.number().int(), count: z.number().int() })), + /** 全站历年(学年按 9 月起算,`year` 是起始年),不受班级和时间段影响 */ + history: z.array( + z.object({ year: z.number().int(), users: z.number().int(), firstTry: z.number().int() }), + ), +}) + +export const yearlySummarySchema = z.object({ + /** 学年起始年,2025 = 25–26 学年 */ year: z.number().int(), - total: z.number().int(), - accepted: z.number().int(), - acRate: z.number(), + /** 统计到哪天为止(不含),进行中的学年和去年同期才有意义 */ + until: z.string(), + grades: z.array(z.string()), + users: z.number().int(), + submissions: z.number().int(), + problems: z.number().int(), + /** 这段时间里第一次做对的人题对 */ + solvedPairs: z.number().int(), + /** 第一次交在这段时间里的人题对,和其中第一次就对的 */ + startedPairs: z.number().int(), + firstTryPairs: z.number().int(), + /** 上面那批里做对了的,以及它们做对那次及之前一共交了几次 */ + solvedStarted: z.number().int(), + solvedTries: z.number().int(), + cSubmissions: z.number().int(), }) -export const acTrendSchema = z.object({ - problemId: z.string(), - problemTitle: z.string(), - yearly: z.array(acTrendYearSchema), +export const yearlyOverviewSchema = z.object({ + /** 从最早的学年到现在这个学年(最后一个是进行中的,只算到今天) */ + years: z.array(yearlySummarySchema), + /** 去年同期:上一个学年开学到「去年的今天」 */ + lastYearToDate: yearlySummarySchema.nullable(), }) // ---------------------------------------------------------------- 自学情况 @@ -943,12 +998,15 @@ export type AdminAiReportListItem = z.infer export type AdminAiReport = z.infer export type AdminAiReportList = z.infer export type StuckProblem = z.infer +export type StuckStudent = z.infer +export type StuckProblemDetail = z.infer +export type YearlySummary = z.infer +export type YearlyOverview = z.infer export type LearnBoard = z.infer export type LearnBoardStudent = z.infer export type LearnExerciseProgress = z.infer export type LearnExerciseProgressList = z.infer export type LearnExerciseAttempt = z.infer -export type AcTrend = z.infer export type AdminTag = z.infer export type RenameTagResponse = z.infer @@ -1010,7 +1068,6 @@ export type CreateProblemSetBadgeRequest = z.infer export type RenameTagRequest = z.infer export type BatchProblemTagRequest = z.infer -export type AcTrendYear = z.infer export type GenerateFlowchartRequest = z.infer export type GenerateFlowchartResponse = z.infer export type UpdateProblemRequest = z.infer