feat(班级PK): 班级 PK 只比两个班都布置过的同一批题,按做对的人占几成分输赢;登录了就能看,AI 分析由服务端现算

- 新接口 GET /rankings/pk:一个班过半的人交过算布置过,至少两个班布置过的题才比;
  显示的整数百分比一样算平;口径和排名页一样(这学期、比赛不算、不计入排名的人不算)
- 不给班就用自己的班(老师用默认的班),只给一个班就配同年级一起布置过的题最多的班,
  一样多挑班级对抗排得更前的
- 去掉 POST /classes/comparison(原来不登录也能查,综合分、四分位数、优秀率都按所选几个班算)
- /ai/class-pk-analysis 只收班号和时间段,数字和页面上的是同一份

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-10 09:06:21 +08:00
1 parent 3c6e4a1e7c
commit d83804b814
7 files changed
+390 -240

No files matched your search

+5 -2
View File
@@ -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,
})
/**
-43
View File
@@ -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<typeof classComparisonSchema>
export type ClassComparisonResponse = z.infer<typeof classComparisonResponseSchema>
export type ClassActivity = z.infer<typeof classActivitySchema>
export type LastVisit = z.infer<typeof lastVisitSchema>
export type ClassLessonRequest = z.infer<typeof classLessonRequestSchema>
@@ -212,5 +171,3 @@ export type ClassBoardProblem = z.infer<typeof classBoardProblemSchema>
export type ClassBoardStudent = z.infer<typeof classBoardStudentSchema>
export type ClassBoardCell = z.infer<typeof classBoardCellSchema>
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema>
export type ClassComparisonRequest = z.infer<typeof classComparisonRequestSchema>
+83
View File
@@ -146,3 +146,86 @@ export const classDetailSchema = z.object({
})
export type ClassDetail = z.infer<typeof classDetailSchema>
/**
* 班级 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<typeof classPkPeriodSchema>
export type ClassPkCell = z.infer<typeof classPkCellSchema>
export type ClassPkProblem = z.infer<typeof classPkProblemSchema>
export type ClassPkClass = z.infer<typeof classPkClassSchema>
export type ClassPk = z.infer<typeof classPkSchema>