diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index 1633d91..cd4c75b 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -23,7 +23,7 @@ import { generateFilteredHint } from "../services/hint-filter" import { buildDetail, buildDuration, listSolved } from "../services/learning-stats" import { decideHintLevel } from "../services/hint-level" import { hintDiagnosis, hintPrompt, referenceAnswer } from "../services/hint-diagnosis" -import { classDetail } from "../services/ranking" +import { classDetail, classPk } from "../services/ranking" import { consumeToken } from "../services/throttling" import { calendarDay, dayNumber, dayText, localTime, localWeekday } from "../time" import { countFailedSubmissions, isTeacherOrAbove, asRecord, queryInteger } from "./helpers" @@ -385,8 +385,46 @@ aiRoutes.post("/ai/class-pk-analysis", requireAuth, async (c) => { if (!parsed.success) return parsed.response const limited = await throttleAi(c) if (limited) return limited + // 数字和页面上的是同一份(services/ranking.ts 的 classPk),不再由前端整包传上来 + const pk = await classPk([...new Set(parsed.data.classNames)], parsed.data.period, null) + const label = (name: string) => `${name.slice(0, 2)}计算机${name.slice(2)}班` + const facts = { + 时间段: parsed.data.period === "week" ? "这周" : "这学期", + 各班: pk.classes.map((item) => ({ + 班级: label(item.className), + 人数: item.members, + 人均做对: item.perCapita, + 中间那位做对: item.median, + 做对过题的人: item.solvedMembers, + 一次就对百分比: item.firstPercent, + 同一批题里领先几道: item.lead, + 这学期每周人均新做对: item.weekly, + })), + 同一批题: pk.problems.map((problem) => ({ + 题目: problem.title, + 各班: problem.cells.map((cell, index) => + cell + ? { + 班级: label(pk.classes[index]!.className), + 做对的人占全班百分比: cell.percent, + 一次就对百分比: cell.firstPercent, + 交过的人: cell.tried, + 最好: cell.best, + } + : { 班级: label(pk.classes[index]!.className), 没布置: true }, + ), + })), + 只有一个班布置过的题数: pk.solo.length, + } return streamChat( - "你是编程教育数据分析专家。根据多个班级 OJ 对比数据,从排名、参与度、典型学生水平、均衡性、梯队、提交质量和教学建议七方面输出中文 Markdown 报告。", - `${parsed.data.timeRangeLabel}\n${JSON.stringify(parsed.data.comparisons)}`, + [ + "你是编程课老师的助教。下面是几个班在 OJ 上的对比数据(JSON)。各班进度不同、布置的题不一样,", + "所以比较以「同一批题」(至少两个班都布置过的题)为主,人均做对只作参考。", + "用中文 Markdown 写一份给老师看的简短分析,分三到四段,每段一两句,段首加粗:", + "**整体**(人均和同一批题里各班领先几道,人均差距有多少是进度造成的)、", + "**差得最多的题**(点题目名,哪个班明显低)、**一次就对**(哪个班更稳、哪个班靠多交几次)、", + "**建议**(下节课能直接做的事)。用大白话,不要出现四分位数、标准差这类统计词,不要编数据里没有的数字。", + ].join(""), + JSON.stringify(facts), ) }) diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index b8f2d16..f374b14 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -1,6 +1,5 @@ import { isSqlProblem, - classComparisonRequestSchema, STUDENT_ROLES, classLessonRequestSchema, type ClassActivity, @@ -8,26 +7,11 @@ import { type ClassBoardStudent, type ClassLesson, type ClassActivityProblem, - type ClassComparison, - type ClassComparisonResponse, type LastVisit, type LessonLanguage, FLOWCHART_PASS_GRADES, } from "@oj2/contract" -import { - and, - asc, - desc, - eq, - gte, - inArray, - isNotNull, - isNull, - like, - lt, - lte, - sql, -} from "drizzle-orm" +import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from "drizzle-orm" import { Hono } from "hono" import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" @@ -37,75 +21,9 @@ import { failure, parseBody, success } from "../http" import { JudgeStatus } from "../judge/status" import { accepted } from "../services/learning-stats" import { calendarDay, dayStart, localTime } from "../time" -import { rounded } from "./helpers" export const classroomRoutes = new Hono() -interface ClassUser { - userId: number - username: string - className: string - acceptedNumber: number - submissionNumber: number -} - -/** - * 入班学生的 AC/提交数。`gradePrefix` 是年级(班号形如 `241` = 24 级 1 班), - * 走 SQL 的 like 而不是拉全表再在内存里 startsWith —— 班级榜每换一次年级就要跑一遍, - * 没必要每次都把全校一千多号人搬进进程。年级在调用处已校验为纯数字,不含 like 通配符。 - */ -async function loadClassUsers(classNames?: string[], gradePrefix?: string) { - const filters = [ - eq(schema.user.isDisabled, false), - inArray(schema.user.adminType, [...STUDENT_ROLES]), - sql`${schema.user.className} is not null`, - ] - if (classNames) filters.push(inArray(schema.user.className, classNames)) - if (gradePrefix) filters.push(like(schema.user.className, `${gradePrefix}%`)) - const rows = await db - .select({ - userId: schema.user.id, - username: schema.user.username, - className: schema.user.className, - acceptedNumber: schema.userProfile.acceptedNumber, - submissionNumber: schema.userProfile.submissionNumber, - }) - .from(schema.user) - .innerJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) - .where(and(...filters)) - return rows.filter((row): row is ClassUser => row.className !== null) -} - -function mean(values: number[]) { - return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0 -} - -function median(values: number[]) { - if (!values.length) return 0 - const sorted = [...values].sort((a, b) => a - b) - const middle = Math.floor(sorted.length / 2) - return sorted.length % 2 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2 -} - -function quantile(values: number[], p: number) { - if (values.length <= 1) return values[0] ?? 0 - const sorted = [...values].sort((a, b) => a - b) - const position = (sorted.length + 1) * p - 1 - if (position <= 0) return sorted[0]! - if (position >= sorted.length - 1) return sorted.at(-1)! - const lower = Math.floor(position) - const fraction = position - lower - return sorted[lower]! + (sorted[lower + 1]! - sorted[lower]!) * fraction -} - -function sampleStdDev(values: number[]) { - if (values.length <= 1) return 0 - const average = mean(values) - return Math.sqrt( - values.reduce((sum, value) => sum + (value - average) ** 2, 0) / (values.length - 1), - ) -} - /** * 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的: * (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题), @@ -892,111 +810,3 @@ classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => { }) return success(c, null) }) - -classroomRoutes.post("/classes/comparison", async (c) => { - const parsed = await parseBody(c, classComparisonRequestSchema, "At least one class is required") - if (!parsed.success) return parsed.response - const users = await loadClassUsers(parsed.data.classNames) - const allAc = users.map((user) => user.acceptedNumber) - const globalQ1 = quantile(allAc, 0.25) - const globalQ3 = quantile(allAc, 0.75) - const byClass = new Map() - for (const user of users) - byClass.set(user.className, [...(byClass.get(user.className) ?? []), user]) - - let recentByUser = new Map>() - let recentSubmissionCount = new Map() - const hasTimeRange = Boolean(parsed.data.startTime && parsed.data.endTime) - if (hasTimeRange) { - const rows = await db - .select({ - userId: schema.submission.userId, - problemId: schema.submission.problemId, - result: schema.submission.result, - }) - .from(schema.submission) - .where( - and( - inArray( - schema.submission.userId, - users.map((user) => user.userId), - ), - gte(schema.submission.createTime, parsed.data.startTime!), - lte(schema.submission.createTime, parsed.data.endTime!), - ), - ) - const userClass = new Map(users.map((user) => [user.userId, user.className])) - for (const row of rows) { - const className = userClass.get(row.userId) - if (!className) continue - recentSubmissionCount.set(className, (recentSubmissionCount.get(className) ?? 0) + 1) - if ([JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(row.result as 0 | 10)) { - const set = recentByUser.get(row.userId) ?? new Set() - set.add(row.problemId) - recentByUser.set(row.userId, set) - } - } - } - - const comparisons = [...byClass].map(([className, members]) => { - const ac = members.map((member) => member.acceptedNumber).sort((a, b) => b - a) - const submissions = members.map((member) => member.submissionNumber).sort((a, b) => b - a) - const userCount = members.length - const topCount = Math.max(1, Math.ceil(userCount * 0.1)) - const bottomCount = topCount - const middle = topCount + bottomCount < userCount ? ac.slice(topCount, -bottomCount) : ac - const totalAc = ac.reduce((sum, value) => sum + value, 0) - const totalSubmission = submissions.reduce((sum, value) => sum + value, 0) - const base: ClassComparison = { - className, - userCount, - totalAc, - totalSubmission, - avgAc: rounded(mean(ac)), - medianAc: rounded(median(ac)), - q1Ac: rounded(quantile(ac, 0.25)), - q3Ac: rounded(quantile(ac, 0.75)), - iqr: rounded(quantile(ac, 0.75) - quantile(ac, 0.25)), - stdDev: rounded(sampleStdDev(ac)), - top10Avg: rounded(mean(ac.slice(0, topCount))), - middle80Avg: rounded(mean(middle)), - bottom10Avg: rounded(mean(ac.slice(-bottomCount))), - excellentRate: rounded((ac.filter((value) => value >= globalQ3).length / userCount) * 100), - passRate: rounded((ac.filter((value) => value >= globalQ1).length / userCount) * 100), - activeRate: rounded((submissions.filter((value) => value > 0).length / userCount) * 100), - acRate: totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0, - compositeScore: 0, - } - if (hasTimeRange) { - const recent = members - .map((member) => recentByUser.get(member.userId)?.size ?? 0) - .sort((a, b) => b - a) - base.recentTotalAc = recent.reduce((sum, value) => sum + value, 0) - base.recentTotalSubmission = recentSubmissionCount.get(className) ?? 0 - base.recentAvgAc = rounded(mean(recent)) - base.recentMedianAc = rounded(median(recent)) - base.recentTop10Avg = rounded( - mean(recent.slice(0, Math.max(1, Math.ceil(recent.length * 0.1)))), - ) - base.recentActiveCount = recent.filter((value) => value > 0).length - } - return base - }) - const maxMedian = Math.max(1, ...comparisons.map((item) => item.medianAc)) - const maxMiddle = Math.max(1, ...comparisons.map((item) => item.middle80Avg)) - for (const item of comparisons) { - item.compositeScore = rounded( - 0.4 * ((item.medianAc / maxMedian) * 100) + - 0.15 * ((item.middle80Avg / maxMiddle) * 100) + - 0.2 * item.activeRate + - 0.15 * item.passRate + - 0.1 * item.excellentRate, - 1, - ) - } - comparisons.sort((a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc) - return success(c, { - comparisons, - hasTimeRange, - } satisfies ClassComparisonResponse) -}) diff --git a/apps/api/src/routes/ranking.ts b/apps/api/src/routes/ranking.ts index 7793dba..5ff324d 100644 --- a/apps/api/src/routes/ranking.ts +++ b/apps/api/src/routes/ranking.ts @@ -1,4 +1,5 @@ import { + classPkPeriodSchema, rankHiddenRequestSchema, rankPeriodSchema, rankScopeSchema, @@ -13,7 +14,7 @@ import { import { and, countDistinct, desc, eq, gte, inArray, isNotNull, ne } from "drizzle-orm" import { Hono } from "hono" -import { optionalAuth, requireTeacher, type AppEnv } from "../auth/middleware" +import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" import { db, schema } from "../db" import { failure, parseBody, success } from "../http" import { dayStart, termStart, weekStart } from "../time" @@ -21,7 +22,10 @@ import { audienceWhere, classBattle, classDetail, + classPk, loadEntrants, + PK_MAX_CLASSES, + pkPartner, rankedStudents, standings, WEEK_MS, @@ -252,6 +256,38 @@ rankingRoutes.get("/rankings/class-detail", optionalAuth, async (c) => { return success(c, await classDetail(className, isTeacherOrAbove(c.get("user")))) }) +/** + * 班级 PK(设计稿「班级 PK 重设计」定稿):`classes` 逗号分隔。谁登录了都能看 —— 只有班级 + * 层面的数,没有点名到人。只给一个班(或者不给,用自己的班 / 老师默认的班)就配一个对手, + * 见 `pkPartner`。 + */ +rankingRoutes.get("/rankings/pk", requireAuth, async (c) => { + const user = c.get("user")! + const period = classPkPeriodSchema.safeParse(c.req.query("period") ?? "term") + if (!period.success) return failure(c, 400, "invalid-query", "时间段不对") + const requested = [ + ...new Set( + (c.req.query("classes") ?? "") + .split(",") + .map((name) => name.trim()) + .filter((name) => name && name.length <= 32), + ), + ] + if (requested.length > PK_MAX_CLASSES) + return failure(c, 400, "too-many-classes", `一次最多比 ${PK_MAX_CLASSES} 个班`) + + const isTeacher = isTeacherOrAbove(user) + const mine = isTeacher ? null : user.className || null + const classNames = requested.length + ? requested + : [isTeacher ? await defaultClassFor(user.id) : mine].filter((name): name is string => !!name) + if (classNames.length === 1) { + const partner = await pkPartner(classNames[0]!) + if (partner) classNames.push(partner) + } + return success(c, await classPk(classNames, period.data, mine)) +}) + /** 本班每周冠军:最近几个已经结束的周,每周新做对最多的那个人(一样多先做到的) */ rankingRoutes.get("/rankings/champions", optionalAuth, async (c) => { const className = await resolveGroup(c.get("user"), "class", c.req.query("className")?.trim()) diff --git a/apps/api/src/services/ranking.ts b/apps/api/src/services/ranking.ts index d50db14..270627f 100644 --- a/apps/api/src/services/ranking.ts +++ b/apps/api/src/services/ranking.ts @@ -2,6 +2,10 @@ import { STUDENT_ROLES, type ClassBattleItem, type ClassDetail, + type ClassPk, + type ClassPkCell, + type ClassPkClass, + type ClassPkPeriod, type RankScope, } from "@oj2/contract" import { @@ -15,6 +19,7 @@ import { like, min, ne, + sql, type SQL, } from "drizzle-orm" @@ -308,3 +313,221 @@ export async function classDetail(className: string, withCare: boolean): Promise care, } satisfies ClassDetail } + +/** 一次最多比几个班(对照格再宽就放不下了) */ +export const PK_MAX_CLASSES = 8 + +interface PkPair { + className: string + problemId: number + solved: boolean + firstSolved: boolean + submissions: number +} + +/** + * 这几个班从 `since` 起每个人每道题:交了几次、做对没有、第一次交就对没有。比赛里的提交不算, + * 入榜人群和排名页一样(不计入排名的人不算)。「第一次」是这段时间里的第一次。 + */ +async function pkPairs(classNames: string[], since: string): Promise { + const rows = await db + .select({ + className: schema.user.className, + problemId: schema.submission.problemId, + submissions: count(), + solved: sql`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`, + first: sql`(array_agg(${schema.submission.result} order by ${schema.submission.createTime}, ${schema.submission.id}))[1]`, + }) + .from(schema.submission) + .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) + .where( + and( + rankedStudents, + inArray(schema.user.className, classNames), + isNull(schema.submission.contestId), + gte(schema.submission.createTime, since), + ), + ) + .groupBy(schema.user.className, schema.submission.userId, schema.submission.problemId) + return rows.map((row) => ({ + className: row.className!, + problemId: row.problemId, + solved: row.solved, + firstSolved: (SOLVED_RESULTS as number[]).includes(row.first), + submissions: row.submissions, + })) +} + +/** 每个班布置过哪些题:过半的人交过就算 */ +function assignedSets(pairs: PkPair[], members: Map) { + const tried = new Map>() + for (const pair of pairs) { + const byProblem = tried.get(pair.className) ?? new Map() + byProblem.set(pair.problemId, (byProblem.get(pair.problemId) ?? 0) + 1) + tried.set(pair.className, byProblem) + } + const sets = new Map>() + for (const [className, byProblem] of tried) { + const n = members.get(className) ?? 0 + sets.set( + className, + new Set([...byProblem].filter(([, people]) => n > 0 && people * 2 >= n).map(([id]) => id)), + ) + } + return sets +} + +function memberCounts(entrants: Entrant[]) { + const members = new Map() + for (const entrant of entrants) + members.set(entrant.className!, (members.get(entrant.className!) ?? 0) + 1) + return members +} + +/** + * 只给了一个班(或者没给、用看的人自己的班)时,配一个对手:同年级在用的班里,和它一起 + * 布置过的题最多的那个;一样多挑班级对抗里排得更前的(用户 2026-10-10 定的)。 + * 同年级没有别的在用的班就不配。 + */ +export async function pkPartner(className: string) { + const { term, entrants } = await classedEntrants() + const battle = battleFrom(entrants) + const grade = className.slice(0, 2) + const candidates = battle.filter( + (item) => item.className !== className && item.className.slice(0, 2) === grade, + ) + if (!candidates.length) return null + const names = [className, ...candidates.map((item) => item.className)] + const sets = assignedSets( + await pkPairs(names, term), + memberCounts(entrants.filter((entrant) => names.includes(entrant.className!))), + ) + const mine = sets.get(className) ?? new Set() + const common = (name: string) => [...(sets.get(name) ?? [])].filter((id) => mine.has(id)).length + return candidates.sort((a, b) => common(b.className) - common(a.className) || a.rank - b.rank)[0]! + .className +} + +/** + * 班级 PK 的全部数字(页面和 AI 分析共用)。人均、分布按 `period` 算;每周人均(赛跑) + * 一直按这学期,横轴才有东西。 + */ +export async function classPk( + classNames: string[], + period: ClassPkPeriod, + mine: string | null, +): Promise { + const { term, entrants: all } = await classedEntrants() + const since = period === "week" ? weekStart() : term + const sinceMs = Date.parse(since) + const battle = battleFrom(all) + const entrants = all.filter((entrant) => classNames.includes(entrant.className!)) + const members = memberCounts(entrants) + const pairs = await pkPairs(classNames, since) + const assigned = assignedSets(pairs, members) + + const firstWeek = Date.parse(weekStart(term)) + const thisWeek = Date.parse(weekStart()) + const weeks: number[] = [] + for (let from = firstWeek; from <= thisWeek; from += WEEK_MS) weeks.push(from) + + // 每道题、每个班:交过 / 做对 / 一次就对 / 提交次数 + const cells = new Map< + number, + Map> + >() + for (const pair of pairs) { + if (!assigned.get(pair.className)?.has(pair.problemId)) continue + const byClass = cells.get(pair.problemId) ?? new Map() + const cell = byClass.get(pair.className) ?? { tried: 0, solved: 0, firstTry: 0, submissions: 0 } + cell.tried++ + if (pair.solved) cell.solved++ + if (pair.firstSolved) cell.firstTry++ + cell.submissions += pair.submissions + byClass.set(pair.className, cell) + cells.set(pair.problemId, byClass) + } + const titles = cells.size + ? await db + .select({ + id: schema.problem.id, + displayId: schema.problem.displayId, + title: schema.problem.title, + }) + .from(schema.problem) + .where(inArray(schema.problem.id, [...cells.keys()])) + : [] + const titleOf = new Map(titles.map((row) => [row.id, row])) + + const lead = new Map() + const problems: ClassPk["problems"] = [] + const solo: ClassPk["solo"] = [] + for (const [problemId, byClass] of [...cells].sort((a, b) => a[0] - b[0])) { + const meta = titleOf.get(problemId) + if (!meta) continue + if (byClass.size === 1) { + const [className] = byClass.keys() + solo.push({ problemId, displayId: meta.displayId, title: meta.title, className: className! }) + continue + } + const row = classNames.map((className) => { + const cell = byClass.get(className) + if (!cell) return null + return { + ...cell, + percent: Math.round((cell.solved / members.get(className)!) * 100), + firstPercent: Math.round((cell.firstTry / cell.tried) * 100), + best: false, + } + }) + const top = Math.max(...row.map((cell) => cell?.percent ?? -1)) + row.forEach((cell, index) => { + if (!cell || cell.percent !== top) return + cell.best = true + lead.set(classNames[index]!, (lead.get(classNames[index]!) ?? 0) + 1) + }) + problems.push({ problemId, displayId: meta.displayId, title: meta.title, cells: row }) + } + + const classes = classNames.map((className) => { + const people = entrants.filter((entrant) => entrant.className === className) + const solved = people.map((entrant) => entrant.times.filter((time) => time >= sinceMs).length) + const own = pairs.filter((pair) => pair.className === className) + return { + className, + members: people.length, + perCapita: people.length ? rounded(average(solved), 1) : 0, + median: median(solved), + solvedMembers: solved.filter((value) => value > 0).length, + firstPercent: own.length + ? Math.round((own.filter((pair) => pair.firstSolved).length / own.length) * 100) + : null, + lead: lead.get(className) ?? 0, + battleRank: battle.find((item) => item.className === className)?.rank ?? null, + distribution: solved, + weekly: weeks.map((from) => + people.length + ? rounded( + average( + people.map( + (entrant) => + entrant.times.filter((time) => time >= from && time < from + WEEK_MS).length, + ), + ), + 1, + ) + : 0, + ), + } satisfies ClassPkClass + }) + + return { + period, + start: since, + weeks: weeks.map((from) => new Date(from).toISOString()), + classes, + mine, + problems, + solo, + } satisfies ClassPk +} diff --git a/packages/contract/src/ai.ts b/packages/contract/src/ai.ts index 10c89b6..0155f48 100644 --- a/packages/contract/src/ai.ts +++ b/packages/contract/src/ai.ts @@ -1,5 +1,7 @@ import { z } from "zod" +import { classPkPeriodSchema } from "./ranking" + /** * 评级。`grade()` 返回 S/A/B/C,`averageGrade()` 在没有可用数据时返回空串 —— * 空串是真会下发的值,别把它从这里去掉,前端要按「无评级」处理。 @@ -240,9 +242,10 @@ export const classAnalysisRequestSchema = z.object({ className: z.string().trim().min(1).max(32), }) +/** 班级 PK 的 AI 分析同样只传班号和时间段,数字和页面上的是同一份(services/ranking.ts 的 classPk) */ export const classPkAnalysisRequestSchema = z.object({ - comparisons: z.array(z.record(z.string(), z.unknown())).min(2), - timeRangeLabel: z.string().default("全部时间"), + classNames: z.array(z.string().trim().min(1).max(32)).min(2).max(8), + period: classPkPeriodSchema, }) /** diff --git a/packages/contract/src/classroom.ts b/packages/contract/src/classroom.ts index e7af81d..a80d029 100644 --- a/packages/contract/src/classroom.ts +++ b/packages/contract/src/classroom.ts @@ -3,44 +3,6 @@ import { z } from "zod" import { judgeStatusSchema } from "./judge-status" import { lessonLanguageSchema as lessonLanguageValueSchema } from "./language" -export const classComparisonSchema = z.object({ - className: z.string(), - userCount: z.number().int(), - totalAc: z.number().int(), - totalSubmission: z.number().int(), - avgAc: z.number(), - medianAc: z.number(), - q1Ac: z.number(), - q3Ac: z.number(), - iqr: z.number(), - stdDev: z.number(), - top10Avg: z.number(), - middle80Avg: z.number(), - bottom10Avg: z.number(), - excellentRate: z.number(), - passRate: z.number(), - activeRate: z.number(), - acRate: z.number(), - compositeScore: z.number(), - recentTotalAc: z.number().int().optional(), - recentTotalSubmission: z.number().int().optional(), - recentAvgAc: z.number().optional(), - recentMedianAc: z.number().optional(), - recentTop10Avg: z.number().optional(), - recentActiveCount: z.number().int().optional(), -}) - -export const classComparisonRequestSchema = z.object({ - classNames: z.array(z.string()).min(1), - startTime: z.string().optional(), - endTime: z.string().optional(), -}) - -export const classComparisonResponseSchema = z.object({ - comparisons: z.array(classComparisonSchema), - hasTimeRange: z.boolean(), -}) - /** * 「班里在做」:同班同一天有好几个人做的题,基本就是老师在课上点名的那几道。 * `day` 是东八区日历日;最近 7 天都没有这样的题时为 null,`problems` 为空。 @@ -200,9 +162,6 @@ export const classLessonSchema = z.object({ problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })), }) -export type ClassComparison = z.infer -export type ClassComparisonResponse = z.infer - export type ClassActivity = z.infer export type LastVisit = z.infer export type ClassLessonRequest = z.infer @@ -212,5 +171,3 @@ export type ClassBoardProblem = z.infer export type ClassBoardStudent = z.infer export type ClassBoardCell = z.infer export type ClassActivityProblem = z.infer - -export type ClassComparisonRequest = z.infer diff --git a/packages/contract/src/ranking.ts b/packages/contract/src/ranking.ts index d52a3d3..9d904ff 100644 --- a/packages/contract/src/ranking.ts +++ b/packages/contract/src/ranking.ts @@ -146,3 +146,86 @@ export const classDetailSchema = z.object({ }) export type ClassDetail = z.infer + +/** + * 班级 PK(设计稿「班级 PK 重设计」定稿)。用户定的:**两个班按「同一批题」对决,三个班以上 + * 按题对照,学生也能看**。 + * + * 各班进度不同、布置的题不一样,比人均等于比谁开课早,所以主角是「同一批题」: + * 一个班过半的人交过这道题就算这个班**布置过**;至少两个班布置过的题才拿来比, + * 比的是**做对的人占全班几成**(整数百分比),高的那个班赢这道,显示一样就算平 —— + * 不然 37/39 和 38/40 都显示 95%,皇冠却只给一边。口径和排名页一样:比赛里的提交不算, + * 不计入排名的人分子分母都不算。 + */ +export const classPkPeriodSchema = z.enum(["week", "term"]) + +export const classPkCellSchema = z.object({ + /** 这段时间交过这道题的人 */ + tried: z.number().int().nonnegative(), + /** 这段时间做对了的人 */ + solved: z.number().int().nonnegative(), + /** 第一次交就对的人 */ + firstTry: z.number().int().nonnegative(), + submissions: z.number().int().nonnegative(), + /** 做对的人占全班几成(整数百分比),比输赢就比它 */ + percent: z.number().int().nonnegative(), + /** 一次就对:交过的人里第一次交就对的占几成 */ + firstPercent: z.number().int().nonnegative(), + /** 这道题做得最好的班(一样多就都算) */ + best: z.boolean(), +}) + +export const classPkProblemSchema = z.object({ + problemId: z.number().int(), + displayId: z.string(), + title: z.string(), + /** 和 `classes` 一一对应;null = 这个班没布置 */ + cells: z.array(classPkCellSchema.nullable()), +}) + +export const classPkClassSchema = z.object({ + className: z.string(), + members: z.number().int().nonnegative(), + /** 这段时间人均做对(这道题第一次做对落在这段时间里) */ + perCapita: z.number(), + /** 中间那位同学做对几道 */ + median: z.number(), + solvedMembers: z.number().int().nonnegative(), + /** 这段时间交过的题里第一次交就对的比例(整数百分比);一道没交过为 null */ + firstPercent: z.number().int().nonnegative().nullable(), + /** 同一批题里做得最好(含并列)的有几道 */ + lead: z.number().int().nonnegative(), + /** 班级对抗里第几;这学期还没做对过题的班没有名次 */ + battleRank: z.number().int().positive().nullable(), + /** 每个同学这段时间做对几道 */ + distribution: z.array(z.number().int().nonnegative()), + /** 这学期每周人均新做对,和 `weeks` 一一对应(不随「这周 / 这学期」变) */ + weekly: z.array(z.number()), +}) + +export const classPkSchema = z.object({ + period: classPkPeriodSchema, + start: z.string(), + /** 这学期每周的周一零点(东八区),赛跑和每周人均的横轴 */ + weeks: z.array(z.string()), + classes: z.array(classPkClassSchema), + /** 看的人自己的班(学生);老师和没班级的为 null */ + mine: z.string().nullable(), + /** 至少两个班布置过的题,按题号 */ + problems: z.array(classPkProblemSchema), + /** 只有一个班布置过的题:不比,只列出来 */ + solo: z.array( + z.object({ + problemId: z.number().int(), + displayId: z.string(), + title: z.string(), + className: z.string(), + }), + ), +}) + +export type ClassPkPeriod = z.infer +export type ClassPkCell = z.infer +export type ClassPkProblem = z.infer +export type ClassPkClass = z.infer +export type ClassPk = z.infer