feat(排名): 班级详情改成右侧抽屉、老师能在里面直接出 AI 分析;排名页加上个人智能分析入口;用词改成前一名 / 后一名
Deploy / deploy (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
- 班级对抗点班名打开班级详情:人均做对和年级比、中间那位同学、做对过的人、全班分布点阵、 每周人均新做对;去掉四分位数、标准差、综合分。老师多看到「要多关心的同学」(这学期做对不到 3 道)和 AI 分析,报告直接在抽屉里出 - 班级 AI 分析只传班号,数字由服务端按抽屉同一套现算(services/ranking.ts),报告和抽屉对得上 - 学生「你」那张卡底下加「看我的智能分析」;老师名单「⋯」加「看他的智能分析」;班级对抗右上角 「班级 PK ›」 - 「要追的 / 追你 / 冲得最猛 / 对手」会被学生读歪,改成「前一名 / 后一名 / 进步最大」, 对前后一名只说「比你早 / 晚几秒做到几道」 - 老师打开排名页默认看全部,班级下拉框第一项「全部」,选了班就切到本班 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2cf2d4260a
commit
3c6e4a1e7c
17 files changed
+1053
-229
No files matched your search
@@ -23,6 +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 { consumeToken } from "../services/throttling"
|
||||
import { calendarDay, dayNumber, dayText, localTime, localWeekday } from "../time"
|
||||
import { countFailedSubmissions, isTeacherOrAbove, asRecord, queryInteger } from "./helpers"
|
||||
@@ -358,9 +359,18 @@ aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const limited = await throttleAi(c)
|
||||
if (limited) return limited
|
||||
// 数字和抽屉里看到的是同一份(services/ranking.ts 的 classDetail),不再由前端整包传上来
|
||||
const detail = await classDetail(parsed.data.className, true)
|
||||
if (!detail.members) return failure(c, 404, "class-not-found", "这个班没有学生")
|
||||
return streamChat(
|
||||
"你是编程教育数据分析专家。根据班级 OJ 数据,从整体水平、参与积极性、均衡性、梯队和改进建议五方面输出中文 Markdown 报告。",
|
||||
JSON.stringify(parsed.data.comparison),
|
||||
[
|
||||
"你是编程课老师的助教。下面是一个班这学期在 OJ 上的做题数据(JSON,人名是用户名)。",
|
||||
"用中文 Markdown 写一份给老师看的简短分析,分四段,每段一两句,段首加粗:",
|
||||
"**整体**(人均和年级比、班级对抗第几)、**节奏**(每周人均的起伏,课上做还是课外也练)、",
|
||||
"**分化**(前后 10%、人最多的那一档同分)、**建议**(点名 care 里的同学,给下节课能直接做的事)。",
|
||||
"用大白话,不要出现四分位数、标准差、方差这类统计词,不要编数据里没有的数字。",
|
||||
].join(""),
|
||||
JSON.stringify(detail),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
+24
-179
@@ -3,7 +3,6 @@ import {
|
||||
rankPeriodSchema,
|
||||
rankScopeSchema,
|
||||
STUDENT_ROLES,
|
||||
type ClassBattleItem,
|
||||
type RankBoard,
|
||||
type RankPeriod,
|
||||
type RankRow,
|
||||
@@ -11,28 +10,24 @@ import {
|
||||
type SampleUser,
|
||||
type WeeklyChampion,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
countDistinct,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
like,
|
||||
min,
|
||||
ne,
|
||||
type SQL,
|
||||
} from "drizzle-orm"
|
||||
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 { db, schema } from "../db"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { dayStart, termStart, weekStart } from "../time"
|
||||
import { isTeacherOrAbove, rounded, sampleUser } from "./helpers"
|
||||
import {
|
||||
audienceWhere,
|
||||
classBattle,
|
||||
classDetail,
|
||||
loadEntrants,
|
||||
rankedStudents,
|
||||
standings,
|
||||
WEEK_MS,
|
||||
type Standing,
|
||||
} from "../services/ranking"
|
||||
import { isTeacherOrAbove, sampleUser } from "./helpers"
|
||||
|
||||
/**
|
||||
* 排名页(设计稿「排名重设计」G1–G3)。口径见契约 `ranking.ts` 的注释:
|
||||
@@ -40,12 +35,9 @@ import { isTeacherOrAbove, rounded, sampleUser } from "./helpers"
|
||||
*/
|
||||
export const rankingRoutes = new Hono<AppEnv>()
|
||||
|
||||
/** AST_CHECK_FAILED 也是答案对了,与周榜、课堂条同口径 */
|
||||
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||
|
||||
/** 本年级 / 全服的榜面:前面这么多人 + 我附近一段,中间折起来 */
|
||||
const BOARD_HEAD = 16
|
||||
/** 我附近一段:前面 5 个、后面 7 个(前面的人是要追的,多给后面几个看「谁在追我」) */
|
||||
/** 我附近一段:前面 5 个、后面 7 个(多给后面几个:看得到后面的人离自己多近) */
|
||||
const WINDOW_BEFORE = 5
|
||||
const WINDOW_AFTER = 7
|
||||
/** 没有「我」(老师、没上榜)时榜面给多少 */
|
||||
@@ -55,130 +47,6 @@ const TREND_WEEKS = 4
|
||||
/** 每周冠军列几周 */
|
||||
const CHAMPION_WEEKS = 3
|
||||
|
||||
const WEEK_MS = 7 * 86_400_000
|
||||
|
||||
/** 入榜人群:正常状态的学生与学生管理员,老师设成不计入排名的不算 */
|
||||
const rankedStudents = and(
|
||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
isNull(schema.user.rankHiddenAt),
|
||||
)!
|
||||
|
||||
interface Entrant {
|
||||
id: number
|
||||
username: string
|
||||
className: string | null
|
||||
avatar: string | null
|
||||
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
|
||||
times: number[]
|
||||
stamps: string[]
|
||||
}
|
||||
|
||||
interface Standing {
|
||||
entrant: Entrant
|
||||
rank: number
|
||||
solved: number
|
||||
reachedAt: string | null
|
||||
}
|
||||
|
||||
function audienceWhere(scope: RankScope, className: string | null) {
|
||||
if (scope === "class") return and(rankedStudents, eq(schema.user.className, className!))!
|
||||
if (scope === "grade") return and(rankedStudents, like(schema.user.className, `${className}%`))!
|
||||
return rankedStudents
|
||||
}
|
||||
|
||||
function avatarOf(avatar: string | null) {
|
||||
return !avatar || avatar.endsWith("/default.png") ? null : avatar
|
||||
}
|
||||
|
||||
/**
|
||||
* 这群人、从 `since` 起每道题第一次做对的时刻。
|
||||
*
|
||||
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
|
||||
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
|
||||
* 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE
|
||||
* contest_id IS NULL),全服全部历史实测 24ms。
|
||||
*/
|
||||
async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> {
|
||||
const firstSolved = min(schema.submission.createTime)
|
||||
const [people, solves] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: schema.user.id,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
avatar: schema.userProfile.avatar,
|
||||
})
|
||||
.from(schema.user)
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(where),
|
||||
db
|
||||
.select({ userId: schema.submission.userId, at: firstSolved })
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(
|
||||
and(
|
||||
where,
|
||||
isNull(schema.submission.contestId),
|
||||
inArray(schema.submission.result, SOLVED_RESULTS),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId)
|
||||
.having(since ? gte(firstSolved, since) : undefined),
|
||||
])
|
||||
|
||||
const byUser = new Map<number, string[]>()
|
||||
for (const row of solves) {
|
||||
if (!row.at) continue
|
||||
const list = byUser.get(row.userId)
|
||||
if (list) list.push(row.at)
|
||||
else byUser.set(row.userId, [row.at])
|
||||
}
|
||||
return people.map((person) => {
|
||||
const pairs = (byUser.get(person.id) ?? [])
|
||||
.map((stamp) => [Date.parse(stamp), stamp] as const)
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
return {
|
||||
id: person.id,
|
||||
username: person.username,
|
||||
className: person.className,
|
||||
avatar: avatarOf(person.avatar),
|
||||
times: pairs.map(([time]) => time),
|
||||
stamps: pairs.map(([, stamp]) => stamp),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `cutoff` 那一刻的名次(不给就是现在)。一样多的先做到的在前,再一样按 id ——
|
||||
* 第三档不是凑数:同一秒做到的确实有(一节课的最后一道),没有稳定的兜底键名次会跳。
|
||||
* `includeZero`:本班把一道没做对的也排上(全班名单),本年级 / 全服不排。
|
||||
*/
|
||||
function standings(entrants: Entrant[], cutoff: number | null, includeZero: boolean) {
|
||||
const list = entrants
|
||||
.map((entrant) => {
|
||||
let solved = entrant.times.length
|
||||
if (cutoff !== null) {
|
||||
solved = 0
|
||||
while (solved < entrant.times.length && entrant.times[solved]! < cutoff) solved++
|
||||
}
|
||||
return {
|
||||
entrant,
|
||||
solved,
|
||||
reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY,
|
||||
reachedAt: solved ? entrant.stamps[solved - 1]! : null,
|
||||
}
|
||||
})
|
||||
.filter((row) => includeZero || row.solved > 0)
|
||||
.sort((a, b) => b.solved - a.solved || a.reached - b.reached || a.entrant.id - b.entrant.id)
|
||||
return list.map((row, index): Standing => ({
|
||||
entrant: row.entrant,
|
||||
rank: index + 1,
|
||||
solved: row.solved,
|
||||
reachedAt: row.reachedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
function toRow(standing: Standing, change: number | null): RankRow {
|
||||
const { entrant } = standing
|
||||
return {
|
||||
@@ -311,7 +179,7 @@ rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
|
||||
)
|
||||
: null
|
||||
|
||||
// 一道没做对就不算上榜(本班的全班名单里也列着他,但没有名次可说,也没有对手)
|
||||
// 一道没做对就不算上榜(本班的全班名单里也列着他,但没有名次可说,也没有前后一名)
|
||||
const meIndex = user ? now.findIndex((row) => row.entrant.id === user.id && row.solved > 0) : -1
|
||||
const me = meIndex >= 0 ? now[meIndex] : undefined
|
||||
|
||||
@@ -369,42 +237,19 @@ rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 班级对抗:全服每个班这学期人均做对几道,外加这周人均涨了多少。人均而不是总数 ——
|
||||
* 班级人数从 11 到 58 都有,比总数等于比人多。这学期一道没做对的班不列(还没开始用)。
|
||||
* 不计入排名的人分子分母都不算:抄来的题不该替全班加分。
|
||||
* 班级对抗:全服每个班这学期人均做对几道,外加这周人均涨了多少(口径见 services/ranking.ts
|
||||
* 的 classBattle)
|
||||
*/
|
||||
rankingRoutes.get("/rankings/classes", async (c) => {
|
||||
const where = and(
|
||||
rankedStudents,
|
||||
isNotNull(schema.user.className),
|
||||
ne(schema.user.className, ""),
|
||||
)!
|
||||
const term = termStart()
|
||||
const week = Date.parse(weekStart())
|
||||
const entrants = await loadEntrants(where, term)
|
||||
const { items } = await classBattle()
|
||||
return success(c, items)
|
||||
})
|
||||
|
||||
const classes = new Map<string, { members: number; term: number; week: number }>()
|
||||
for (const entrant of entrants) {
|
||||
const entry = classes.get(entrant.className!) ?? { members: 0, term: 0, week: 0 }
|
||||
entry.members++
|
||||
entry.term += entrant.times.length
|
||||
entry.week += entrant.times.filter((time) => time >= week).length
|
||||
classes.set(entrant.className!, entry)
|
||||
}
|
||||
|
||||
const result = [...classes]
|
||||
.filter(([, entry]) => entry.term > 0)
|
||||
.map(([className, entry]) => ({
|
||||
className,
|
||||
members: entry.members,
|
||||
perCapita: rounded(entry.term / entry.members, 1),
|
||||
weekGain: rounded(entry.week / entry.members, 1),
|
||||
}))
|
||||
.sort((a, b) => b.perCapita - a.perCapita || b.weekGain - a.weekGain)
|
||||
return success(
|
||||
c,
|
||||
result.map((item, index) => ({ ...item, rank: index + 1 }) satisfies ClassBattleItem),
|
||||
)
|
||||
/** 班级详情:谁都能看(原来的弹框就是),「要多关心的同学」只给老师 */
|
||||
rankingRoutes.get("/rankings/class-detail", optionalAuth, async (c) => {
|
||||
const className = c.req.query("className")?.trim()
|
||||
if (!className) return failure(c, 400, "class-missing", "没有班级")
|
||||
return success(c, await classDetail(className, isTeacherOrAbove(c.get("user"))))
|
||||
})
|
||||
|
||||
/** 本班每周冠军:最近几个已经结束的周,每周新做对最多的那个人(一样多先做到的) */
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
STUDENT_ROLES,
|
||||
type ClassBattleItem,
|
||||
type ClassDetail,
|
||||
type RankScope,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
count,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
like,
|
||||
min,
|
||||
ne,
|
||||
type SQL,
|
||||
} from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { sampleUser, rounded } from "../routes/helpers"
|
||||
import { termStart, weekStart } from "../time"
|
||||
|
||||
/**
|
||||
* 排名页和班级详情、班级 AI 分析共用的口径(routes/ranking.ts、routes/ai.ts):
|
||||
* 「做对」= 这道题第一次做对落在这段时间里,比赛里的提交不算;入榜人群是正常状态的学生,
|
||||
* 老师设成不计入排名的不算。
|
||||
*/
|
||||
|
||||
/** AST_CHECK_FAILED 也是答案对了,与周榜、课堂条同口径 */
|
||||
export const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||
|
||||
export const WEEK_MS = 7 * 86_400_000
|
||||
|
||||
/** 入榜人群:正常状态的学生与学生管理员,老师设成不计入排名的不算 */
|
||||
export const rankedStudents = and(
|
||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
isNull(schema.user.rankHiddenAt),
|
||||
)!
|
||||
|
||||
export interface Entrant {
|
||||
id: number
|
||||
username: string
|
||||
className: string | null
|
||||
avatar: string | null
|
||||
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
|
||||
times: number[]
|
||||
stamps: string[]
|
||||
}
|
||||
|
||||
export interface Standing {
|
||||
entrant: Entrant
|
||||
rank: number
|
||||
solved: number
|
||||
reachedAt: string | null
|
||||
}
|
||||
|
||||
export function audienceWhere(scope: RankScope, className: string | null) {
|
||||
if (scope === "class") return and(rankedStudents, eq(schema.user.className, className!))!
|
||||
if (scope === "grade") return and(rankedStudents, like(schema.user.className, `${className}%`))!
|
||||
return rankedStudents
|
||||
}
|
||||
|
||||
export function avatarOf(avatar: string | null) {
|
||||
return !avatar || avatar.endsWith("/default.png") ? null : avatar
|
||||
}
|
||||
|
||||
/**
|
||||
* 这群人、从 `since` 起每道题第一次做对的时刻。
|
||||
*
|
||||
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
|
||||
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
|
||||
* 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE
|
||||
* contest_id IS NULL),全服全部历史实测 24ms。
|
||||
*/
|
||||
export async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> {
|
||||
const firstSolved = min(schema.submission.createTime)
|
||||
const [people, solves] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: schema.user.id,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
avatar: schema.userProfile.avatar,
|
||||
})
|
||||
.from(schema.user)
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(where),
|
||||
db
|
||||
.select({ userId: schema.submission.userId, at: firstSolved })
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(
|
||||
and(
|
||||
where,
|
||||
isNull(schema.submission.contestId),
|
||||
inArray(schema.submission.result, SOLVED_RESULTS),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId)
|
||||
.having(since ? gte(firstSolved, since) : undefined),
|
||||
])
|
||||
|
||||
const byUser = new Map<number, string[]>()
|
||||
for (const row of solves) {
|
||||
if (!row.at) continue
|
||||
const list = byUser.get(row.userId)
|
||||
if (list) list.push(row.at)
|
||||
else byUser.set(row.userId, [row.at])
|
||||
}
|
||||
return people.map((person) => {
|
||||
const pairs = (byUser.get(person.id) ?? [])
|
||||
.map((stamp) => [Date.parse(stamp), stamp] as const)
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
return {
|
||||
id: person.id,
|
||||
username: person.username,
|
||||
className: person.className,
|
||||
avatar: avatarOf(person.avatar),
|
||||
times: pairs.map(([time]) => time),
|
||||
stamps: pairs.map(([, stamp]) => stamp),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `cutoff` 那一刻的名次(不给就是现在)。一样多的先做到的在前,再一样按 id ——
|
||||
* 第三档不是凑数:同一秒做到的确实有(一节课的最后一道),没有稳定的兜底键名次会跳。
|
||||
* `includeZero`:本班把一道没做对的也排上(全班名单),本年级 / 全服不排。
|
||||
*/
|
||||
export function standings(entrants: Entrant[], cutoff: number | null, includeZero: boolean) {
|
||||
const list = entrants
|
||||
.map((entrant) => {
|
||||
let solved = entrant.times.length
|
||||
if (cutoff !== null) {
|
||||
solved = 0
|
||||
while (solved < entrant.times.length && entrant.times[solved]! < cutoff) solved++
|
||||
}
|
||||
return {
|
||||
entrant,
|
||||
solved,
|
||||
reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY,
|
||||
reachedAt: solved ? entrant.stamps[solved - 1]! : null,
|
||||
}
|
||||
})
|
||||
.filter((row) => includeZero || row.solved > 0)
|
||||
.sort((a, b) => b.solved - a.solved || a.reached - b.reached || a.entrant.id - b.entrant.id)
|
||||
return list.map((row, index): Standing => ({
|
||||
entrant: row.entrant,
|
||||
rank: index + 1,
|
||||
solved: row.solved,
|
||||
reachedAt: row.reachedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 「要多关心的同学」:这学期做对不到这么多道 */
|
||||
const CARE_BELOW = 3
|
||||
/** 一档同分至少这么多人,才说「N 个人停在 x 道」 */
|
||||
const PLATEAU_MIN = 5
|
||||
/** 班级详情的每周人均列最近几周 */
|
||||
const DETAIL_WEEKS = 6
|
||||
|
||||
/** 有班级的学生,这学期的做对时刻 —— 班级对抗和班级详情都从这一份算 */
|
||||
async function classedEntrants() {
|
||||
const term = termStart()
|
||||
const where = and(
|
||||
rankedStudents,
|
||||
isNotNull(schema.user.className),
|
||||
ne(schema.user.className, ""),
|
||||
)!
|
||||
return { term, entrants: await loadEntrants(where, term) }
|
||||
}
|
||||
|
||||
function battleFrom(entrants: Entrant[]) {
|
||||
const week = Date.parse(weekStart())
|
||||
const classes = new Map<string, { members: number; term: number; week: number }>()
|
||||
for (const entrant of entrants) {
|
||||
const entry = classes.get(entrant.className!) ?? { members: 0, term: 0, week: 0 }
|
||||
entry.members++
|
||||
entry.term += entrant.times.length
|
||||
entry.week += entrant.times.filter((time) => time >= week).length
|
||||
classes.set(entrant.className!, entry)
|
||||
}
|
||||
return [...classes]
|
||||
.filter(([, entry]) => entry.term > 0)
|
||||
.map(([className, entry]) => ({
|
||||
className,
|
||||
members: entry.members,
|
||||
perCapita: rounded(entry.term / entry.members, 1),
|
||||
weekGain: rounded(entry.week / entry.members, 1),
|
||||
}))
|
||||
.sort((a, b) => b.perCapita - a.perCapita || b.weekGain - a.weekGain)
|
||||
.map((item, index) => ({ ...item, rank: index + 1 }) satisfies ClassBattleItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级对抗:全服每个班这学期人均做对几道,外加这周人均涨了多少。人均而不是总数 ——
|
||||
* 班级人数从 11 到 58 都有,比总数等于比人多。这学期一道没做对的班不列(还没开始用)。
|
||||
* 不计入排名的人分子分母都不算:抄来的题不该替全班加分。
|
||||
*/
|
||||
export async function classBattle() {
|
||||
const { entrants } = await classedEntrants()
|
||||
return { items: battleFrom(entrants) }
|
||||
}
|
||||
|
||||
function average(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
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级详情,抽屉里的全部数字(也是班级 AI 分析的输入)。`withCare` 只对老师开:
|
||||
* 「要多关心的同学」点了名,学生不该看到别人被点名。
|
||||
*/
|
||||
export async function classDetail(className: string, withCare: boolean): Promise<ClassDetail> {
|
||||
const { term, entrants } = await classedEntrants()
|
||||
const battle = battleFrom(entrants)
|
||||
const mine = entrants.filter((entrant) => entrant.className === className)
|
||||
const solved = mine.map((entrant) => entrant.times.length)
|
||||
const desc = [...solved].sort((a, b) => b - a)
|
||||
const tenth = Math.max(1, Math.ceil(mine.length / 10))
|
||||
|
||||
// 同年级在用的班:班级对抗里列着的(这学期做对过题)、班号前两位相同
|
||||
const grade = className.slice(0, 2)
|
||||
const gradeClasses = new Set(
|
||||
battle.filter((item) => item.className.slice(0, 2) === grade).map((item) => item.className),
|
||||
)
|
||||
const gradeEntrants = entrants.filter((entrant) => gradeClasses.has(entrant.className!))
|
||||
const gradeAvg = gradeEntrants.length
|
||||
? rounded(average(gradeEntrants.map((entrant) => entrant.times.length)), 1)
|
||||
: null
|
||||
|
||||
const groups = new Map<number, number>()
|
||||
for (const value of solved) if (value > 0) groups.set(value, (groups.get(value) ?? 0) + 1)
|
||||
const [plateau] = [...groups]
|
||||
.filter(([, n]) => n >= PLATEAU_MIN)
|
||||
.sort((a, b) => b[1] - a[1] || b[0] - a[0])
|
||||
|
||||
// 每周:周一零点起算,从这学期第一周到这周,只留最近几周
|
||||
const firstWeek = Date.parse(weekStart(term))
|
||||
const thisWeek = Date.parse(weekStart())
|
||||
const weeks: ClassDetail["weeks"] = []
|
||||
for (let from = firstWeek; from <= thisWeek; from += WEEK_MS) {
|
||||
const inWeek = (entrant: Entrant) =>
|
||||
entrant.times.filter((time) => time >= from && time < from + WEEK_MS).length
|
||||
weeks.push({
|
||||
weekStart: new Date(from).toISOString(),
|
||||
perCapita: mine.length ? rounded(average(mine.map(inWeek)), 1) : 0,
|
||||
gradeAvg: gradeEntrants.length ? rounded(average(gradeEntrants.map(inWeek)), 1) : null,
|
||||
})
|
||||
}
|
||||
|
||||
let care: ClassDetail["care"] = null
|
||||
if (withCare) {
|
||||
const low = mine
|
||||
.filter((entrant) => entrant.times.length < CARE_BELOW)
|
||||
.sort((a, b) => a.times.length - b.times.length || a.id - b.id)
|
||||
const submitted = low.length
|
||||
? await db
|
||||
.select({ userId: schema.submission.userId, n: count() })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
schema.submission.userId,
|
||||
low.map((entrant) => entrant.id),
|
||||
),
|
||||
isNull(schema.submission.contestId),
|
||||
gte(schema.submission.createTime, term),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.userId)
|
||||
: []
|
||||
const counts = new Map(submitted.map((row) => [row.userId, row.n]))
|
||||
care = low.map((entrant) => ({
|
||||
user: sampleUser(entrant, null),
|
||||
avatar: entrant.avatar,
|
||||
solved: entrant.times.length,
|
||||
submissions: counts.get(entrant.id) ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const item = battle.find((entry) => entry.className === className)
|
||||
return {
|
||||
className,
|
||||
start: term,
|
||||
members: mine.length,
|
||||
rank: item?.rank ?? null,
|
||||
battleSize: battle.length,
|
||||
perCapita: mine.length ? rounded(average(solved), 1) : 0,
|
||||
gradeAvg,
|
||||
median: median(solved),
|
||||
solvedMembers: solved.filter((value) => value > 0).length,
|
||||
top10Avg: rounded(average(desc.slice(0, tenth)), 1),
|
||||
bottom10Avg: rounded(average(desc.slice(-tenth)), 1),
|
||||
plateau: plateau ? { solved: plateau[0], count: plateau[1] } : null,
|
||||
distribution: solved,
|
||||
weeks: weeks.slice(-DETAIL_WEEKS),
|
||||
care,
|
||||
} satisfies ClassDetail
|
||||
}
|
||||
Reference in new issue
Block a user