Compare commits

...
2 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 ce0093fc29 feat(班级PK): 班级 PK 页重做:两个班看比分和每道题谁赢,三个班以上看题目对照格,学生也能比
Deploy / deploy (push) Canceled after 0s
- 两个班:大比分 + 一次就对副比分,每道题左右两根条、差得最多的在上,下面每周人均和全班分布
- 三个班以上:一行一道题、一格一个班,越深做对的人越多,皇冠是这道做得最好的班;右边谁领先的题多和赛跑
- 学生自己的班标「你们班」,输掉的题写「再 N 人做对就打平」;AI 分析只给老师,直接在页面里出
- 去掉综合分、雷达图、十张柱状图;手机版按稿改成上下排和页签
- 排名页、班级对抗、班级详情的入口对登录的人都开放,带上当前的班

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:06:21 +08:00
xuyueandClaude Opus 5.5 d83804b814 feat(班级PK): 班级 PK 只比两个班都布置过的同一批题,按做对的人占几成分输赢;登录了就能看,AI 分析由服务端现算
- 新接口 GET /rankings/pk:一个班过半的人交过算布置过,至少两个班布置过的题才比;
  显示的整数百分比一样算平;口径和排名页一样(这学期、比赛不算、不计入排名的人不算)
- 不给班就用自己的班(老师用默认的班),只给一个班就配同年级一起布置过的题最多的班,
  一样多挑班级对抗排得更前的
- 去掉 POST /classes/comparison(原来不登录也能查,综合分、四分位数、优秀率都按所选几个班算)
- /ai/class-pk-analysis 只收班号和时间段,数字和页面上的是同一份

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:06:21 +08:00
19 changed files with 2620 additions and 1113 deletions

No files matched your search

+41 -3
View File
@@ -23,7 +23,7 @@ import { generateFilteredHint } from "../services/hint-filter"
import { buildDetail, buildDuration, listSolved } from "../services/learning-stats" import { buildDetail, buildDuration, listSolved } from "../services/learning-stats"
import { decideHintLevel } from "../services/hint-level" import { decideHintLevel } from "../services/hint-level"
import { hintDiagnosis, hintPrompt, referenceAnswer } from "../services/hint-diagnosis" 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 { consumeToken } from "../services/throttling"
import { calendarDay, dayNumber, dayText, localTime, localWeekday } from "../time" import { calendarDay, dayNumber, dayText, localTime, localWeekday } from "../time"
import { countFailedSubmissions, isTeacherOrAbove, asRecord, queryInteger } from "./helpers" 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 if (!parsed.success) return parsed.response
const limited = await throttleAi(c) const limited = await throttleAi(c)
if (limited) return limited 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( return streamChat(
"你是编程教育数据分析专家。根据多个班级 OJ 对比数据,从排名、参与度、典型学生水平、均衡性、梯队、提交质量和教学建议七方面输出中文 Markdown 报告。", [
`${parsed.data.timeRangeLabel}\n${JSON.stringify(parsed.data.comparisons)}`, "你是编程课老师的助教。下面是几个班在 OJ 上的对比数据(JSON)。各班进度不同、布置的题不一样,",
"所以比较以「同一批题」(至少两个班都布置过的题)为主,人均做对只作参考。",
"用中文 Markdown 写一份给老师看的简短分析,分三到四段,每段一两句,段首加粗:",
"**整体**(人均和同一批题里各班领先几道,人均差距有多少是进度造成的)、",
"**差得最多的题**(点题目名,哪个班明显低)、**一次就对**(哪个班更稳、哪个班靠多交几次)、",
"**建议**(下节课能直接做的事)。用大白话,不要出现四分位数、标准差这类统计词,不要编数据里没有的数字。",
].join(""),
JSON.stringify(facts),
) )
}) })
+1 -191
View File
@@ -1,6 +1,5 @@
import { import {
isSqlProblem, isSqlProblem,
classComparisonRequestSchema,
STUDENT_ROLES, STUDENT_ROLES,
classLessonRequestSchema, classLessonRequestSchema,
type ClassActivity, type ClassActivity,
@@ -8,26 +7,11 @@ import {
type ClassBoardStudent, type ClassBoardStudent,
type ClassLesson, type ClassLesson,
type ClassActivityProblem, type ClassActivityProblem,
type ClassComparison,
type ClassComparisonResponse,
type LastVisit, type LastVisit,
type LessonLanguage, type LessonLanguage,
FLOWCHART_PASS_GRADES, FLOWCHART_PASS_GRADES,
} from "@oj2/contract" } from "@oj2/contract"
import { import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from "drizzle-orm"
and,
asc,
desc,
eq,
gte,
inArray,
isNotNull,
isNull,
like,
lt,
lte,
sql,
} from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
@@ -37,75 +21,9 @@ import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
import { accepted } from "../services/learning-stats" import { accepted } from "../services/learning-stats"
import { calendarDay, dayStart, localTime } from "../time" import { calendarDay, dayStart, localTime } from "../time"
import { rounded } from "./helpers"
export const classroomRoutes = new Hono<AppEnv>() export const classroomRoutes = new Hono<AppEnv>()
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 秋的实测分布是两极的: * 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题), * (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
@@ -892,111 +810,3 @@ classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
}) })
return success(c, null) 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<string, ClassUser[]>()
for (const user of users)
byClass.set(user.className, [...(byClass.get(user.className) ?? []), user])
let recentByUser = new Map<number, Set<number>>()
let recentSubmissionCount = new Map<string, number>()
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<number>()
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)
})
+37 -1
View File
@@ -1,4 +1,5 @@
import { import {
classPkPeriodSchema,
rankHiddenRequestSchema, rankHiddenRequestSchema,
rankPeriodSchema, rankPeriodSchema,
rankScopeSchema, rankScopeSchema,
@@ -13,7 +14,7 @@ import {
import { and, countDistinct, desc, eq, gte, inArray, isNotNull, ne } from "drizzle-orm" import { and, countDistinct, desc, eq, gte, inArray, isNotNull, ne } from "drizzle-orm"
import { Hono } from "hono" 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 { db, schema } from "../db"
import { failure, parseBody, success } from "../http" import { failure, parseBody, success } from "../http"
import { dayStart, termStart, weekStart } from "../time" import { dayStart, termStart, weekStart } from "../time"
@@ -21,7 +22,10 @@ import {
audienceWhere, audienceWhere,
classBattle, classBattle,
classDetail, classDetail,
classPk,
loadEntrants, loadEntrants,
PK_MAX_CLASSES,
pkPartner,
rankedStudents, rankedStudents,
standings, standings,
WEEK_MS, WEEK_MS,
@@ -252,6 +256,38 @@ rankingRoutes.get("/rankings/class-detail", optionalAuth, async (c) => {
return success(c, await classDetail(className, isTeacherOrAbove(c.get("user")))) 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) => { rankingRoutes.get("/rankings/champions", optionalAuth, async (c) => {
const className = await resolveGroup(c.get("user"), "class", c.req.query("className")?.trim()) const className = await resolveGroup(c.get("user"), "class", c.req.query("className")?.trim())
+223
View File
@@ -2,6 +2,10 @@ import {
STUDENT_ROLES, STUDENT_ROLES,
type ClassBattleItem, type ClassBattleItem,
type ClassDetail, type ClassDetail,
type ClassPk,
type ClassPkCell,
type ClassPkClass,
type ClassPkPeriod,
type RankScope, type RankScope,
} from "@oj2/contract" } from "@oj2/contract"
import { import {
@@ -15,6 +19,7 @@ import {
like, like,
min, min,
ne, ne,
sql,
type SQL, type SQL,
} from "drizzle-orm" } from "drizzle-orm"
@@ -308,3 +313,221 @@ export async function classDetail(className: string, withCare: boolean): Promise
care, care,
} satisfies ClassDetail } 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<PkPair[]> {
const rows = await db
.select({
className: schema.user.className,
problemId: schema.submission.problemId,
submissions: count(),
solved: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
first: sql<number>`(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<string, number>) {
const tried = new Map<string, Map<number, number>>()
for (const pair of pairs) {
const byProblem = tried.get(pair.className) ?? new Map<number, number>()
byProblem.set(pair.problemId, (byProblem.get(pair.problemId) ?? 0) + 1)
tried.set(pair.className, byProblem)
}
const sets = new Map<string, Set<number>>()
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<string, number>()
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<number>()
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<ClassPk> {
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<string, Omit<ClassPkCell, "percent" | "firstPercent" | "best">>
>()
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<string, number>()
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
}
+6 -6
View File
@@ -14,9 +14,10 @@ import {
type ClassBoard, type ClassBoard,
type ClassLesson, type ClassLesson,
type KnowledgeMap, type KnowledgeMap,
type ClassComparisonResponse,
type ClassBattleItem, type ClassBattleItem,
type ClassDetail, type ClassDetail,
type ClassPk,
type ClassPkPeriod,
type RankBoard, type RankBoard,
type RankPeriod, type RankPeriod,
type RankScope, type RankScope,
@@ -304,11 +305,10 @@ export function getLastVisit() {
return api.get<LastVisit>("me/last-visit") return api.get<LastVisit>("me/last-visit")
} }
export function getClassPK(classNames: string[], startTime?: string, endTime?: string) { /** 班级 PK:不给班就用自己的班(老师用默认的班),只给一个班就配一个同年级的对手 */
return api.post<ClassComparisonResponse>("classes/comparison", { export function getClassPk(classes: string[], period: ClassPkPeriod) {
classNames, return api.get<ClassPk>("rankings/pk", {
...(startTime ? { startTime } : {}), params: { period, ...(classes.length ? { classes: classes.join(",") } : {}) },
...(endTime ? { endTime } : {}),
}) })
} }
+808
View File
@@ -0,0 +1,808 @@
<script setup lang="ts">
import type { ClassPk, ClassPkCell } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import { parseTime } from "utils/functions"
import { needToTie, pkColor, plateau, shortLabels, tint } from "../utils"
import PkHist from "./PkHist.vue"
/**
* 两个班对决(设计稿「班级 PK」B / 学生 · 两个班 / 手机 · 两个班):只比两个班都布置过的题,
* 谁做对的人多谁赢这道 → 大比分;副比分是一次就对。下面一道一行左右两根条,差得最多的在上;
* 再下面每周人均、全班分布。学生看自己班输掉的题,标「再 N 人做对就打平」。
*/
const props = defineProps<{ pk: ClassPk; compact: boolean }>()
const theme = useThemeVars()
const left = computed(() => props.pk.classes[0]!)
const right = computed(() => props.pk.classes[1]!)
const colors = [pkColor(0), pkColor(1)] as const
const shorts = computed(() => shortLabels(props.pk))
const mineIndex = computed(() =>
props.pk.classes.findIndex((item) => item.className === props.pk.mine),
)
function nameOf(index: number) {
return index === mineIndex.value ? "你们班" : shorts.value[index]!
}
type Side = "left" | "right" | "tie"
const rows = computed(() =>
props.pk.problems
.map((problem) => {
const [a, b] = problem.cells as [ClassPkCell, ClassPkCell]
const winner: Side = a.best && b.best ? "tie" : a.best ? "left" : "right"
const first: Side =
a.firstPercent === b.firstPercent
? "tie"
: a.firstPercent > b.firstPercent
? "left"
: "right"
let tie: number | null = null
const mine = mineIndex.value
if (mine >= 0 && winner !== "tie" && winner !== (mine === 0 ? "left" : "right")) {
const own = mine === 0 ? a : b
const other = mine === 0 ? b : a
tie = needToTie(own, props.pk.classes[mine]!.members, other.percent)
}
return { problem, a, b, winner, first, gap: Math.abs(a.percent - b.percent), tie }
})
.sort((x, y) => y.gap - x.gap || x.problem.problemId - y.problem.problemId),
)
const score = computed(() => {
const count = (key: "winner" | "first", side: Side) =>
rows.value.filter((row) => row[key] === side).length
return {
left: count("winner", "left"),
right: count("winner", "right"),
tie: count("winner", "tie"),
firstLeft: count("first", "left"),
firstRight: count("first", "right"),
}
})
const steadier = computed(() => {
const { firstLeft, firstRight } = score.value
if (firstLeft === firstRight) return "两个班一样稳"
return `${nameOf(firstLeft > firstRight ? 0 : 1)}更稳`
})
/** 只有一个班布置过的题:不比,列几道名字 */
const soloText = computed(() => {
const { solo } = props.pk
if (!solo.length) return ""
const names = solo.slice(0, 2).map((item) => `「${item.title}」`)
const owners = new Set(solo.map((item) => item.className))
const who =
owners.size === 1
? `只有${nameOf(props.pk.classes.findIndex((item) => item.className === solo[0]!.className))}做过的`
: "只有一个班做过的"
return `${who} ${solo.length} 道不比:${names.join("、")}${solo.length > 2 ? " 等" : ""}`
})
const SHOW_MOBILE = 7
const expanded = ref(false)
const shownRows = computed(() =>
props.compact && !expanded.value ? rows.value.slice(0, SHOW_MOBILE) : rows.value,
)
/** 每周人均:最近 8 周,两班并排的柱 */
const weeks = computed(() => {
const from = Math.max(0, props.pk.weeks.length - 8)
const list = props.pk.weeks.slice(from).map((week, k) => ({
label: parseTime(week, "M月D日"),
values: [left.value.weekly[from + k] ?? 0, right.value.weekly[from + k] ?? 0],
}))
const max = Math.max(1, ...list.flatMap((week) => week.values))
return { list, max }
})
const plateaus = computed(() => [
plateau(left.value.distribution),
plateau(right.value.distribution),
])
const tab = ref<"weekly" | "dist">("weekly")
</script>
<template>
<div class="duel" :class="{ compact }">
<div
class="score"
:style="{
background: `linear-gradient(90deg, ${tint(colors[0], 0.12)} 0%, transparent 38%, transparent 62%, ${tint(colors[1], 0.12)} 100%)`,
}"
>
<div
v-for="(item, index) in [left, right]"
:key="item.className"
class="side"
:class="{ end: index === 1 }"
>
<span class="side-name" :style="{ color: colors[index] }">
<template v-if="index === mineIndex">
你们班<small>{{ compact ? shorts[index] : classLabel(item.className) }}</small>
</template>
<template v-else>{{ compact ? shorts[index] : classLabel(item.className) }}</template>
</span>
<span class="side-sub">
{{ item.members }} 人 · 人均做对 <b>{{ item.perCapita }}</b>
<template v-if="!compact && item.firstPercent !== null">
· 一次就对 {{ item.firstPercent }}%</template
>
</span>
</div>
<div class="mid">
<span class="muted">两个班都做过的 {{ rows.length }} 道题 · 谁做对的人多谁赢这道</span>
<span class="big">
<b :style="{ color: colors[0] }">{{ score.left }}</b>
<i>:</i>
<b :style="{ color: colors[1] }">{{ score.right }}</b>
</span>
<span class="mid-sub">
平 {{ score.tie }} 道 · 比一次就对:
<b :style="{ color: colors[0] }">{{ score.firstLeft }}</b> :
<b :style="{ color: colors[1] }">{{ score.firstRight }}</b
>,{{ steadier }}
</span>
</div>
</div>
<div class="card">
<div class="card-head">
<b>同一批题</b>
<span class="muted">
条 = 全班做对的人占几成<template v-if="!compact">
· 皇冠 = 这道赢了 · 按两班差距从大到小</template
>
</span>
<div class="spacer" />
<span v-if="soloText && !compact" class="muted">{{ soloText }}</span>
</div>
<template v-if="!compact">
<div v-for="row in shownRows" :key="row.problem.problemId" class="row">
<span class="info left">
<span class="value" :class="{ win: row.winner === 'left' }">
<svg
v-if="row.winner === 'left'"
class="crown"
viewBox="0 0 24 24"
aria-label="这道赢了"
>
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
{{ row.a.percent }}%
</span>
<span class="sub">
{{ row.a.solved }}/{{ left.members }} 人 · 一次就对 {{ row.a.firstPercent }}%
</span>
</span>
<span class="bar-box left">
<span
class="bar"
:style="{
width: `${row.a.percent}%`,
background: colors[0],
opacity: row.winner === 'right' ? 0.45 : 1,
}"
/>
</span>
<span class="title">
<router-link class="ell" :to="`/problem/${row.problem.displayId}`">{{
row.problem.title
}}</router-link>
<span class="tags">
<span class="muted">#{{ row.problem.displayId }}</span>
<span v-if="row.winner === 'tie'" class="muted">平</span>
<span v-else-if="row === rows[0] && row.gap > 0" class="most">差得最多</span>
<span v-if="row.tie !== null" class="tie" :class="{ close: row.tie <= 2 }">
再 {{ row.tie }} 人做对就打平
</span>
</span>
</span>
<span class="bar-box">
<span
class="bar"
:style="{
width: `${row.b.percent}%`,
background: colors[1],
opacity: row.winner === 'left' ? 0.45 : 1,
}"
/>
</span>
<span class="info">
<span class="value" :class="{ win: row.winner === 'right' }">
{{ row.b.percent }}%
<svg
v-if="row.winner === 'right'"
class="crown"
viewBox="0 0 24 24"
aria-label="这道赢了"
>
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
</span>
<span class="sub">
{{ row.b.solved }}/{{ right.members }} 人 · 一次就对 {{ row.b.firstPercent }}%
</span>
</span>
</div>
</template>
<template v-else>
<div v-for="row in shownRows" :key="row.problem.problemId" class="m-row">
<div class="m-title">
<router-link class="ell" :to="`/problem/${row.problem.displayId}`">{{
row.problem.title
}}</router-link>
<span v-if="row.tie !== null" class="tie" :class="{ close: row.tie <= 2 }"
>再 {{ row.tie }} 人就平</span
>
<span v-else-if="row.winner === 'tie'" class="muted">平</span>
</div>
<div v-for="(cell, index) in [row.a, row.b]" :key="index" class="m-line">
<span class="m-name" :style="{ color: colors[index] }">{{ shorts[index] }}</span>
<span class="bar-box">
<span
class="bar"
:style="{
width: `${cell.percent}%`,
background: colors[index],
opacity:
row.winner !== 'tie' && row.winner !== (index === 0 ? 'left' : 'right')
? 0.45
: 1,
}"
/>
</span>
<span class="m-value" :class="{ win: cell.best && row.winner !== 'tie' }"
>{{ cell.percent }}%</span
>
<svg
v-if="cell.best && row.winner !== 'tie'"
class="crown"
viewBox="0 0 24 24"
aria-label="这道赢了"
>
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
<span v-else class="crown" />
</div>
</div>
<button v-if="rows.length > SHOW_MOBILE" class="more" @click="expanded = !expanded">
{{ expanded ? "收起" : `再看 ${rows.length - SHOW_MOBILE} 道 ›` }}
</button>
</template>
</div>
<div class="bottom" :class="{ compact }">
<div v-if="compact" class="tabs" role="tablist">
<button :class="{ on: tab === 'weekly' }" role="tab" @click="tab = 'weekly'">
每周人均
</button>
<button :class="{ on: tab === 'dist' }" role="tab" @click="tab = 'dist'">全班分布</button>
</div>
<div v-if="!compact || tab === 'weekly'" class="card pad">
<div v-if="!compact" class="card-title">
<b>每周人均新做对</b><span class="muted">这学期,最近 {{ weeks.list.length }} 周</span>
</div>
<div class="weeks">
<div v-for="week in weeks.list" :key="week.label" class="week">
<div class="pair">
<div v-for="(value, index) in week.values" :key="index" class="bar-col">
<span class="v" :style="{ color: colors[index] }">{{ value }}</span>
<span
class="wbar"
:style="{
height: `${Math.max(2, (value / weeks.max) * (compact ? 80 : 110))}px`,
background: colors[index],
}"
/>
</div>
</div>
<span class="axis">{{ week.label }}</span>
</div>
</div>
</div>
<div v-if="!compact || tab === 'dist'" class="card pad dist">
<div v-if="!compact" class="card-title">
<b>全班分布</b><span class="muted">一根柱一档,横轴是做对几道 · 虚线是中间那位</span>
</div>
<div v-for="(item, index) in [left, right]" :key="item.className" class="dist-one">
<span class="dist-name" :style="{ color: colors[index] }">
{{ index === mineIndex ? "你们班" : classLabel(item.className) }} · 中间那位
{{ item.median }} 道
<template v-if="plateaus[index]">
· {{ plateaus[index]!.count }} 个人停在 {{ plateaus[index]!.solved }} 道</template
>
</span>
<PkHist :values="item.distribution" :median="item.median" :color="colors[index]" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.duel {
display: flex;
flex-direction: column;
gap: 14px;
}
.score {
padding: 18px 28px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 24px;
}
.side {
display: flex;
flex-direction: column;
gap: 4px;
grid-row: 1;
grid-column: 1;
}
.side.end {
grid-column: 3;
align-items: flex-end;
text-align: right;
}
.side-name {
font-size: 22px;
font-weight: 800;
display: flex;
align-items: baseline;
gap: 8px;
}
.side-name small {
font-size: 14px;
font-weight: 600;
color: v-bind("theme.textColor3");
}
.side-sub {
font-size: 13px;
color: v-bind("theme.textColor2");
}
.side-sub b {
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.mid {
grid-row: 1;
grid-column: 2;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
text-align: center;
}
.big {
display: flex;
align-items: center;
gap: 18px;
line-height: 1.05;
}
.big b {
font-size: 52px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.big i {
font-style: normal;
font-size: 28px;
font-weight: 700;
color: v-bind("theme.textColor3");
}
.mid-sub {
font-size: 13px;
color: v-bind("theme.textColor2");
}
.muted {
font-size: 12px;
font-weight: 400;
color: v-bind("theme.textColor3");
}
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
padding: 12px 16px 4px;
min-width: 0;
}
.card.pad {
padding: 12px 16px;
}
.card-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 8px;
padding-bottom: 6px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.card-title {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
}
.spacer {
flex-grow: 1;
}
.row {
min-height: 44px;
display: grid;
grid-template-columns: 168px minmax(0, 1fr) 210px minmax(0, 1fr) 168px;
align-items: center;
gap: 12px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.row:last-child {
border-bottom: 0;
}
.info {
display: flex;
flex-direction: column;
}
.info.left {
align-items: flex-end;
}
.value {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: v-bind("theme.textColor3");
}
.value.win {
color: v-bind("theme.textColor1");
}
.sub {
font-size: 11px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.crown {
width: 14px;
height: 14px;
flex-shrink: 0;
fill: #c58b00;
stroke: #c58b00;
stroke-width: 1.5;
stroke-linejoin: round;
}
.bar-box {
height: 16px;
display: flex;
background: v-bind("theme.actionColor");
border-radius: 3px;
overflow: hidden;
}
.bar-box.left {
justify-content: flex-end;
}
.bar {
height: 100%;
}
.title {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
min-width: 0;
}
.title a,
.m-title a {
max-width: 100%;
font-size: 13px;
font-weight: 600;
color: v-bind("theme.textColor1");
text-decoration: none;
}
.title a:hover,
.m-title a:hover {
color: v-bind("theme.primaryColor");
}
.ell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tags {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.tags .muted {
font-size: 11px;
}
.most {
font-size: 11px;
font-weight: 600;
color: #b54708;
}
.tie {
font-size: 11px;
font-weight: 600;
white-space: nowrap;
color: v-bind("theme.textColor3");
}
.tie.close {
color: #18a058;
background: rgba(24, 160, 88, 0.12);
border-radius: 3px;
padding: 0 5px;
}
.bottom {
display: flex;
gap: 16px;
}
.bottom > .card {
flex: 1;
}
.weeks {
display: flex;
gap: 4px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.week {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.pair {
height: 130px;
display: flex;
align-items: flex-end;
gap: 4px;
}
.bar-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.v {
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.wbar {
width: 18px;
border-radius: 3px 3px 0 0;
}
.axis {
font-size: 11px;
color: v-bind("theme.textColor3");
white-space: nowrap;
}
.dist {
display: flex;
flex-direction: column;
gap: 10px;
}
.dist-one {
display: flex;
flex-direction: column;
gap: 4px;
}
.dist-name {
font-size: 12px;
font-weight: 600;
}
/* ---------- 手机 ---------- */
.compact .score {
padding: 12px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 6px;
}
.compact .side {
grid-row: 1;
}
.compact .side.end {
grid-column: 2;
}
.compact .side-name {
font-size: 15px;
}
.compact .side-sub {
font-size: 12px;
}
.compact .mid {
grid-row: 2;
grid-column: 1 / 3;
}
.compact .big b {
font-size: 44px;
}
.compact .mid-sub {
font-size: 12px;
}
.compact .card {
padding: 10px 12px 2px;
}
.m-row {
padding: 8px 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
display: flex;
flex-direction: column;
gap: 4px;
}
.m-title {
display: flex;
align-items: baseline;
gap: 6px;
}
.m-title a {
flex: 1;
min-width: 0;
}
.m-line {
display: flex;
align-items: center;
gap: 6px;
}
.m-line .bar-box {
flex: 1;
height: 12px;
}
.m-name {
width: 36px;
font-size: 12px;
font-weight: 600;
}
.m-value {
width: 40px;
text-align: right;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.m-value.win {
font-weight: 700;
}
.m-line .crown {
width: 14px;
height: 14px;
}
.more {
width: 100%;
height: 40px;
border: 0;
background: none;
font: inherit;
font-size: 13px;
color: v-bind("theme.primaryColor");
cursor: pointer;
}
.bottom.compact {
flex-direction: column;
gap: 0;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
padding: 0 12px 12px;
}
.bottom.compact > .card {
border: 0;
padding: 10px 0 0;
}
.tabs {
display: flex;
gap: 18px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.tabs button {
height: 34px;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 14px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.tabs button.on {
color: #18a058;
font-weight: 600;
box-shadow: inset 0 -2px 0 #18a058;
}
.compact .pair {
height: 100px;
gap: 2px;
}
.compact .wbar {
width: 12px;
}
.compact .axis {
font-size: 10px;
}
</style>
+625
View File
@@ -0,0 +1,625 @@
<script setup lang="ts">
import type { ClassPk } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import { useRankPalette } from "oj/rank/palette"
import { needToTie, pkColor, shortLabels, tint } from "../utils"
import PkRace from "./PkRace.vue"
/**
* 三个班以上(设计稿「班级 PK」C / 学生 · 三个班 / 手机 · 三个班以上):题目对照格,一行
* 一道题、一格一个班,越深做对的人越多,皇冠 = 这道做得最好(一样多都算)。右边谁领先的题多、
* 一句话(学生:离领先最近的那道;老师:差得最多的那道)、赛跑。
*/
const props = defineProps<{ pk: ClassPk; compact: boolean }>()
const theme = useThemeVars()
const palette = useRankPalette()
const shorts = computed(() => shortLabels(props.pk))
const mineIndex = computed(() =>
props.pk.classes.findIndex((item) => item.className === props.pk.mine),
)
const rows = computed(() =>
props.pk.problems.map((problem) => {
const top = Math.max(...problem.cells.map((cell) => cell?.percent ?? -1))
const winner = problem.cells.findIndex((cell) => cell?.best)
const own = mineIndex.value >= 0 ? problem.cells[mineIndex.value] : null
const tie =
own && !own.best ? needToTie(own, props.pk.classes[mineIndex.value]!.members, top) : null
return {
problem,
winner,
tie,
cells: problem.cells.map((cell, index) => {
if (!cell) return null
// 做对比例 45% 以下最浅,100% 最深;深到压白字时字换白
const alpha = Math.max(0.1, Math.min(0.85, ((cell.percent - 45) / 55) * 0.85))
return {
...cell,
background: tint(pkColor(index), alpha),
dark: alpha > 0.5,
hint: `${props.pk.classes[index]!.className.slice(2)}班:做对 ${cell.solved}/${props.pk.classes[index]!.members} 人、交了 ${cell.submissions} 次、一次就对 ${cell.firstPercent}%`,
}
}),
}
}),
)
const ranking = computed(() =>
props.pk.classes
.map((item, index) => ({ item, index }))
.sort((a, b) => b.item.lead - a.item.lead || b.item.perCapita - a.item.perCapita),
)
/** 右边那一句:学生看离领先最近的那道,老师看差得最多的那道 */
const insight = computed(() => {
if (mineIndex.value >= 0) {
const close = rows.value.filter((row) => row.tie !== null).sort((a, b) => a.tie! - b.tie!)[0]
if (!close) return { mine: true, text: "同一批题你们班都做得最好" }
return {
mine: true,
text: `离领先最近:「${close.problem.title}」再 ${close.tie} 人做对就和${shorts.value[close.winner]}打平`,
}
}
let worst: { title: string; low: number; lowIndex: number; others: number } | null = null
for (const row of rows.value) {
const present = row.cells
.map((cell, index) => (cell ? { percent: cell.percent, index } : null))
.filter((cell): cell is { percent: number; index: number } => !!cell)
.sort((a, b) => a.percent - b.percent)
const gap = present[1]!.percent - present[0]!.percent
if (!worst || gap > worst.others - worst.low)
worst = {
title: row.problem.title,
low: present[0]!.percent,
lowIndex: present[0]!.index,
others: present[1]!.percent,
}
}
if (!worst || worst.others === worst.low) return null
return {
mine: false,
text: `差得最多的是「${worst.title}」:${shorts.value[worst.lowIndex]}只有 ${worst.low}% 做对,别的班都在 ${worst.others}% 以上`,
}
})
const SHOW_MOBILE = 8
const expanded = ref(false)
const shownRows = computed(() =>
props.compact && !expanded.value ? rows.value.slice(0, SHOW_MOBILE) : rows.value,
)
const soloCount = computed(() => props.pk.solo.length)
const tab = ref<"race" | "lead">("race")
const medal = (index: number) => palette.value.medal[index]
</script>
<template>
<div class="grid-pk" :class="{ compact }">
<div class="card table">
<div class="card-title">
<b>同一批题 · 一格一个班</b>
<span class="muted">
格子里是全班做对的人占几成,越深越多<template v-if="!compact">
· 皇冠 = 这道题做得最好的班(一样多就都算)</template
>
</span>
</div>
<div class="line heads">
<span v-if="!compact" class="name muted">至少两个班布置过的 {{ rows.length }} 道</span>
<span
v-for="(item, index) in pk.classes"
:key="item.className"
class="head"
:class="{ mine: index === mineIndex }"
:style="{ background: tint(pkColor(index), 0.1) }"
>
<span class="head-name" :style="{ color: pkColor(index) }">
{{ compact ? shorts[index] : classLabel(item.className) }}
<span v-if="index === mineIndex" class="mine-tag">{{
compact ? "你们" : "你们班"
}}</span>
</span>
<span class="head-lead">
<span v-if="!compact" class="muted">领先</span>
<b :style="{ color: pkColor(index) }">{{ item.lead }}</b>
<span class="muted">{{ compact ? "道领先" : "道" }}</span>
</span>
<span v-if="!compact" class="muted per">人均做对 {{ item.perCapita }}</span>
</span>
</div>
<div
v-for="row in shownRows"
:key="row.problem.problemId"
class="line"
:class="{ stacked: compact }"
>
<span class="name">
<span v-if="!compact" class="muted id">#{{ row.problem.displayId }}</span>
<router-link class="ell" :to="`/problem/${row.problem.displayId}`">{{
row.problem.title
}}</router-link>
<span v-if="row.tie !== null" class="tie" :class="{ close: row.tie <= 2 }"
>再 {{ row.tie }} 人就平</span
>
</span>
<div class="cells">
<template v-for="(cell, index) in row.cells" :key="index">
<span v-if="!cell" class="cell none">{{ compact ? "—" : "没布置" }}</span>
<span
v-else
class="cell"
:class="{ best: cell.best, dark: cell.dark, mine: index === mineIndex }"
:style="{ background: cell.background }"
:title="cell.hint"
>
<svg v-if="cell.best" class="crown" viewBox="0 0 24 24" aria-label="这道做得最好">
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
<b>{{ cell.percent }}{{ compact ? "" : "%" }}</b>
<span v-if="!compact" class="first">一次 {{ cell.firstPercent }}%</span>
</span>
</template>
</div>
</div>
<button
v-if="compact && rows.length > SHOW_MOBILE"
class="more"
@click="expanded = !expanded"
>
{{ expanded ? "收起" : `再看 ${rows.length - SHOW_MOBILE} 道 ›` }}
</button>
<span v-if="!compact && soloCount" class="muted foot">
只有一个班做过的 {{ soloCount }} 道不在格子里 · 鼠标放到格子上看做对几个人、交了几次
</span>
</div>
<div class="side" :class="{ compact }">
<div v-if="compact" class="tabs" role="tablist">
<button :class="{ on: tab === 'race' }" role="tab" @click="tab = 'race'">赛跑</button>
<button :class="{ on: tab === 'lead' }" role="tab" @click="tab = 'lead'">
谁领先的题多
</button>
</div>
<div v-if="!compact || tab === 'lead'" class="card pad">
<div v-if="!compact" class="card-title">
<b>谁领先的题多</b><span class="muted">同一批题里</span>
</div>
<div
v-for="({ item, index }, place) in ranking"
:key="item.className"
class="lead-row"
:class="{ mine: index === mineIndex }"
>
<span
class="medal"
:style="
medal(place)
? { color: medal(place)!.color, background: medal(place)!.background }
: undefined
"
>{{ place + 1 }}</span
>
<span class="lead-name" :style="{ color: pkColor(index) }">
{{ classLabel(item.className) }}
<span v-if="index === mineIndex" class="mine-tag">你们班</span>
</span>
<span
><b class="num">{{ item.lead }}</b
><span class="muted"> 道领先</span></span
>
</div>
<div v-if="insight" class="insight" :class="{ mine: insight.mine }">{{ insight.text }}</div>
</div>
<div v-if="!compact || tab === 'race'" class="card pad">
<div v-if="!compact" class="card-title">
<b>赛跑</b><span class="muted">这学期累计人均做对</span>
</div>
<PkRace :pk="pk" :width="420" :height="compact ? 220 : 230" />
</div>
</div>
</div>
</template>
<style scoped>
.grid-pk {
display: flex;
gap: 16px;
align-items: flex-start;
}
.grid-pk.compact {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
min-width: 0;
}
.table {
flex: 1.75;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.pad {
padding: 12px 16px;
}
.card-title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 8px;
}
.muted {
font-size: 12px;
font-weight: 400;
color: v-bind("theme.textColor3");
}
.line {
display: flex;
align-items: center;
gap: 6px;
min-height: 40px;
}
.line.heads {
align-items: stretch;
}
.name {
width: 290px;
flex-shrink: 0;
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.heads .name {
align-items: flex-end;
padding-bottom: 6px;
}
.name a {
font-size: 13px;
font-weight: 600;
color: v-bind("theme.textColor1");
text-decoration: none;
min-width: 0;
}
.name a:hover {
color: v-bind("theme.primaryColor");
}
.id {
flex-shrink: 0;
font-size: 11px;
}
.ell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.head {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 0;
border-radius: 6px;
}
.head.mine {
box-shadow: inset 0 0 0 2px #9fd8b8;
}
.head-name {
display: flex;
align-items: center;
gap: 6px;
font-weight: 700;
}
.head-lead {
display: flex;
align-items: baseline;
gap: 4px;
white-space: nowrap;
}
.per {
font-size: 11px;
white-space: nowrap;
}
.head-lead b {
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.mine-tag {
font-size: 11px;
background: rgba(24, 160, 88, 0.12);
color: #18a058;
border-radius: 3px;
padding: 0 5px;
height: 16px;
display: inline-flex;
align-items: center;
font-weight: 600;
white-space: nowrap;
}
.cells {
flex: 1;
min-width: 0;
display: flex;
gap: 6px;
}
.heads .head,
.cells .cell {
flex: 1;
}
.cell {
min-width: 0;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 4px;
color: v-bind("theme.textColor1");
font-variant-numeric: tabular-nums;
}
.cell.dark {
color: #ffffff;
}
.cell.best {
box-shadow: inset 0 0 0 2px #c58b00;
}
.cell b {
font-size: 14px;
}
.first {
font-size: 11px;
opacity: 0.85;
}
.cell.none {
font-size: 12px;
color: v-bind("theme.textColorDisabled");
background: repeating-linear-gradient(
135deg,
v-bind("theme.actionColor") 0 6px,
v-bind("theme.hoverColor") 6px 12px
);
}
.crown {
width: 12px;
height: 12px;
flex-shrink: 0;
fill: #c58b00;
stroke: #c58b00;
stroke-width: 1.5;
stroke-linejoin: round;
}
.cell.dark .crown {
fill: #ffffff;
stroke: #ffffff;
}
.tie {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
color: v-bind("theme.textColor3");
}
.tie.close {
color: #18a058;
background: rgba(24, 160, 88, 0.12);
border-radius: 3px;
padding: 0 5px;
}
.foot {
padding-top: 4px;
}
.side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.lead-row {
height: 34px;
display: flex;
align-items: center;
gap: 10px;
}
.lead-row.mine {
background: rgba(24, 160, 88, 0.08);
border-radius: 6px;
padding: 0 6px;
margin: 0 -6px;
}
.medal {
width: 26px;
height: 26px;
border-radius: 13px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 13px;
flex-shrink: 0;
color: v-bind("theme.textColor2");
}
.lead-name {
flex: 1;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.num {
font-size: 16px;
font-variant-numeric: tabular-nums;
}
.insight {
margin-top: 8px;
font-size: 13px;
color: v-bind("theme.textColor2");
padding: 8px 10px;
background: v-bind("theme.actionColor");
border-radius: 6px;
}
.insight.mine {
background: transparent;
border: 1px dashed #9fd8b8;
color: #18a058;
font-weight: 600;
}
.more {
width: 100%;
height: 40px;
border: 0;
background: none;
font: inherit;
font-size: 13px;
color: v-bind("theme.primaryColor");
cursor: pointer;
}
/* ---------- 手机 ---------- */
.compact .table {
padding: 10px 12px 2px;
}
.compact .line.heads {
min-height: 0;
padding-top: 4px;
}
.compact .head {
padding: 6px 0;
}
.compact .head-name {
font-size: 12px;
flex-direction: column;
gap: 0;
}
.compact .per {
font-size: 11px;
white-space: nowrap;
}
.head-lead b {
font-size: 20px;
}
.line.stacked {
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 6px 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.line.stacked .name {
width: auto;
}
.compact .cells {
gap: 4px;
}
.compact .cell {
height: 28px;
gap: 2px;
font-size: 12px;
}
.compact .cell b {
font-size: 12px;
}
.compact .crown {
width: 10px;
height: 10px;
}
.side.compact {
gap: 0;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
padding: 0 12px 12px;
}
.side.compact .card {
border: 0;
padding: 10px 0 0;
}
.tabs {
display: flex;
gap: 18px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.tabs button {
height: 34px;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 14px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.tabs button.on {
color: #18a058;
font-weight: 600;
box-shadow: inset 0 -2px 0 #18a058;
}
</style>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
/**
* 全班分布:横轴做对几道(0–30,再多的堆在最右一档),一根柱一档,虚线是中间那位同学。
* 人少的档(不到 3 个)淡一点,一眼看到大部分人停在哪。
*/
const props = withDefaults(
defineProps<{ values: number[]; median: number; color: string; height?: number }>(),
{ height: 44 },
)
const theme = useThemeVars()
const CAP = 30
const WIDTH = 560
const bars = computed(() => {
const counts = new Map<number, number>()
for (const value of props.values) {
const key = Math.min(value, CAP)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
const tallest = Math.max(16, ...counts.values())
const width = WIDTH / (CAP + 1)
return {
width,
list: [...counts].map(([value, n]) => {
const h = Math.max(2, (props.height * n) / tallest)
return { value, n, x: value * width + 0.5, y: props.height - h, h }
}),
median: (Math.min(props.median, CAP) + 0.5) * width,
}
})
</script>
<template>
<svg
class="hist"
:viewBox="`0 0 ${WIDTH} ${height + 12}`"
role="img"
aria-label="全班做对几道的分布"
>
<line :x1="0" :x2="WIDTH" :y1="height + 0.5" :y2="height + 0.5" :stroke="theme.dividerColor" />
<rect
v-for="bar in bars.list"
:key="bar.value"
:x="bar.x"
:y="bar.y"
:width="bars.width - 1"
:height="bar.h"
rx="1"
:fill="color"
:opacity="bar.n >= 3 ? 0.9 : 0.55"
>
<title>做对 {{ bar.value }}{{ bar.value === CAP ? " 道以上" : " 道" }}:{{ bar.n }} 人</title>
</rect>
<line
:x1="bars.median"
:x2="bars.median"
y1="0"
:y2="height"
:stroke="theme.textColor2"
stroke-dasharray="2 2"
/>
<text x="0" :y="height + 11" font-size="9" :fill="theme.textColor3">0</text>
<text
:x="15 * bars.width"
:y="height + 11"
font-size="9"
:fill="theme.textColor3"
text-anchor="middle"
>
15
</text>
<text :x="WIDTH" :y="height + 11" font-size="9" :fill="theme.textColor3" text-anchor="end">
30+
</text>
</svg>
</template>
<style scoped>
.hist {
display: block;
width: 100%;
height: auto;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import type { ClassPk } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { parseTime } from "utils/functions"
import { pkColor, shortLabels } from "../utils"
/**
* 赛跑:每个班这学期累计人均做对,一周一个点,线尾写班名和到现在的人均。
* 按 viewBox 等比缩放,放在多宽的格子里都行。
*/
const props = withDefaults(defineProps<{ pk: ClassPk; width?: number; height?: number }>(), {
width: 700,
height: 280,
})
const theme = useThemeVars()
const PAD = { left: 34, right: 92, top: 10, bottom: 26 }
const series = computed(() =>
props.pk.classes.map((item) => {
let sum = 0
return item.weekly.map((value) => Math.round((sum += value) * 10) / 10)
}),
)
const chart = computed(() => {
const inner = {
w: props.width - PAD.left - PAD.right,
h: props.height - PAD.top - PAD.bottom,
}
const top = Math.max(1, ...series.value.flat())
const step = top > 30 ? 10 : top > 12 ? 6 : top > 5 ? 2 : 1
const max = Math.ceil(top / step) * step
const count = props.pk.weeks.length
const x = (index: number) =>
PAD.left + (count > 1 ? (inner.w * index) / (count - 1) : inner.w / 2)
const y = (value: number) => PAD.top + inner.h * (1 - value / max)
const ticks = Array.from({ length: max / step + 1 }, (_, k) => ({
value: k * step,
y: y(k * step),
}))
// 周多了横轴字会挤:最多标 8 个,最后一周一定标
const every = Math.max(1, Math.ceil(count / 8))
const labels = props.pk.weeks
.map((week, index) => ({ index, x: x(index), text: parseTime(week, "M月D日") }))
.filter(({ index }) => index === count - 1 || (count - 1 - index) % every === 0)
const labelsShort = shortLabels(props.pk)
const lines = series.value.map((points, index) => ({
color: pkColor(index),
path: points.map((value, k) => `${x(k).toFixed(1)},${y(value).toFixed(1)}`).join(" "),
dots: points.map((value, k) => ({ cx: x(k), cy: y(value) })),
end: { y: y(points.at(-1) ?? 0), text: `${labelsShort[index]} ${points.at(-1) ?? 0}` },
}))
// 线尾的字别叠在一起:按高低排,挨得太近的往下推
const ends = lines.map((line, index) => ({ index, y: line.end.y })).sort((a, b) => a.y - b.y)
for (let k = 1; k < ends.length; k++)
if (ends[k]!.y - ends[k - 1]!.y < 14) ends[k]!.y = ends[k - 1]!.y + 14
for (const end of ends) lines[end.index]!.end.y = end.y
return { ticks, labels, lines, endX: x(count - 1) + 10, right: PAD.left + inner.w }
})
</script>
<template>
<svg
class="race"
:viewBox="`0 0 ${width} ${height}`"
role="img"
aria-label="各班这学期累计人均做对,一周一个点"
>
<g v-for="tick in chart.ticks" :key="tick.value">
<line
:x1="PAD.left"
:x2="chart.right"
:y1="tick.y"
:y2="tick.y"
:stroke="theme.dividerColor"
/>
<text
:x="PAD.left - 8"
:y="tick.y + 4"
font-size="11"
:fill="theme.textColor3"
text-anchor="end"
>
{{ tick.value }}
</text>
</g>
<text
v-for="label in chart.labels"
:key="label.index"
:x="label.x"
:y="height - 6"
font-size="11"
:fill="theme.textColor3"
text-anchor="middle"
>
{{ label.text }}
</text>
<g v-for="(line, index) in chart.lines" :key="index">
<polyline
:points="line.path"
fill="none"
:stroke="line.color"
stroke-width="2.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
<circle
v-for="(dot, k) in line.dots"
:key="k"
:cx="dot.cx"
:cy="dot.cy"
r="3.2"
:fill="theme.cardColor"
:stroke="line.color"
stroke-width="2"
/>
<text :x="chart.endX" :y="line.end.y + 4" font-size="12" font-weight="700" :fill="line.color">
{{ line.end.text }}
</text>
</g>
</svg>
</template>
<style scoped>
.race {
display: block;
width: 100%;
height: auto;
}
</style>
File diff suppressed because it is too large. Load diff
+59
View File
@@ -0,0 +1,59 @@
import type { ClassPk, ClassPkCell } from "@oj2/contract"
import { classLabel } from "oj/submission/utils"
/**
* 每个班一种颜色,按选班的顺序给。蓝 / 橙打头:两个班对决时最常见,这两个色盲也分得开;
* 后面几个亮度错开,对照格的深浅才看得出来。
*/
const COLORS = [
"#2f6fd0",
"#e0702f",
"#18a058",
"#8e5bd0",
"#b8901a",
"#d0457a",
"#0f8f9f",
"#5f6b7a",
]
export function pkColor(index: number) {
return COLORS[index % COLORS.length]!
}
/** 颜色加透明度:对照格按做对比例取深浅,比分卡两头的底色也用它(暗色下一样能用) */
export function tint(hex: string, alpha: number) {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
/** 同一个年级的几个班只说「3班」,跨年级说全名 */
export function shortLabels(pk: ClassPk) {
const grades = new Set(pk.classes.map((item) => item.className.slice(0, 2)))
return pk.classes.map((item) =>
grades.size === 1 ? `${item.className.slice(2)}班` : classLabel(item.className),
)
}
/**
* 你们班再有几个人做对,这道就能和 `target` 打平。比的是显示出来的整数百分比
* (后端 `percent` 就是这么比输赢的),所以按同样的取整往上加。
*/
export function needToTie(cell: ClassPkCell, members: number, target: number) {
let more = 0
while (
Math.round(((cell.solved + more) / members) * 100) < target &&
cell.solved + more < members
)
more++
return more
}
/** 一档同分人最多、且至少 5 个人,才说「N 个人停在 x 道」 */
export function plateau(values: number[]) {
const counts = new Map<number, number>()
for (const value of values) if (value > 0) counts.set(value, (counts.get(value) ?? 0) + 1)
const [top] = [...counts].filter(([, n]) => n >= 5).sort((a, b) => b[1] - a[1] || b[0] - a[0])
return top ? { solved: top[0], count: top[1] } : null
}
@@ -14,7 +14,7 @@ const props = defineProps<{
teacher?: boolean teacher?: boolean
/** 放在手机页签卡里:不要自己的边框和标题 */ /** 放在手机页签卡里:不要自己的边框和标题 */
bare?: boolean bare?: boolean
/** 显示「班级 PK」入口(原来排名页上那个按钮,管理员角色才有) */ /** 显示「班级 PK」入口(登录了就有:学生也能比) */
pk?: boolean pk?: boolean
}>() }>()
const emit = defineEmits<{ pick: [className: string] }>() const emit = defineEmits<{ pick: [className: string] }>()
@@ -56,7 +56,12 @@ function shortLabel(className: string) {
<div class="battle" :class="{ bare }"> <div class="battle" :class="{ bare }">
<div class="title"> <div class="title">
<b v-if="!bare">班级对抗</b><span class="muted">人均做对 · 全服 · 点班名看详情</span> <b v-if="!bare">班级对抗</b><span class="muted">人均做对 · 全服 · 点班名看详情</span>
<router-link v-if="pk" to="/class" class="pk">班级 PK ›</router-link> <router-link
v-if="pk"
:to="{ path: '/class', query: mine ? { classes: mine } : {} }"
class="pk"
>班级 PK ›</router-link
>
</div> </div>
<div <div
v-for="item in shown" v-for="item in shown"
@@ -245,7 +245,7 @@ function go(path: string, query: Record<string, string>) {
<MdPreview v-if="report" class="report" :model-value="report" /> <MdPreview v-if="report" class="report" :model-value="report" />
</section> </section>
<a v-if="pk" href="#" class="pk" @click.prevent="go('/class', {})" <a v-if="pk" href="#" class="pk" @click.prevent="go('/class', { classes: className! })"
>和别的班比 → 班级 PK ›</a >和别的班比 → 班级 PK ›</a
> >
</div> </div>
+14 -4
View File
@@ -480,7 +480,17 @@ async function restore(userId: number) {
> >
班级详情 · AI 分析 班级详情 · AI 分析
</n-button> </n-button>
<n-button size="small" secondary @click="router.push('/class')">班级 PK</n-button> <n-button
size="small"
secondary
@click="
router.push({
path: '/class',
query: classContext ? { classes: classContext } : {},
})
"
>班级 PK</n-button
>
</div> </div>
<div class="hidden-list"> <div class="hidden-list">
<span class="hidden-title">不计入排名的人</span> <span class="hidden-title">不计入排名的人</span>
@@ -506,7 +516,7 @@ async function restore(userId: number) {
:items="battle" :items="battle"
:mine="classContext" :mine="classContext"
:teacher="teacher" :teacher="teacher"
:pk="userStore.isAdminRole" :pk="userStore.isAuthed"
@pick="openDetail" @pick="openDetail"
/> />
</aside> </aside>
@@ -560,7 +570,7 @@ async function restore(userId: number) {
:items="battle" :items="battle"
:mine="classContext" :mine="classContext"
:teacher="teacher" :teacher="teacher"
:pk="userStore.isAdminRole" :pk="userStore.isAuthed"
@pick="openDetail" @pick="openDetail"
/> />
<RankTrend <RankTrend
@@ -586,7 +596,7 @@ async function restore(userId: number) {
v-model:show="showDetail" v-model:show="showDetail"
:class-name="detailClass" :class-name="detailClass"
:teacher="teacher" :teacher="teacher"
:pk="userStore.isAdminRole" :pk="userStore.isAuthed"
/> />
</div> </div>
</template> </template>
+1
View File
@@ -93,6 +93,7 @@ export const ojs: RouteRecordRaw = {
path: "class", path: "class",
name: "class", name: "class",
component: () => import("oj/class/pk.vue"), component: () => import("oj/class/pk.vue"),
meta: { requiresAuth: true },
}, },
{ {
path: "announcement", path: "announcement",
-2
View File
@@ -275,8 +275,6 @@ export type Rank = RankProfile
/** 本周进步榜:`rank` 是周榜名次,跟存量总榜的名次没有关系 */ /** 本周进步榜:`rank` 是周榜名次,跟存量总榜的名次没有关系 */
export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract" export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract"
export type { ClassComparison } from "@oj2/contract"
/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */ /** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */
export type { ClassActivity, ClassActivityProblem, LastVisit } from "@oj2/contract" export type { ClassActivity, ClassActivityProblem, LastVisit } from "@oj2/contract"
+5 -2
View File
@@ -1,5 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { classPkPeriodSchema } from "./ranking"
/** /**
* 评级。`grade()` 返回 S/A/B/C,`averageGrade()` 在没有可用数据时返回空串 —— * 评级。`grade()` 返回 S/A/B/C,`averageGrade()` 在没有可用数据时返回空串 ——
* 空串是真会下发的值,别把它从这里去掉,前端要按「无评级」处理。 * 空串是真会下发的值,别把它从这里去掉,前端要按「无评级」处理。
@@ -240,9 +242,10 @@ export const classAnalysisRequestSchema = z.object({
className: z.string().trim().min(1).max(32), className: z.string().trim().min(1).max(32),
}) })
/** 班级 PK 的 AI 分析同样只传班号和时间段,数字和页面上的是同一份(services/ranking.ts 的 classPk) */
export const classPkAnalysisRequestSchema = z.object({ export const classPkAnalysisRequestSchema = z.object({
comparisons: z.array(z.record(z.string(), z.unknown())).min(2), classNames: z.array(z.string().trim().min(1).max(32)).min(2).max(8),
timeRangeLabel: z.string().default("全部时间"), period: classPkPeriodSchema,
}) })
/** /**
-43
View File
@@ -3,44 +3,6 @@ import { z } from "zod"
import { judgeStatusSchema } from "./judge-status" import { judgeStatusSchema } from "./judge-status"
import { lessonLanguageSchema as lessonLanguageValueSchema } from "./language" 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` 为空。 * `day` 是东八区日历日;最近 7 天都没有这样的题时为 null,`problems` 为空。
@@ -200,9 +162,6 @@ export const classLessonSchema = z.object({
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })), 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 ClassActivity = z.infer<typeof classActivitySchema>
export type LastVisit = z.infer<typeof lastVisitSchema> export type LastVisit = z.infer<typeof lastVisitSchema>
export type ClassLessonRequest = z.infer<typeof classLessonRequestSchema> 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 ClassBoardStudent = z.infer<typeof classBoardStudentSchema>
export type ClassBoardCell = z.infer<typeof classBoardCellSchema> export type ClassBoardCell = z.infer<typeof classBoardCellSchema>
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema> 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> 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>