diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index 1bf46d9..1633d91 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -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), ) }) diff --git a/apps/api/src/routes/ranking.ts b/apps/api/src/routes/ranking.ts index 4e5ff3c..7793dba 100644 --- a/apps/api/src/routes/ranking.ts +++ b/apps/api/src/routes/ranking.ts @@ -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() -/** 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 { - 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() - 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() - 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")))) }) /** 本班每周冠军:最近几个已经结束的周,每周新做对最多的那个人(一样多先做到的) */ diff --git a/apps/api/src/services/ranking.ts b/apps/api/src/services/ranking.ts new file mode 100644 index 0000000..d50db14 --- /dev/null +++ b/apps/api/src/services/ranking.ts @@ -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 { + 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() + 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() + 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 { + 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() + 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 +} diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 2d0b6e2..6cee907 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -16,6 +16,7 @@ import { type KnowledgeMap, type ClassComparisonResponse, type ClassBattleItem, + type ClassDetail, type RankBoard, type RankPeriod, type RankScope, @@ -245,6 +246,11 @@ export function getClassBattle() { return api.get("rankings/classes") } +/** 班级详情(抽屉):谁都能看,「要多关心的同学」只有老师拿得到 */ +export function getClassDetail(className: string) { + return api.get("rankings/class-detail", { params: { className } }) +} + /** 本班最近几周的每周冠军 */ export function getWeeklyChampions(className?: string) { return api.get("rankings/champions", { diff --git a/apps/web/src/oj/rank/components/ChampionList.vue b/apps/web/src/oj/rank/components/ChampionList.vue index ae18d9a..5ee2c91 100644 --- a/apps/web/src/oj/rank/components/ChampionList.vue +++ b/apps/web/src/oj/rank/components/ChampionList.vue @@ -5,7 +5,7 @@ import UserName from "shared/components/UserName.vue" import { parseTime } from "utils/functions" import RankAvatar from "./RankAvatar.vue" -/** 本班最近几周的每周冠军,底下再挂这张榜上「冲得最猛」的那个人 */ +/** 本班最近几周的每周冠军,底下再挂这张榜上「进步最大」的那个人 */ defineProps<{ champions: WeeklyChampion[] hot: RankRow | null @@ -51,7 +51,7 @@ function weekLabel(start: string) { 最近几周还没有冠军
- 冲得最猛 + 进步最大 () +const emit = defineEmits<{ pick: [className: string] }>() const theme = useThemeVars() const palette = useRankPalette() @@ -52,7 +55,8 @@ function shortLabel(className: string) { @@ -238,6 +257,21 @@ function threatNote(me: RankRow, behind: RankRow) { background: rgba(199, 106, 18, 0.12); } +.analysis { + display: flex; + align-items: center; + gap: 6px; + padding-top: 8px; + border-top: 1px solid rgba(24, 160, 88, 0.3); + font-size: 13px; + color: #2f6fd0; + text-decoration: none; +} + +.analysis:hover { + text-decoration: underline; +} + .minis { display: flex; gap: 6px; diff --git a/apps/web/src/oj/rank/components/RankPodium.vue b/apps/web/src/oj/rank/components/RankPodium.vue index 5df78b2..07521ad 100644 --- a/apps/web/src/oj/rank/components/RankPodium.vue +++ b/apps/web/src/oj/rank/components/RankPodium.vue @@ -7,7 +7,7 @@ import RankAvatar from "./RankAvatar.vue" /** * 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。 - * 你要追的人站在台上时台阶描蓝边、挂「你要追的」,和赛道里的蓝条是一回事。 + * 你的前一名站在台上时台阶描蓝边、挂「前一名」,和赛道里的蓝条是一回事。 */ const props = defineProps<{ rows: RankRow[] @@ -49,8 +49,8 @@ const HEIGHTS: Record = { 1: 50, 2: 38, 3: 30 } > - 你要追的 - 在追你 + 前一名 + 后一名 你
() @@ -130,11 +131,13 @@ function width(row: RankRow) { const menu: DropdownOption[] = [ { label: "看他的提交", key: "submissions" }, + { label: "看他的智能分析", key: "analysis" }, { label: "不计入排名…", key: "hide" }, ] function onMenu(key: string, row: RankRow) { if (key === "submissions") emit("submissions", row.user.username) + else if (key === "analysis") emit("analysis", row.user.username) else emit("hide", row) } @@ -165,9 +168,9 @@ function onMenu(key: string, row: RankRow) { - 要追 - 追你 - 冲得最猛 + 前一名 + 后一名 + 进步最大 {{ item.row.solved }} ↑{{ item.row.change }} @@ -208,9 +211,9 @@ function onMenu(key: string, row: RankRow) { — - 要追 - 追你 - 冲得最猛 + 前一名 + 后一名 + 进步最大 { @@ -438,6 +470,18 @@ async function restore(userId: number) { 这周人均涨+{{ myBattle.weekGain }} +
+ + 班级详情 · AI 分析 + + 班级 PK +
不计入排名的人 - +
- 我和对手的名次每周日晚上 · 越高越好 + 我和前后一名的名次每周日晚上 · 越高越好
@@ -509,6 +560,8 @@ async function restore(userId: number) { :items="battle" :mine="classContext" :teacher="teacher" + :pk="userStore.isAdminRole" + @pick="openDetail" />
+ @@ -709,6 +768,11 @@ async function restore(userId: number) { font-variant-numeric: tabular-nums; } +.actions { + display: flex; + gap: 8px; +} + .hidden-list { display: flex; flex-direction: column; diff --git a/apps/web/src/oj/rank/palette.ts b/apps/web/src/oj/rank/palette.ts index a839763..b1880d9 100644 --- a/apps/web/src/oj/rank/palette.ts +++ b/apps/web/src/oj/rank/palette.ts @@ -1,8 +1,8 @@ import { useDark } from "@vueuse/core" /** - * 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、你要追的蓝、 - * 在追你的橙、本年级 / 全服里你们班的人浅绿,其余灰;前三名金银铜。 + * 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、前一名蓝、 + * 后一名橙、本年级 / 全服里你们班的人浅绿,其余灰;前三名金银铜。 * 暗色下灰条和浅绿换深一档,不然在暗底上发白。 */ export function useRankPalette() { diff --git a/apps/web/src/oj/rank/utils.ts b/apps/web/src/oj/rank/utils.ts index 7c7053b..c9d9a61 100644 --- a/apps/web/src/oj/rank/utils.ts +++ b/apps/web/src/oj/rank/utils.ts @@ -53,7 +53,7 @@ export function toPass(me: RankRow, target: RankRow) { return target.solved - me.solved + 1 } -/** 这张榜上升得最猛的人(至少升 3 名才算),给他挂「冲得最猛」 */ +/** 这张榜上升得最多的人(至少升 3 名才算),给他挂「进步最大」 */ export function hottest(rows: RankRow[]) { let best: RankRow | null = null for (const row of rows) { diff --git a/packages/contract/src/ai.ts b/packages/contract/src/ai.ts index f996975..10c89b6 100644 --- a/packages/contract/src/ai.ts +++ b/packages/contract/src/ai.ts @@ -232,8 +232,12 @@ export type HintDiagnosis = z.infer */ export const aiHintFeedbackRequestSchema = z.object({ helpful: z.boolean() }) +/** + * 班级 AI 分析只传班号,数字在服务端按「班级详情」那一套现算 —— 原来是前端把弹框里的 + * 统计值整包传上来,报告和抽屉里看到的数字可能对不上 + */ export const classAnalysisRequestSchema = z.object({ - comparison: z.record(z.string(), z.unknown()), + className: z.string().trim().min(1).max(32), }) export const classPkAnalysisRequestSchema = z.object({ diff --git a/packages/contract/src/ranking.ts b/packages/contract/src/ranking.ts index d6139f6..d52a3d3 100644 --- a/packages/contract/src/ranking.ts +++ b/packages/contract/src/ranking.ts @@ -29,7 +29,7 @@ export const rankRowSchema = z.object({ change: z.number().int().nullable(), }) -/** 我和对手每周的名次,给「我和对手的名次」那张走势图 */ +/** 我和前后一名每周的名次,给「我和前后一名的名次」那张走势图 */ export const rankTrendSchema = z.object({ /** 每个点的时刻(周一零点,也就是上周日晚上),最后一个是现在 */ points: z.array(z.string()), @@ -101,3 +101,48 @@ export type RankBoard = z.infer export type ClassBattleItem = z.infer export type WeeklyChampion = z.infer export type RankHiddenRequest = z.infer + +/** + * 班级详情(设计稿「班级详情 · 老师 / 学生」)。原来弹框里的四分位数、四分位距、标准差、 + * 综合分都去掉了,只留一眼看得懂的数:人均、中间那位同学、做对过的人、前后 10%、全班分布、 + * 每周人均和年级比。口径和排名页一样:这学期第一次做对的题,不计入排名的人不算。 + */ +export const classDetailSchema = z.object({ + className: z.string(), + /** 这学期从哪天算起 */ + start: z.string(), + members: z.number().int().nonnegative(), + /** 班级对抗里第几、一共几个班;这学期还没做对过题的班没有名次 */ + rank: z.number().int().positive().nullable(), + battleSize: z.number().int().nonnegative(), + perCapita: z.number(), + /** 同年级在用的班(这学期做对过题)的人均;没有为 null */ + gradeAvg: z.number().nullable(), + /** 中间那位同学做对几道 */ + median: z.number(), + /** 这学期做对过至少 1 道的人数 */ + solvedMembers: z.number().int().nonnegative(), + top10Avg: z.number(), + bottom10Avg: z.number(), + /** 人最多的那一档同分(至少 5 人才算「停在这」),没有为 null */ + plateau: z.object({ solved: z.number().int(), count: z.number().int() }).nullable(), + /** 每个同学这学期做对几道,画分布点阵用 */ + distribution: z.array(z.number().int().nonnegative()), + /** 这学期每周(周一起)人均新做对,和同年级在用的班平均比 */ + weeks: z.array( + z.object({ weekStart: z.string(), perCapita: z.number(), gradeAvg: z.number().nullable() }), + ), + /** 要多关心的同学(这学期做对不到 3 道),只给老师;学生拿到的是 null */ + care: z + .array( + z.object({ + user: sampleUserSchema, + avatar: z.string().nullable(), + solved: z.number().int().nonnegative(), + submissions: z.number().int().nonnegative(), + }), + ) + .nullable(), +}) + +export type ClassDetail = z.infer