feat(排名): 班级详情改成右侧抽屉、老师能在里面直接出 AI 分析;排名页加上个人智能分析入口;用词改成前一名 / 后一名
Deploy / deploy (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
- 班级对抗点班名打开班级详情:人均做对和年级比、中间那位同学、做对过的人、全班分布点阵、 每周人均新做对;去掉四分位数、标准差、综合分。老师多看到「要多关心的同学」(这学期做对不到 3 道)和 AI 分析,报告直接在抽屉里出 - 班级 AI 分析只传班号,数字由服务端按抽屉同一套现算(services/ranking.ts),报告和抽屉对得上 - 学生「你」那张卡底下加「看我的智能分析」;老师名单「⋯」加「看他的智能分析」;班级对抗右上角 「班级 PK ›」 - 「要追的 / 追你 / 冲得最猛 / 对手」会被学生读歪,改成「前一名 / 后一名 / 进步最大」, 对前后一名只说「比你早 / 晚几秒做到几道」 - 老师打开排名页默认看全部,班级下拉框第一项「全部」,选了班就切到本班 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2cf2d4260a
commit
3c6e4a1e7c
17 files changed
+1053
-229
No files matched your search
@@ -23,6 +23,7 @@ import { generateFilteredHint } from "../services/hint-filter"
|
|||||||
import { buildDetail, buildDuration, listSolved } from "../services/learning-stats"
|
import { 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 { 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"
|
||||||
@@ -358,9 +359,18 @@ aiRoutes.post("/ai/class-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 的 classDetail),不再由前端整包传上来
|
||||||
|
const detail = await classDetail(parsed.data.className, true)
|
||||||
|
if (!detail.members) return failure(c, 404, "class-not-found", "这个班没有学生")
|
||||||
return streamChat(
|
return streamChat(
|
||||||
"你是编程教育数据分析专家。根据班级 OJ 数据,从整体水平、参与积极性、均衡性、梯队和改进建议五方面输出中文 Markdown 报告。",
|
[
|
||||||
JSON.stringify(parsed.data.comparison),
|
"你是编程课老师的助教。下面是一个班这学期在 OJ 上的做题数据(JSON,人名是用户名)。",
|
||||||
|
"用中文 Markdown 写一份给老师看的简短分析,分四段,每段一两句,段首加粗:",
|
||||||
|
"**整体**(人均和年级比、班级对抗第几)、**节奏**(每周人均的起伏,课上做还是课外也练)、",
|
||||||
|
"**分化**(前后 10%、人最多的那一档同分)、**建议**(点名 care 里的同学,给下节课能直接做的事)。",
|
||||||
|
"用大白话,不要出现四分位数、标准差、方差这类统计词,不要编数据里没有的数字。",
|
||||||
|
].join(""),
|
||||||
|
JSON.stringify(detail),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+24
-179
@@ -3,7 +3,6 @@ import {
|
|||||||
rankPeriodSchema,
|
rankPeriodSchema,
|
||||||
rankScopeSchema,
|
rankScopeSchema,
|
||||||
STUDENT_ROLES,
|
STUDENT_ROLES,
|
||||||
type ClassBattleItem,
|
|
||||||
type RankBoard,
|
type RankBoard,
|
||||||
type RankPeriod,
|
type RankPeriod,
|
||||||
type RankRow,
|
type RankRow,
|
||||||
@@ -11,28 +10,24 @@ import {
|
|||||||
type SampleUser,
|
type SampleUser,
|
||||||
type WeeklyChampion,
|
type WeeklyChampion,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import {
|
import { and, countDistinct, desc, eq, gte, inArray, isNotNull, ne } from "drizzle-orm"
|
||||||
and,
|
|
||||||
countDistinct,
|
|
||||||
desc,
|
|
||||||
eq,
|
|
||||||
gte,
|
|
||||||
inArray,
|
|
||||||
isNotNull,
|
|
||||||
isNull,
|
|
||||||
like,
|
|
||||||
min,
|
|
||||||
ne,
|
|
||||||
type SQL,
|
|
||||||
} from "drizzle-orm"
|
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { optionalAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
import { optionalAuth, 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 { JudgeStatus } from "../judge/status"
|
|
||||||
import { dayStart, termStart, weekStart } from "../time"
|
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` 的注释:
|
* 排名页(设计稿「排名重设计」G1–G3)。口径见契约 `ranking.ts` 的注释:
|
||||||
@@ -40,12 +35,9 @@ import { isTeacherOrAbove, rounded, sampleUser } from "./helpers"
|
|||||||
*/
|
*/
|
||||||
export const rankingRoutes = new Hono<AppEnv>()
|
export const rankingRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
/** AST_CHECK_FAILED 也是答案对了,与周榜、课堂条同口径 */
|
|
||||||
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
|
||||||
|
|
||||||
/** 本年级 / 全服的榜面:前面这么多人 + 我附近一段,中间折起来 */
|
/** 本年级 / 全服的榜面:前面这么多人 + 我附近一段,中间折起来 */
|
||||||
const BOARD_HEAD = 16
|
const BOARD_HEAD = 16
|
||||||
/** 我附近一段:前面 5 个、后面 7 个(前面的人是要追的,多给后面几个看「谁在追我」) */
|
/** 我附近一段:前面 5 个、后面 7 个(多给后面几个:看得到后面的人离自己多近) */
|
||||||
const WINDOW_BEFORE = 5
|
const WINDOW_BEFORE = 5
|
||||||
const WINDOW_AFTER = 7
|
const WINDOW_AFTER = 7
|
||||||
/** 没有「我」(老师、没上榜)时榜面给多少 */
|
/** 没有「我」(老师、没上榜)时榜面给多少 */
|
||||||
@@ -55,130 +47,6 @@ const TREND_WEEKS = 4
|
|||||||
/** 每周冠军列几周 */
|
/** 每周冠军列几周 */
|
||||||
const CHAMPION_WEEKS = 3
|
const CHAMPION_WEEKS = 3
|
||||||
|
|
||||||
const WEEK_MS = 7 * 86_400_000
|
|
||||||
|
|
||||||
/** 入榜人群:正常状态的学生与学生管理员,老师设成不计入排名的不算 */
|
|
||||||
const rankedStudents = and(
|
|
||||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
|
||||||
eq(schema.user.isDisabled, false),
|
|
||||||
isNull(schema.user.rankHiddenAt),
|
|
||||||
)!
|
|
||||||
|
|
||||||
interface Entrant {
|
|
||||||
id: number
|
|
||||||
username: string
|
|
||||||
className: string | null
|
|
||||||
avatar: string | null
|
|
||||||
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
|
|
||||||
times: number[]
|
|
||||||
stamps: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Standing {
|
|
||||||
entrant: Entrant
|
|
||||||
rank: number
|
|
||||||
solved: number
|
|
||||||
reachedAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function audienceWhere(scope: RankScope, className: string | null) {
|
|
||||||
if (scope === "class") return and(rankedStudents, eq(schema.user.className, className!))!
|
|
||||||
if (scope === "grade") return and(rankedStudents, like(schema.user.className, `${className}%`))!
|
|
||||||
return rankedStudents
|
|
||||||
}
|
|
||||||
|
|
||||||
function avatarOf(avatar: string | null) {
|
|
||||||
return !avatar || avatar.endsWith("/default.png") ? null : avatar
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 这群人、从 `since` 起每道题第一次做对的时刻。
|
|
||||||
*
|
|
||||||
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
|
|
||||||
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
|
|
||||||
* 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE
|
|
||||||
* contest_id IS NULL),全服全部历史实测 24ms。
|
|
||||||
*/
|
|
||||||
async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> {
|
|
||||||
const firstSolved = min(schema.submission.createTime)
|
|
||||||
const [people, solves] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
id: schema.user.id,
|
|
||||||
username: schema.user.username,
|
|
||||||
className: schema.user.className,
|
|
||||||
avatar: schema.userProfile.avatar,
|
|
||||||
})
|
|
||||||
.from(schema.user)
|
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
|
||||||
.where(where),
|
|
||||||
db
|
|
||||||
.select({ userId: schema.submission.userId, at: firstSolved })
|
|
||||||
.from(schema.submission)
|
|
||||||
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
where,
|
|
||||||
isNull(schema.submission.contestId),
|
|
||||||
inArray(schema.submission.result, SOLVED_RESULTS),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.groupBy(schema.submission.userId, schema.submission.problemId)
|
|
||||||
.having(since ? gte(firstSolved, since) : undefined),
|
|
||||||
])
|
|
||||||
|
|
||||||
const byUser = new Map<number, string[]>()
|
|
||||||
for (const row of solves) {
|
|
||||||
if (!row.at) continue
|
|
||||||
const list = byUser.get(row.userId)
|
|
||||||
if (list) list.push(row.at)
|
|
||||||
else byUser.set(row.userId, [row.at])
|
|
||||||
}
|
|
||||||
return people.map((person) => {
|
|
||||||
const pairs = (byUser.get(person.id) ?? [])
|
|
||||||
.map((stamp) => [Date.parse(stamp), stamp] as const)
|
|
||||||
.sort((a, b) => a[0] - b[0])
|
|
||||||
return {
|
|
||||||
id: person.id,
|
|
||||||
username: person.username,
|
|
||||||
className: person.className,
|
|
||||||
avatar: avatarOf(person.avatar),
|
|
||||||
times: pairs.map(([time]) => time),
|
|
||||||
stamps: pairs.map(([, stamp]) => stamp),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `cutoff` 那一刻的名次(不给就是现在)。一样多的先做到的在前,再一样按 id ——
|
|
||||||
* 第三档不是凑数:同一秒做到的确实有(一节课的最后一道),没有稳定的兜底键名次会跳。
|
|
||||||
* `includeZero`:本班把一道没做对的也排上(全班名单),本年级 / 全服不排。
|
|
||||||
*/
|
|
||||||
function standings(entrants: Entrant[], cutoff: number | null, includeZero: boolean) {
|
|
||||||
const list = entrants
|
|
||||||
.map((entrant) => {
|
|
||||||
let solved = entrant.times.length
|
|
||||||
if (cutoff !== null) {
|
|
||||||
solved = 0
|
|
||||||
while (solved < entrant.times.length && entrant.times[solved]! < cutoff) solved++
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
entrant,
|
|
||||||
solved,
|
|
||||||
reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY,
|
|
||||||
reachedAt: solved ? entrant.stamps[solved - 1]! : null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((row) => includeZero || row.solved > 0)
|
|
||||||
.sort((a, b) => b.solved - a.solved || a.reached - b.reached || a.entrant.id - b.entrant.id)
|
|
||||||
return list.map((row, index): Standing => ({
|
|
||||||
entrant: row.entrant,
|
|
||||||
rank: index + 1,
|
|
||||||
solved: row.solved,
|
|
||||||
reachedAt: row.reachedAt,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRow(standing: Standing, change: number | null): RankRow {
|
function toRow(standing: Standing, change: number | null): RankRow {
|
||||||
const { entrant } = standing
|
const { entrant } = standing
|
||||||
return {
|
return {
|
||||||
@@ -311,7 +179,7 @@ rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
|
|||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// 一道没做对就不算上榜(本班的全班名单里也列着他,但没有名次可说,也没有对手)
|
// 一道没做对就不算上榜(本班的全班名单里也列着他,但没有名次可说,也没有前后一名)
|
||||||
const meIndex = user ? now.findIndex((row) => row.entrant.id === user.id && row.solved > 0) : -1
|
const meIndex = user ? now.findIndex((row) => row.entrant.id === user.id && row.solved > 0) : -1
|
||||||
const me = meIndex >= 0 ? now[meIndex] : undefined
|
const me = meIndex >= 0 ? now[meIndex] : undefined
|
||||||
|
|
||||||
@@ -369,42 +237,19 @@ rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 班级对抗:全服每个班这学期人均做对几道,外加这周人均涨了多少。人均而不是总数 ——
|
* 班级对抗:全服每个班这学期人均做对几道,外加这周人均涨了多少(口径见 services/ranking.ts
|
||||||
* 班级人数从 11 到 58 都有,比总数等于比人多。这学期一道没做对的班不列(还没开始用)。
|
* 的 classBattle)
|
||||||
* 不计入排名的人分子分母都不算:抄来的题不该替全班加分。
|
|
||||||
*/
|
*/
|
||||||
rankingRoutes.get("/rankings/classes", async (c) => {
|
rankingRoutes.get("/rankings/classes", async (c) => {
|
||||||
const where = and(
|
const { items } = await classBattle()
|
||||||
rankedStudents,
|
return success(c, items)
|
||||||
isNotNull(schema.user.className),
|
})
|
||||||
ne(schema.user.className, ""),
|
|
||||||
)!
|
|
||||||
const term = termStart()
|
|
||||||
const week = Date.parse(weekStart())
|
|
||||||
const entrants = await loadEntrants(where, term)
|
|
||||||
|
|
||||||
const classes = new Map<string, { members: number; term: number; week: number }>()
|
/** 班级详情:谁都能看(原来的弹框就是),「要多关心的同学」只给老师 */
|
||||||
for (const entrant of entrants) {
|
rankingRoutes.get("/rankings/class-detail", optionalAuth, async (c) => {
|
||||||
const entry = classes.get(entrant.className!) ?? { members: 0, term: 0, week: 0 }
|
const className = c.req.query("className")?.trim()
|
||||||
entry.members++
|
if (!className) return failure(c, 400, "class-missing", "没有班级")
|
||||||
entry.term += entrant.times.length
|
return success(c, await classDetail(className, isTeacherOrAbove(c.get("user"))))
|
||||||
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),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 本班每周冠军:最近几个已经结束的周,每周新做对最多的那个人(一样多先做到的) */
|
/** 本班每周冠军:最近几个已经结束的周,每周新做对最多的那个人(一样多先做到的) */
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import {
|
||||||
|
STUDENT_ROLES,
|
||||||
|
type ClassBattleItem,
|
||||||
|
type ClassDetail,
|
||||||
|
type RankScope,
|
||||||
|
} from "@oj2/contract"
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
count,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
inArray,
|
||||||
|
isNotNull,
|
||||||
|
isNull,
|
||||||
|
like,
|
||||||
|
min,
|
||||||
|
ne,
|
||||||
|
type SQL,
|
||||||
|
} from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db, schema } from "../db"
|
||||||
|
import { JudgeStatus } from "../judge/status"
|
||||||
|
import { sampleUser, rounded } from "../routes/helpers"
|
||||||
|
import { termStart, weekStart } from "../time"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排名页和班级详情、班级 AI 分析共用的口径(routes/ranking.ts、routes/ai.ts):
|
||||||
|
* 「做对」= 这道题第一次做对落在这段时间里,比赛里的提交不算;入榜人群是正常状态的学生,
|
||||||
|
* 老师设成不计入排名的不算。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** AST_CHECK_FAILED 也是答案对了,与周榜、课堂条同口径 */
|
||||||
|
export const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||||
|
|
||||||
|
export const WEEK_MS = 7 * 86_400_000
|
||||||
|
|
||||||
|
/** 入榜人群:正常状态的学生与学生管理员,老师设成不计入排名的不算 */
|
||||||
|
export const rankedStudents = and(
|
||||||
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
isNull(schema.user.rankHiddenAt),
|
||||||
|
)!
|
||||||
|
|
||||||
|
export interface Entrant {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
className: string | null
|
||||||
|
avatar: string | null
|
||||||
|
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
|
||||||
|
times: number[]
|
||||||
|
stamps: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Standing {
|
||||||
|
entrant: Entrant
|
||||||
|
rank: number
|
||||||
|
solved: number
|
||||||
|
reachedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audienceWhere(scope: RankScope, className: string | null) {
|
||||||
|
if (scope === "class") return and(rankedStudents, eq(schema.user.className, className!))!
|
||||||
|
if (scope === "grade") return and(rankedStudents, like(schema.user.className, `${className}%`))!
|
||||||
|
return rankedStudents
|
||||||
|
}
|
||||||
|
|
||||||
|
export function avatarOf(avatar: string | null) {
|
||||||
|
return !avatar || avatar.endsWith("/default.png") ? null : avatar
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这群人、从 `since` 起每道题第一次做对的时刻。
|
||||||
|
*
|
||||||
|
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
|
||||||
|
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
|
||||||
|
* 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE
|
||||||
|
* contest_id IS NULL),全服全部历史实测 24ms。
|
||||||
|
*/
|
||||||
|
export async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> {
|
||||||
|
const firstSolved = min(schema.submission.createTime)
|
||||||
|
const [people, solves] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: schema.user.id,
|
||||||
|
username: schema.user.username,
|
||||||
|
className: schema.user.className,
|
||||||
|
avatar: schema.userProfile.avatar,
|
||||||
|
})
|
||||||
|
.from(schema.user)
|
||||||
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
|
.where(where),
|
||||||
|
db
|
||||||
|
.select({ userId: schema.submission.userId, at: firstSolved })
|
||||||
|
.from(schema.submission)
|
||||||
|
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
where,
|
||||||
|
isNull(schema.submission.contestId),
|
||||||
|
inArray(schema.submission.result, SOLVED_RESULTS),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.submission.userId, schema.submission.problemId)
|
||||||
|
.having(since ? gte(firstSolved, since) : undefined),
|
||||||
|
])
|
||||||
|
|
||||||
|
const byUser = new Map<number, string[]>()
|
||||||
|
for (const row of solves) {
|
||||||
|
if (!row.at) continue
|
||||||
|
const list = byUser.get(row.userId)
|
||||||
|
if (list) list.push(row.at)
|
||||||
|
else byUser.set(row.userId, [row.at])
|
||||||
|
}
|
||||||
|
return people.map((person) => {
|
||||||
|
const pairs = (byUser.get(person.id) ?? [])
|
||||||
|
.map((stamp) => [Date.parse(stamp), stamp] as const)
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
return {
|
||||||
|
id: person.id,
|
||||||
|
username: person.username,
|
||||||
|
className: person.className,
|
||||||
|
avatar: avatarOf(person.avatar),
|
||||||
|
times: pairs.map(([time]) => time),
|
||||||
|
stamps: pairs.map(([, stamp]) => stamp),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `cutoff` 那一刻的名次(不给就是现在)。一样多的先做到的在前,再一样按 id ——
|
||||||
|
* 第三档不是凑数:同一秒做到的确实有(一节课的最后一道),没有稳定的兜底键名次会跳。
|
||||||
|
* `includeZero`:本班把一道没做对的也排上(全班名单),本年级 / 全服不排。
|
||||||
|
*/
|
||||||
|
export function standings(entrants: Entrant[], cutoff: number | null, includeZero: boolean) {
|
||||||
|
const list = entrants
|
||||||
|
.map((entrant) => {
|
||||||
|
let solved = entrant.times.length
|
||||||
|
if (cutoff !== null) {
|
||||||
|
solved = 0
|
||||||
|
while (solved < entrant.times.length && entrant.times[solved]! < cutoff) solved++
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
entrant,
|
||||||
|
solved,
|
||||||
|
reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY,
|
||||||
|
reachedAt: solved ? entrant.stamps[solved - 1]! : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((row) => includeZero || row.solved > 0)
|
||||||
|
.sort((a, b) => b.solved - a.solved || a.reached - b.reached || a.entrant.id - b.entrant.id)
|
||||||
|
return list.map((row, index): Standing => ({
|
||||||
|
entrant: row.entrant,
|
||||||
|
rank: index + 1,
|
||||||
|
solved: row.solved,
|
||||||
|
reachedAt: row.reachedAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「要多关心的同学」:这学期做对不到这么多道 */
|
||||||
|
const CARE_BELOW = 3
|
||||||
|
/** 一档同分至少这么多人,才说「N 个人停在 x 道」 */
|
||||||
|
const PLATEAU_MIN = 5
|
||||||
|
/** 班级详情的每周人均列最近几周 */
|
||||||
|
const DETAIL_WEEKS = 6
|
||||||
|
|
||||||
|
/** 有班级的学生,这学期的做对时刻 —— 班级对抗和班级详情都从这一份算 */
|
||||||
|
async function classedEntrants() {
|
||||||
|
const term = termStart()
|
||||||
|
const where = and(
|
||||||
|
rankedStudents,
|
||||||
|
isNotNull(schema.user.className),
|
||||||
|
ne(schema.user.className, ""),
|
||||||
|
)!
|
||||||
|
return { term, entrants: await loadEntrants(where, term) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function battleFrom(entrants: Entrant[]) {
|
||||||
|
const week = Date.parse(weekStart())
|
||||||
|
const classes = new Map<string, { members: number; term: number; week: number }>()
|
||||||
|
for (const entrant of entrants) {
|
||||||
|
const entry = classes.get(entrant.className!) ?? { members: 0, term: 0, week: 0 }
|
||||||
|
entry.members++
|
||||||
|
entry.term += entrant.times.length
|
||||||
|
entry.week += entrant.times.filter((time) => time >= week).length
|
||||||
|
classes.set(entrant.className!, entry)
|
||||||
|
}
|
||||||
|
return [...classes]
|
||||||
|
.filter(([, entry]) => entry.term > 0)
|
||||||
|
.map(([className, entry]) => ({
|
||||||
|
className,
|
||||||
|
members: entry.members,
|
||||||
|
perCapita: rounded(entry.term / entry.members, 1),
|
||||||
|
weekGain: rounded(entry.week / entry.members, 1),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.perCapita - a.perCapita || b.weekGain - a.weekGain)
|
||||||
|
.map((item, index) => ({ ...item, rank: index + 1 }) satisfies ClassBattleItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级对抗:全服每个班这学期人均做对几道,外加这周人均涨了多少。人均而不是总数 ——
|
||||||
|
* 班级人数从 11 到 58 都有,比总数等于比人多。这学期一道没做对的班不列(还没开始用)。
|
||||||
|
* 不计入排名的人分子分母都不算:抄来的题不该替全班加分。
|
||||||
|
*/
|
||||||
|
export async function classBattle() {
|
||||||
|
const { entrants } = await classedEntrants()
|
||||||
|
return { items: battleFrom(entrants) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function average(values: number[]) {
|
||||||
|
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(values: number[]) {
|
||||||
|
if (!values.length) return 0
|
||||||
|
const sorted = [...values].sort((a, b) => a - b)
|
||||||
|
const middle = Math.floor(sorted.length / 2)
|
||||||
|
return sorted.length % 2 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级详情,抽屉里的全部数字(也是班级 AI 分析的输入)。`withCare` 只对老师开:
|
||||||
|
* 「要多关心的同学」点了名,学生不该看到别人被点名。
|
||||||
|
*/
|
||||||
|
export async function classDetail(className: string, withCare: boolean): Promise<ClassDetail> {
|
||||||
|
const { term, entrants } = await classedEntrants()
|
||||||
|
const battle = battleFrom(entrants)
|
||||||
|
const mine = entrants.filter((entrant) => entrant.className === className)
|
||||||
|
const solved = mine.map((entrant) => entrant.times.length)
|
||||||
|
const desc = [...solved].sort((a, b) => b - a)
|
||||||
|
const tenth = Math.max(1, Math.ceil(mine.length / 10))
|
||||||
|
|
||||||
|
// 同年级在用的班:班级对抗里列着的(这学期做对过题)、班号前两位相同
|
||||||
|
const grade = className.slice(0, 2)
|
||||||
|
const gradeClasses = new Set(
|
||||||
|
battle.filter((item) => item.className.slice(0, 2) === grade).map((item) => item.className),
|
||||||
|
)
|
||||||
|
const gradeEntrants = entrants.filter((entrant) => gradeClasses.has(entrant.className!))
|
||||||
|
const gradeAvg = gradeEntrants.length
|
||||||
|
? rounded(average(gradeEntrants.map((entrant) => entrant.times.length)), 1)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const groups = new Map<number, number>()
|
||||||
|
for (const value of solved) if (value > 0) groups.set(value, (groups.get(value) ?? 0) + 1)
|
||||||
|
const [plateau] = [...groups]
|
||||||
|
.filter(([, n]) => n >= PLATEAU_MIN)
|
||||||
|
.sort((a, b) => b[1] - a[1] || b[0] - a[0])
|
||||||
|
|
||||||
|
// 每周:周一零点起算,从这学期第一周到这周,只留最近几周
|
||||||
|
const firstWeek = Date.parse(weekStart(term))
|
||||||
|
const thisWeek = Date.parse(weekStart())
|
||||||
|
const weeks: ClassDetail["weeks"] = []
|
||||||
|
for (let from = firstWeek; from <= thisWeek; from += WEEK_MS) {
|
||||||
|
const inWeek = (entrant: Entrant) =>
|
||||||
|
entrant.times.filter((time) => time >= from && time < from + WEEK_MS).length
|
||||||
|
weeks.push({
|
||||||
|
weekStart: new Date(from).toISOString(),
|
||||||
|
perCapita: mine.length ? rounded(average(mine.map(inWeek)), 1) : 0,
|
||||||
|
gradeAvg: gradeEntrants.length ? rounded(average(gradeEntrants.map(inWeek)), 1) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let care: ClassDetail["care"] = null
|
||||||
|
if (withCare) {
|
||||||
|
const low = mine
|
||||||
|
.filter((entrant) => entrant.times.length < CARE_BELOW)
|
||||||
|
.sort((a, b) => a.times.length - b.times.length || a.id - b.id)
|
||||||
|
const submitted = low.length
|
||||||
|
? await db
|
||||||
|
.select({ userId: schema.submission.userId, n: count() })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(
|
||||||
|
schema.submission.userId,
|
||||||
|
low.map((entrant) => entrant.id),
|
||||||
|
),
|
||||||
|
isNull(schema.submission.contestId),
|
||||||
|
gte(schema.submission.createTime, term),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.submission.userId)
|
||||||
|
: []
|
||||||
|
const counts = new Map(submitted.map((row) => [row.userId, row.n]))
|
||||||
|
care = low.map((entrant) => ({
|
||||||
|
user: sampleUser(entrant, null),
|
||||||
|
avatar: entrant.avatar,
|
||||||
|
solved: entrant.times.length,
|
||||||
|
submissions: counts.get(entrant.id) ?? 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = battle.find((entry) => entry.className === className)
|
||||||
|
return {
|
||||||
|
className,
|
||||||
|
start: term,
|
||||||
|
members: mine.length,
|
||||||
|
rank: item?.rank ?? null,
|
||||||
|
battleSize: battle.length,
|
||||||
|
perCapita: mine.length ? rounded(average(solved), 1) : 0,
|
||||||
|
gradeAvg,
|
||||||
|
median: median(solved),
|
||||||
|
solvedMembers: solved.filter((value) => value > 0).length,
|
||||||
|
top10Avg: rounded(average(desc.slice(0, tenth)), 1),
|
||||||
|
bottom10Avg: rounded(average(desc.slice(-tenth)), 1),
|
||||||
|
plateau: plateau ? { solved: plateau[0], count: plateau[1] } : null,
|
||||||
|
distribution: solved,
|
||||||
|
weeks: weeks.slice(-DETAIL_WEEKS),
|
||||||
|
care,
|
||||||
|
} satisfies ClassDetail
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type KnowledgeMap,
|
type KnowledgeMap,
|
||||||
type ClassComparisonResponse,
|
type ClassComparisonResponse,
|
||||||
type ClassBattleItem,
|
type ClassBattleItem,
|
||||||
|
type ClassDetail,
|
||||||
type RankBoard,
|
type RankBoard,
|
||||||
type RankPeriod,
|
type RankPeriod,
|
||||||
type RankScope,
|
type RankScope,
|
||||||
@@ -245,6 +246,11 @@ export function getClassBattle() {
|
|||||||
return api.get<ClassBattleItem[]>("rankings/classes")
|
return api.get<ClassBattleItem[]>("rankings/classes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 班级详情(抽屉):谁都能看,「要多关心的同学」只有老师拿得到 */
|
||||||
|
export function getClassDetail(className: string) {
|
||||||
|
return api.get<ClassDetail>("rankings/class-detail", { params: { className } })
|
||||||
|
}
|
||||||
|
|
||||||
/** 本班最近几周的每周冠军 */
|
/** 本班最近几周的每周冠军 */
|
||||||
export function getWeeklyChampions(className?: string) {
|
export function getWeeklyChampions(className?: string) {
|
||||||
return api.get<WeeklyChampion[]>("rankings/champions", {
|
return api.get<WeeklyChampion[]>("rankings/champions", {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import UserName from "shared/components/UserName.vue"
|
|||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import RankAvatar from "./RankAvatar.vue"
|
import RankAvatar from "./RankAvatar.vue"
|
||||||
|
|
||||||
/** 本班最近几周的每周冠军,底下再挂这张榜上「冲得最猛」的那个人 */
|
/** 本班最近几周的每周冠军,底下再挂这张榜上「进步最大」的那个人 */
|
||||||
defineProps<{
|
defineProps<{
|
||||||
champions: WeeklyChampion[]
|
champions: WeeklyChampion[]
|
||||||
hot: RankRow | null
|
hot: RankRow | null
|
||||||
@@ -51,7 +51,7 @@ function weekLabel(start: string) {
|
|||||||
</div>
|
</div>
|
||||||
<span v-if="!champions.length" class="muted">最近几周还没有冠军</span>
|
<span v-if="!champions.length" class="muted">最近几周还没有冠军</span>
|
||||||
<div v-if="hot" class="row hot-line">
|
<div v-if="hot" class="row hot-line">
|
||||||
<span class="hot-tag">冲得最猛</span>
|
<span class="hot-tag">进步最大</span>
|
||||||
<RankAvatar
|
<RankAvatar
|
||||||
:username="hot.user.username"
|
:username="hot.user.username"
|
||||||
:avatar="hot.avatar"
|
:avatar="hot.avatar"
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ const props = defineProps<{
|
|||||||
teacher?: boolean
|
teacher?: boolean
|
||||||
/** 放在手机页签卡里:不要自己的边框和标题 */
|
/** 放在手机页签卡里:不要自己的边框和标题 */
|
||||||
bare?: boolean
|
bare?: boolean
|
||||||
|
/** 显示「班级 PK」入口(原来排名页上那个按钮,管理员角色才有) */
|
||||||
|
pk?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
const emit = defineEmits<{ pick: [className: string] }>()
|
||||||
|
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const palette = useRankPalette()
|
const palette = useRankPalette()
|
||||||
@@ -52,7 +55,8 @@ function shortLabel(className: string) {
|
|||||||
<template>
|
<template>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="item in shown"
|
v-for="item in shown"
|
||||||
@@ -61,7 +65,9 @@ function shortLabel(className: string) {
|
|||||||
:class="{ mine: item.className === mine }"
|
:class="{ mine: item.className === mine }"
|
||||||
>
|
>
|
||||||
<span class="rank">{{ item.rank }}</span>
|
<span class="rank">{{ item.rank }}</span>
|
||||||
<span class="name">{{ classLabel(item.className) }}</span>
|
<button class="name" title="看班级详情" @click="emit('pick', item.className)">
|
||||||
|
{{ classLabel(item.className) }}
|
||||||
|
</button>
|
||||||
<span class="bar-box">
|
<span class="bar-box">
|
||||||
<span
|
<span
|
||||||
class="bar"
|
class="bar"
|
||||||
@@ -132,6 +138,26 @@ function shortLabel(className: string) {
|
|||||||
|
|
||||||
.name {
|
.name {
|
||||||
width: 86px;
|
width: 86px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name:hover {
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ClassDetail } from "@oj2/contract"
|
||||||
|
import { MdPreview } from "md-editor-v3"
|
||||||
|
import "md-editor-v3/lib/preview.css"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { getClassDetail } from "oj/api"
|
||||||
|
import { classLabel } from "oj/submission/utils"
|
||||||
|
import UserName from "shared/components/UserName.vue"
|
||||||
|
import { useAIStream } from "shared/composables/aiStream"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
|
import { useRankPalette } from "../palette"
|
||||||
|
import RankAvatar from "./RankAvatar.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级详情(设计稿「班级详情 · 老师 / 学生」):从右边滑出的抽屉,代替原来那个满是
|
||||||
|
* 四分位数、标准差、综合分的弹框。谁都能看;「要多关心的同学」和 AI 分析只给老师,
|
||||||
|
* AI 报告直接在抽屉里出,不再关掉再弹第二个框。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ className: string | null; teacher: boolean; pk: boolean }>()
|
||||||
|
const show = defineModel<boolean>("show", { required: true })
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const palette = useRankPalette()
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
|
const detail = ref<ClassDetail | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const report = ref("")
|
||||||
|
const ai = useAIStream()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [show.value, props.className] as const,
|
||||||
|
async ([open, className]) => {
|
||||||
|
if (!open || !className) return
|
||||||
|
if (detail.value?.className !== className) {
|
||||||
|
report.value = ""
|
||||||
|
ai.abort()
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
detail.value = await getClassDetail(className)
|
||||||
|
} catch {
|
||||||
|
detail.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 分布点阵:每个做对数一列,一列里一个点一个同学;做对不到 3 道的老师那边标红 */
|
||||||
|
const columns = computed(() => {
|
||||||
|
const counts = new Map<number, number>()
|
||||||
|
for (const n of detail.value?.distribution ?? []) counts.set(n, (counts.get(n) ?? 0) + 1)
|
||||||
|
return [...counts].sort((a, b) => a[0] - b[0]).map(([solved, people]) => ({ solved, people }))
|
||||||
|
})
|
||||||
|
const dot = computed(() => {
|
||||||
|
const tallest = Math.max(1, ...columns.value.map((column) => column.people))
|
||||||
|
return Math.max(4, Math.min(10, Math.floor(172 / tallest) - 2))
|
||||||
|
})
|
||||||
|
|
||||||
|
const weekMax = computed(() =>
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
...(detail.value?.weeks ?? []).flatMap((week) => [week.perCapita, week.gradeAvg ?? 0]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function analyze() {
|
||||||
|
if (!props.className) return
|
||||||
|
report.value = ""
|
||||||
|
try {
|
||||||
|
await ai.run(
|
||||||
|
"ai/class-analysis",
|
||||||
|
{ className: props.className },
|
||||||
|
{ onDelta: (content) => (report.value += content) },
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
message.error((error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(path: string, query: Record<string, string>) {
|
||||||
|
show.value = false
|
||||||
|
router.push({ path, query })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-drawer v-model:show="show" :width="isDesktop ? 560 : '100%'" placement="right">
|
||||||
|
<n-drawer-content closable :native-scrollbar="false">
|
||||||
|
<template #header>
|
||||||
|
<div class="head">
|
||||||
|
<span class="title">{{ className ? classLabel(className) : "班级详情" }}</span>
|
||||||
|
<span v-if="detail" class="muted">
|
||||||
|
{{ detail.members }} 人 · 这学期({{ parseTime(detail.start, "M月D日") }}起)
|
||||||
|
<template v-if="detail.rank"> · 班级对抗第 {{ detail.rank }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-spin :show="loading">
|
||||||
|
<div v-if="detail" class="body">
|
||||||
|
<div class="bigs">
|
||||||
|
<div class="big">
|
||||||
|
<span>人均做对</span><b>{{ detail.perCapita }}</b>
|
||||||
|
<span v-if="detail.gradeAvg !== null">年级平均 {{ detail.gradeAvg }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="big">
|
||||||
|
<span>中间那位同学做对</span><b>{{ detail.median }} 道</b><span>一半人比他多</span>
|
||||||
|
</div>
|
||||||
|
<div class="big">
|
||||||
|
<span>做对过题的</span>
|
||||||
|
<b
|
||||||
|
>{{ detail.solvedMembers }} <small>/ {{ detail.members }} 人</small></b
|
||||||
|
>
|
||||||
|
<span>{{
|
||||||
|
detail.members - detail.solvedMembers
|
||||||
|
? `${detail.members - detail.solvedMembers} 人还没做对`
|
||||||
|
: "人人都做对过"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="sec-title">
|
||||||
|
全班分布<span class="muted">一个点一个同学,下面是这学期做对几道</span>
|
||||||
|
</div>
|
||||||
|
<div class="dots">
|
||||||
|
<div v-for="column in columns" :key="column.solved" class="dot-col">
|
||||||
|
<div class="stack">
|
||||||
|
<span
|
||||||
|
v-for="i in column.people"
|
||||||
|
:key="i"
|
||||||
|
class="dot"
|
||||||
|
:style="{
|
||||||
|
width: `${dot}px`,
|
||||||
|
height: `${dot}px`,
|
||||||
|
background: teacher && column.solved < 3 ? '#e5484d' : '#9fb3c8',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="axis">{{ column.solved }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="line">
|
||||||
|
前 10% 平均 <b>{{ detail.top10Avg }}</b> 道 · 后 10% 平均
|
||||||
|
<b>{{ detail.bottom10Avg }}</b> 道
|
||||||
|
<template v-if="detail.plateau">
|
||||||
|
· {{ detail.plateau.count }} 个人停在 {{ detail.plateau.solved }} 道</template
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="detail.weeks.length">
|
||||||
|
<div class="sec-title">
|
||||||
|
每周人均新做对
|
||||||
|
<span class="key"><i :style="{ background: palette.me }" />本班</span>
|
||||||
|
<span v-if="detail.gradeAvg !== null" class="key"
|
||||||
|
><i :style="{ background: palette.bar }" />年级平均</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="weeks">
|
||||||
|
<div v-for="week in detail.weeks" :key="week.weekStart" class="week">
|
||||||
|
<div class="pair">
|
||||||
|
<div class="bar-col">
|
||||||
|
<span class="v me">{{ week.perCapita }}</span>
|
||||||
|
<span
|
||||||
|
class="bar"
|
||||||
|
:style="{
|
||||||
|
height: `${Math.max(2, (week.perCapita / weekMax) * 90)}px`,
|
||||||
|
background: palette.me,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="week.gradeAvg !== null" class="bar-col">
|
||||||
|
<span class="v">{{ week.gradeAvg }}</span>
|
||||||
|
<span
|
||||||
|
class="bar"
|
||||||
|
:style="{
|
||||||
|
height: `${Math.max(2, (week.gradeAvg / weekMax) * 90)}px`,
|
||||||
|
background: palette.bar,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="axis">{{ parseTime(week.weekStart, "M月D日") }}那周</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="detail.care">
|
||||||
|
<div class="sec-title">
|
||||||
|
要多关心的同学<span class="muted">这学期做对不到 3 道 · 只有老师看得到</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="row in detail.care" :key="row.user.id" class="care">
|
||||||
|
<RankAvatar :username="row.user.username" :avatar="row.avatar" :size="20" />
|
||||||
|
<UserName :username="row.user.username" />
|
||||||
|
<span class="muted">这学期 {{ row.solved }} 道 · 交过 {{ row.submissions }} 次</span>
|
||||||
|
<div class="spacer" />
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
@click.prevent="
|
||||||
|
go('/submission', { username: row.user.username, exactUsername: '1' })
|
||||||
|
"
|
||||||
|
>看他的提交 ›</a
|
||||||
|
>
|
||||||
|
<a href="#" @click.prevent="go('/ai-analysis', { username: row.user.username })"
|
||||||
|
>智能分析 ›</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span v-if="!detail.care.length" class="muted">没有,这学期人人都做对 3 道以上</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="teacher" class="ai">
|
||||||
|
<div class="ai-head">
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z" />
|
||||||
|
</svg>
|
||||||
|
<b>AI 分析这个班</b>
|
||||||
|
<span class="muted">根据上面这些数字写,仅供参考</span>
|
||||||
|
<div class="spacer" />
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
:type="report ? 'default' : 'info'"
|
||||||
|
:loading="ai.running.value"
|
||||||
|
@click="analyze"
|
||||||
|
>
|
||||||
|
{{ report ? "重新分析" : "开始分析" }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<span v-if="ai.waiting.value" class="muted">正在看这个班的数据…</span>
|
||||||
|
<MdPreview v-if="report" class="report" :model-value="report" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<a v-if="pk" href="#" class="pk" @click.prevent="go('/class', {})"
|
||||||
|
>和别的班比 → 班级 PK ›</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!loading" class="muted">这个班的数据没取到</div>
|
||||||
|
</n-spin>
|
||||||
|
</n-drawer-content>
|
||||||
|
</n-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bigs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big b {
|
||||||
|
font-size: 24px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big b small {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.big span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-col {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
height: 172px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis {
|
||||||
|
font-size: 11px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key i {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weeks {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair {
|
||||||
|
height: 110px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 16px;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v {
|
||||||
|
font-size: 11px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v.me {
|
||||||
|
color: #18a058;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.care {
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.care a,
|
||||||
|
.pk {
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(47, 111, 208, 0.3);
|
||||||
|
background: rgba(47, 111, 208, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
color: #2f6fd0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-head b {
|
||||||
|
color: v-bind("theme.textColor1");
|
||||||
|
}
|
||||||
|
|
||||||
|
.report {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report :deep(.md-editor-preview-wrapper) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,7 +6,7 @@ import { USERNAME_CLASS_RE } from "utils/constants"
|
|||||||
/**
|
/**
|
||||||
* 排名页的头像。九成以上的学生没换过头像(1797 个号是 default.png),一排一模一样的
|
* 排名页的头像。九成以上的学生没换过头像(1797 个号是 default.png),一排一模一样的
|
||||||
* 默认图分不出人,所以默认头像画成「名字最后一个字 + 彩色圆」,颜色按用户名固定 ——
|
* 默认图分不出人,所以默认头像画成「名字最后一个字 + 彩色圆」,颜色按用户名固定 ——
|
||||||
* 同一个人在领奖台、赛道、对手卡里是同一个颜色。自己是实心绿。
|
* 同一个人在领奖台、赛道、「你」那张卡里是同一个颜色。自己是实心绿。
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
username: string
|
username: string
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export interface MiniRank {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 「你」那张卡:大字第几名、比周一升了几名,一句最能推人一把的话(离领奖台几道 / 升了几名),
|
* 「你」那张卡:大字第几名、比周一升了几名,一句最能推人一把的话(离领奖台几道 / 升了几名),
|
||||||
* 再是两个对手 —— 前面那个要追的、后面那个在追你的,差几秒、差几道都写出来。
|
* 再是前一名、后一名,差几秒、差几道都写出来。
|
||||||
|
*
|
||||||
|
* 用词只说位置(前一名 / 后一名 / 进步最大),不用「追」「对手」「冲」:用户指出
|
||||||
|
* 「要追的」「追你」会被中学生读成追求谁,「冲得最猛」也容易误会。
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
me: RankRow
|
me: RankRow
|
||||||
@@ -25,7 +28,7 @@ const props = defineProps<{
|
|||||||
total: number
|
total: number
|
||||||
label: string
|
label: string
|
||||||
minis: MiniRank[]
|
minis: MiniRank[]
|
||||||
/** 手机:对手的说明换到名字下面一行,别被截断 */
|
/** 手机:前后一名的说明换到名字下面一行,别被截断 */
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -33,7 +36,7 @@ const theme = useThemeVars()
|
|||||||
|
|
||||||
const headline = computed(() => {
|
const headline = computed(() => {
|
||||||
const { me, ahead, third } = props
|
const { me, ahead, third } = props
|
||||||
if (me.rank === 1) return "你是第一名,后面的人在追你"
|
if (me.rank === 1) return "你是第一名"
|
||||||
if (me.rank <= 3 && ahead)
|
if (me.rank <= 3 && ahead)
|
||||||
return `你在领奖台上 · 再做对 ${toPass(me, ahead)} 道就是第 ${ahead.rank} 名`
|
return `你在领奖台上 · 再做对 ${toPass(me, ahead)} 道就是第 ${ahead.rank} 名`
|
||||||
if (third) {
|
if (third) {
|
||||||
@@ -47,13 +50,13 @@ const headline = computed(() => {
|
|||||||
|
|
||||||
function chaseNote(me: RankRow, ahead: RankRow) {
|
function chaseNote(me: RankRow, ahead: RankRow) {
|
||||||
if (ahead.solved === me.solved && ahead.reachedAt && me.reachedAt)
|
if (ahead.solved === me.solved && ahead.reachedAt && me.reachedAt)
|
||||||
return `早你 ${gapText(ahead.reachedAt, me.reachedAt)}做到 ${me.solved} 道`
|
return `比你早 ${gapText(ahead.reachedAt, me.reachedAt)}做到 ${me.solved} 道`
|
||||||
return `比你多 ${ahead.solved - me.solved} 道`
|
return `比你多 ${ahead.solved - me.solved} 道`
|
||||||
}
|
}
|
||||||
|
|
||||||
function threatNote(me: RankRow, behind: RankRow) {
|
function threatNote(me: RankRow, behind: RankRow) {
|
||||||
if (behind.solved === me.solved && behind.reachedAt && me.reachedAt)
|
if (behind.solved === me.solved && behind.reachedAt && me.reachedAt)
|
||||||
return `只比你晚 ${gapText(me.reachedAt, behind.reachedAt)},再做对 1 道就超过你`
|
return `比你晚 ${gapText(me.reachedAt, behind.reachedAt)}做到 ${me.solved} 道`
|
||||||
if (!behind.solved) return "还没做对"
|
if (!behind.solved) return "还没做对"
|
||||||
return `比你少 ${me.solved - behind.solved} 道`
|
return `比你少 ${me.solved - behind.solved} 道`
|
||||||
}
|
}
|
||||||
@@ -92,13 +95,13 @@ function threatNote(me: RankRow, behind: RankRow) {
|
|||||||
<b>{{ headline }}</b>
|
<b>{{ headline }}</b>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="ahead" class="rival">
|
<div v-if="ahead" class="rival">
|
||||||
<span class="tag chase">要追</span>
|
<span class="tag chase">前一名</span>
|
||||||
<RankAvatar :username="ahead.user.username" :avatar="ahead.avatar" :size="20" />
|
<RankAvatar :username="ahead.user.username" :avatar="ahead.avatar" :size="20" />
|
||||||
<UserName :username="ahead.user.username" class="rival-name" />
|
<UserName :username="ahead.user.username" class="rival-name" />
|
||||||
<span class="note">{{ chaseNote(me, ahead) }}</span>
|
<span class="note">{{ chaseNote(me, ahead) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="behind" class="rival">
|
<div v-if="behind" class="rival">
|
||||||
<span class="tag threat">追你</span>
|
<span class="tag threat">后一名</span>
|
||||||
<RankAvatar :username="behind.user.username" :avatar="behind.avatar" :size="20" />
|
<RankAvatar :username="behind.user.username" :avatar="behind.avatar" :size="20" />
|
||||||
<UserName :username="behind.user.username" class="rival-name" />
|
<UserName :username="behind.user.username" class="rival-name" />
|
||||||
<span class="note">{{ threatNote(me, behind) }}</span>
|
<span class="note">{{ threatNote(me, behind) }}</span>
|
||||||
@@ -112,6 +115,22 @@ function threatNote(me: RankRow, behind: RankRow) {
|
|||||||
<b v-else class="none">没上榜</b>
|
<b v-else class="none">没上榜</b>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<router-link to="/ai-analysis" class="analysis">
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z" />
|
||||||
|
</svg>
|
||||||
|
看我的智能分析:哪类题卡得多、哪段时间最常做题 ›
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -238,6 +257,21 @@ function threatNote(me: RankRow, behind: RankRow) {
|
|||||||
background: rgba(199, 106, 18, 0.12);
|
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 {
|
.minis {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import RankAvatar from "./RankAvatar.vue"
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。
|
* 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。
|
||||||
* 你要追的人站在台上时台阶描蓝边、挂「你要追的」,和赛道里的蓝条是一回事。
|
* 你的前一名站在台上时台阶描蓝边、挂「前一名」,和赛道里的蓝条是一回事。
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
rows: RankRow[]
|
rows: RankRow[]
|
||||||
@@ -49,8 +49,8 @@ const HEIGHTS: Record<number, number> = { 1: 50, 2: 38, 3: 30 }
|
|||||||
>
|
>
|
||||||
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
|
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span v-if="row.user.id === chaseId" class="tag chase">你要追的</span>
|
<span v-if="row.user.id === chaseId" class="tag chase">前一名</span>
|
||||||
<span v-else-if="row.user.id === threatId" class="tag threat">在追你</span>
|
<span v-else-if="row.user.id === threatId" class="tag threat">后一名</span>
|
||||||
<span v-else-if="row.user.id === meId" class="tag me">你</span>
|
<span v-else-if="row.user.id === meId" class="tag me">你</span>
|
||||||
</div>
|
</div>
|
||||||
<RankAvatar
|
<RankAvatar
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const emit = defineEmits<{
|
|||||||
open: [username: string]
|
open: [username: string]
|
||||||
expand: []
|
expand: []
|
||||||
submissions: [username: string]
|
submissions: [username: string]
|
||||||
|
analysis: [username: string]
|
||||||
hide: [row: RankRow]
|
hide: [row: RankRow]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -130,11 +131,13 @@ function width(row: RankRow) {
|
|||||||
|
|
||||||
const menu: DropdownOption[] = [
|
const menu: DropdownOption[] = [
|
||||||
{ label: "看他的提交", key: "submissions" },
|
{ label: "看他的提交", key: "submissions" },
|
||||||
|
{ label: "看他的智能分析", key: "analysis" },
|
||||||
{ label: "不计入排名…", key: "hide" },
|
{ label: "不计入排名…", key: "hide" },
|
||||||
]
|
]
|
||||||
|
|
||||||
function onMenu(key: string, row: RankRow) {
|
function onMenu(key: string, row: RankRow) {
|
||||||
if (key === "submissions") emit("submissions", row.user.username)
|
if (key === "submissions") emit("submissions", row.user.username)
|
||||||
|
else if (key === "analysis") emit("analysis", row.user.username)
|
||||||
else emit("hide", row)
|
else emit("hide", row)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -165,9 +168,9 @@ function onMenu(key: string, row: RankRow) {
|
|||||||
<button class="who" @click="emit('open', item.row.user.username)">
|
<button class="who" @click="emit('open', item.row.user.username)">
|
||||||
<UserName :username="item.row.user.username" />
|
<UserName :username="item.row.user.username" />
|
||||||
</button>
|
</button>
|
||||||
<span v-if="item.row.user.id === chaseId" class="tag chase">要追</span>
|
<span v-if="item.row.user.id === chaseId" class="tag chase">前一名</span>
|
||||||
<span v-else-if="item.row.user.id === threatId" class="tag threat">追你</span>
|
<span v-else-if="item.row.user.id === threatId" class="tag threat">后一名</span>
|
||||||
<span v-else-if="item.row.user.id === hotId" class="tag hot">冲得最猛</span>
|
<span v-else-if="item.row.user.id === hotId" class="tag hot">进步最大</span>
|
||||||
<span class="solved">{{ item.row.solved }}</span>
|
<span class="solved">{{ item.row.solved }}</span>
|
||||||
<span class="change">
|
<span class="change">
|
||||||
<span v-if="(item.row.change ?? 0) > 0" class="up">↑{{ item.row.change }}</span>
|
<span v-if="(item.row.change ?? 0) > 0" class="up">↑{{ item.row.change }}</span>
|
||||||
@@ -208,9 +211,9 @@ function onMenu(key: string, row: RankRow) {
|
|||||||
<span v-else-if="item.row.change === 0" class="flat">—</span>
|
<span v-else-if="item.row.change === 0" class="flat">—</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="tag-box">
|
<span class="tag-box">
|
||||||
<span v-if="item.row.user.id === chaseId" class="tag chase">要追</span>
|
<span v-if="item.row.user.id === chaseId" class="tag chase">前一名</span>
|
||||||
<span v-else-if="item.row.user.id === threatId" class="tag threat">追你</span>
|
<span v-else-if="item.row.user.id === threatId" class="tag threat">后一名</span>
|
||||||
<span v-else-if="item.row.user.id === hotId" class="tag hot">冲得最猛</span>
|
<span v-else-if="item.row.user.id === hotId" class="tag hot">进步最大</span>
|
||||||
</span>
|
</span>
|
||||||
<n-dropdown
|
<n-dropdown
|
||||||
v-if="teacher"
|
v-if="teacher"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useThemeVars } from "naive-ui"
|
|||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 我和两个对手每周日晚上的名次。名次越小越好,所以纵轴倒过来(第 1 在最上面)。
|
* 我和前后一名每周日晚上的名次。名次越小越好,所以纵轴倒过来(第 1 在最上面)。
|
||||||
* 线的末端直接写名字,不要图例 —— 三条线,看名字比对颜色快。
|
* 线的末端直接写名字,不要图例 —— 三条线,看名字比对颜色快。
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -88,7 +88,7 @@ const ends = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<svg :viewBox="`0 0 ${W} ${H}`" class="chart" role="img" aria-label="我和对手每周的名次">
|
<svg :viewBox="`0 0 ${W} ${H}`" class="chart" role="img" aria-label="我和前后一名每周的名次">
|
||||||
<g v-for="tick in ticks" :key="tick">
|
<g v-for="tick in ticks" :key="tick">
|
||||||
<line :x1="PAD.left" :x2="W - PAD.right" :y1="y(tick)" :y2="y(tick)" class="grid" />
|
<line :x1="PAD.left" :x2="W - PAD.right" :y1="y(tick)" :y2="y(tick)" class="grid" />
|
||||||
<text :x="PAD.left - 6" :y="y(tick) + 4" text-anchor="end" class="axis">第 {{ tick }}</text>
|
<text :x="PAD.left - 6" :y="y(tick) + 4" text-anchor="end" class="axis">第 {{ tick }}</text>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { USERNAME_CLASS_RE } from "utils/constants"
|
|||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import ChampionList from "./components/ChampionList.vue"
|
import ChampionList from "./components/ChampionList.vue"
|
||||||
import ClassBattle from "./components/ClassBattle.vue"
|
import ClassBattle from "./components/ClassBattle.vue"
|
||||||
|
import ClassDetailDrawer from "./components/ClassDetailDrawer.vue"
|
||||||
import RankMe, { type MiniRank } from "./components/RankMe.vue"
|
import RankMe, { type MiniRank } from "./components/RankMe.vue"
|
||||||
import RankPodium from "./components/RankPodium.vue"
|
import RankPodium from "./components/RankPodium.vue"
|
||||||
import RankTrack from "./components/RankTrack.vue"
|
import RankTrack from "./components/RankTrack.vue"
|
||||||
@@ -86,8 +87,11 @@ async function load() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
board.value = await fetchBoard(scope.value, period.value)
|
board.value = await fetchBoard(scope.value, period.value)
|
||||||
if (teacher.value && !teacherClass.value && board.value.scope !== "all")
|
// 老师在「全部」时点了本班:后端挑了最近上课的班,回填到下拉框(这一下不用再取一遍)
|
||||||
teacherClass.value = board.value.scope === "class" ? (board.value.className ?? "") : ""
|
if (teacher.value && !teacherClass.value && board.value.scope === "class") {
|
||||||
|
backfilling = true
|
||||||
|
teacherClass.value = board.value.className ?? ""
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
board.value = null
|
board.value = null
|
||||||
} finally {
|
} finally {
|
||||||
@@ -131,6 +135,9 @@ async function expand() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let backfilling = false
|
||||||
|
|
||||||
|
/** 范围、时间、老师选的班:三个一起变(下拉框选班会顺手改范围)也只取一次 */
|
||||||
watch([scope, period, teacherClass], () => {
|
watch([scope, period, teacherClass], () => {
|
||||||
router.replace({
|
router.replace({
|
||||||
query: {
|
query: {
|
||||||
@@ -140,19 +147,15 @@ watch([scope, period, teacherClass], () => {
|
|||||||
class: teacher.value && teacherClass.value ? teacherClass.value : undefined,
|
class: teacher.value && teacherClass.value ? teacherClass.value : undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
if (backfilling) backfilling = false
|
||||||
watch([scope, period], load)
|
else load()
|
||||||
watch(teacherClass, (value, old) => {
|
|
||||||
// 后端回填的那一下(空 → 班号)不用再取一遍
|
|
||||||
if (old || !board.value || board.value.className !== value) {
|
|
||||||
cache.clear()
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!userStore.user) await userStore.getMyProfile().catch(() => {})
|
if (!userStore.user) await userStore.getMyProfile().catch(() => {})
|
||||||
scope.value = pick(route.query.scope, SCOPE_OPTIONS, canScopeClass.value ? "class" : "all")
|
// 老师默认看全部(用户定的),要看哪个班自己在下拉框里选;学生默认看本班
|
||||||
|
const fallback = teacher.value ? "all" : canScopeClass.value ? "class" : "all"
|
||||||
|
scope.value = pick(route.query.scope, SCOPE_OPTIONS, fallback)
|
||||||
if (!canScopeClass.value && scope.value !== "all") scope.value = "all"
|
if (!canScopeClass.value && scope.value !== "all") scope.value = "all"
|
||||||
loadBattle()
|
loadBattle()
|
||||||
// scope 从 "all" 改成别的会触发 watch 去取;没改就自己取
|
// scope 从 "all" 改成别的会触发 watch 去取;没改就自己取
|
||||||
@@ -160,7 +163,19 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/** 班级那几块(走势、这周本班、每周冠军、班级对抗高亮)对的是哪个班 */
|
/** 班级那几块(走势、这周本班、每周冠军、班级对抗高亮)对的是哪个班 */
|
||||||
const classContext = computed(() => (teacher.value ? teacherClass.value : myClass.value) || null)
|
const classContext = computed(
|
||||||
|
() => (teacher.value ? (scope.value === "all" ? "" : teacherClass.value) : myClass.value) || null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 老师的班级下拉框:「全部」就是全服,选了班就切到本班(本年级时留在本年级) */
|
||||||
|
const pickedClass = computed({
|
||||||
|
get: () => (scope.value === "all" ? "" : teacherClass.value),
|
||||||
|
set: (value: string) => {
|
||||||
|
teacherClass.value = value
|
||||||
|
if (!value) scope.value = "all"
|
||||||
|
else if (scope.value === "all") scope.value = "class"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const rows = computed(() => board.value?.rows ?? [])
|
const rows = computed(() => board.value?.rows ?? [])
|
||||||
const podium = computed(() => rows.value.filter((row) => row.rank <= 3 && row.solved > 0))
|
const podium = computed(() => rows.value.filter((row) => row.rank <= 3 && row.solved > 0))
|
||||||
@@ -239,7 +254,10 @@ const teacherClassOptions = computed(() => {
|
|||||||
const list = configStore.config?.classList ?? []
|
const list = configStore.config?.classList ?? []
|
||||||
const all =
|
const all =
|
||||||
teacherClass.value && !list.includes(teacherClass.value) ? [teacherClass.value, ...list] : list
|
teacherClass.value && !list.includes(teacherClass.value) ? [teacherClass.value, ...list] : list
|
||||||
return all.map((name) => ({ label: classLabel(name), value: name }))
|
return [
|
||||||
|
{ label: "全部", value: "" },
|
||||||
|
...all.map((name) => ({ label: classLabel(name), value: name })),
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const myBattle = computed(() => battle.value.find((item) => item.className === classContext.value))
|
const myBattle = computed(() => battle.value.find((item) => item.className === classContext.value))
|
||||||
@@ -250,7 +268,7 @@ const daysLeft = computed(() => {
|
|||||||
return Math.max(1, Math.ceil((Date.parse(start) + 7 * 86_400_000 - Date.now()) / 86_400_000))
|
return Math.max(1, Math.ceil((Date.parse(start) + 7 * 86_400_000 - Date.now()) / 86_400_000))
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 走势图三条线:在追你的、你要追的、你(画在最上面) */
|
/** 走势图三条线:后一名、前一名、你(画在最上面) */
|
||||||
const trendLines = computed(() => {
|
const trendLines = computed(() => {
|
||||||
const b = board.value
|
const b = board.value
|
||||||
if (!b?.me) return []
|
if (!b?.me) return []
|
||||||
@@ -287,10 +305,23 @@ watch(phoneTabs, (tabs) => {
|
|||||||
if (!tabs.some((tab) => tab.key === phoneTab.value)) phoneTab.value = "battle"
|
if (!tabs.some((tab) => tab.key === phoneTab.value)) phoneTab.value = "battle"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 班级详情(+ 老师的 AI 分析):点班级对抗里的班名、或老师「这个班」卡上的按钮 */
|
||||||
|
const detailClass = ref<string | null>(null)
|
||||||
|
const showDetail = ref(false)
|
||||||
|
function openDetail(className: string) {
|
||||||
|
detailClass.value = className
|
||||||
|
showDetail.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function open(username: string) {
|
function open(username: string) {
|
||||||
router.push({ path: "/user", query: { name: username } })
|
router.push({ path: "/user", query: { name: username } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 老师看某个学生的智能分析(那一页本来就支持 ?username=) */
|
||||||
|
function openAnalysis(username: string) {
|
||||||
|
router.push({ path: "/ai-analysis", query: { username } })
|
||||||
|
}
|
||||||
|
|
||||||
function openSubmissions(username: string) {
|
function openSubmissions(username: string) {
|
||||||
router.push({ path: "/submission", query: { username, exactUsername: "1" } })
|
router.push({ path: "/submission", query: { username, exactUsername: "1" } })
|
||||||
}
|
}
|
||||||
@@ -350,8 +381,8 @@ async function restore(userId: number) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<n-select
|
<n-select
|
||||||
v-if="teacher && scope !== 'all'"
|
v-if="teacher"
|
||||||
v-model:value="teacherClass"
|
v-model:value="pickedClass"
|
||||||
class="class-select"
|
class="class-select"
|
||||||
size="small"
|
size="small"
|
||||||
filterable
|
filterable
|
||||||
@@ -373,8 +404,8 @@ async function restore(userId: number) {
|
|||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<span v-if="!teacher && me" class="legend">
|
<span v-if="!teacher && me" class="legend">
|
||||||
<span><i :style="{ background: palette.me }" />你</span>
|
<span><i :style="{ background: palette.me }" />你</span>
|
||||||
<span v-if="board.ahead"><i :style="{ background: palette.chase }" />你要追的</span>
|
<span v-if="board.ahead"><i :style="{ background: palette.chase }" />前一名</span>
|
||||||
<span v-if="board.behind"><i :style="{ background: palette.threat }" />在追你的</span>
|
<span v-if="board.behind"><i :style="{ background: palette.threat }" />后一名</span>
|
||||||
<span v-if="mateClass"><i :style="{ background: palette.mate }" />你们班</span>
|
<span v-if="mateClass"><i :style="{ background: palette.mate }" />你们班</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -402,6 +433,7 @@ async function restore(userId: number) {
|
|||||||
@open="open"
|
@open="open"
|
||||||
@expand="expand"
|
@expand="expand"
|
||||||
@submissions="openSubmissions"
|
@submissions="openSubmissions"
|
||||||
|
@analysis="openAnalysis"
|
||||||
@hide="hide"
|
@hide="hide"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -438,6 +470,18 @@ async function restore(userId: number) {
|
|||||||
<span>这周人均涨</span><b>+{{ myBattle.weekGain }}</b>
|
<span>这周人均涨</span><b>+{{ myBattle.weekGain }}</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<n-button
|
||||||
|
v-if="classContext"
|
||||||
|
size="small"
|
||||||
|
secondary
|
||||||
|
type="info"
|
||||||
|
@click="openDetail(classContext)"
|
||||||
|
>
|
||||||
|
班级详情 · AI 分析
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" secondary @click="router.push('/class')">班级 PK</n-button>
|
||||||
|
</div>
|
||||||
<div class="hidden-list">
|
<div class="hidden-list">
|
||||||
<span class="hidden-title">不计入排名的人</span>
|
<span class="hidden-title">不计入排名的人</span>
|
||||||
<template v-if="board.scope === 'class'">
|
<template v-if="board.scope === 'class'">
|
||||||
@@ -457,13 +501,20 @@ async function restore(userId: number) {
|
|||||||
<span class="muted">做对 1 道新题就能上榜</span>
|
<span class="muted">做对 1 道新题就能上榜</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<ClassBattle v-if="isDesktop" :items="battle" :mine="classContext" :teacher="teacher" />
|
<ClassBattle
|
||||||
|
v-if="isDesktop"
|
||||||
|
:items="battle"
|
||||||
|
:mine="classContext"
|
||||||
|
:teacher="teacher"
|
||||||
|
:pk="userStore.isAdminRole"
|
||||||
|
@pick="openDetail"
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isDesktop && classContext" class="below">
|
<div v-if="isDesktop && classContext" class="below">
|
||||||
<div v-if="board?.trend && me" class="card">
|
<div v-if="board?.trend && me" class="card">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<b>我和对手的名次</b><span class="muted">每周日晚上 · 越高越好</span>
|
<b>我和前后一名的名次</b><span class="muted">每周日晚上 · 越高越好</span>
|
||||||
</div>
|
</div>
|
||||||
<RankTrend :trend="board.trend" :lines="trendLines" />
|
<RankTrend :trend="board.trend" :lines="trendLines" />
|
||||||
</div>
|
</div>
|
||||||
@@ -509,6 +560,8 @@ async function restore(userId: number) {
|
|||||||
:items="battle"
|
:items="battle"
|
||||||
:mine="classContext"
|
:mine="classContext"
|
||||||
:teacher="teacher"
|
:teacher="teacher"
|
||||||
|
:pk="userStore.isAdminRole"
|
||||||
|
@pick="openDetail"
|
||||||
/>
|
/>
|
||||||
<RankTrend
|
<RankTrend
|
||||||
v-else-if="phoneTab === 'trend' && board?.trend"
|
v-else-if="phoneTab === 'trend' && board?.trend"
|
||||||
@@ -529,6 +582,12 @@ async function restore(userId: number) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ClassDetailDrawer
|
||||||
|
v-model:show="showDetail"
|
||||||
|
:class-name="detailClass"
|
||||||
|
:teacher="teacher"
|
||||||
|
:pk="userStore.isAdminRole"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -709,6 +768,11 @@ async function restore(userId: number) {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.hidden-list {
|
.hidden-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useDark } from "@vueuse/core"
|
import { useDark } from "@vueuse/core"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、你要追的蓝、
|
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、前一名蓝、
|
||||||
* 在追你的橙、本年级 / 全服里你们班的人浅绿,其余灰;前三名金银铜。
|
* 后一名橙、本年级 / 全服里你们班的人浅绿,其余灰;前三名金银铜。
|
||||||
* 暗色下灰条和浅绿换深一档,不然在暗底上发白。
|
* 暗色下灰条和浅绿换深一档,不然在暗底上发白。
|
||||||
*/
|
*/
|
||||||
export function useRankPalette() {
|
export function useRankPalette() {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function toPass(me: RankRow, target: RankRow) {
|
|||||||
return target.solved - me.solved + 1
|
return target.solved - me.solved + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 这张榜上升得最猛的人(至少升 3 名才算),给他挂「冲得最猛」 */
|
/** 这张榜上升得最多的人(至少升 3 名才算),给他挂「进步最大」 */
|
||||||
export function hottest(rows: RankRow[]) {
|
export function hottest(rows: RankRow[]) {
|
||||||
let best: RankRow | null = null
|
let best: RankRow | null = null
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|||||||
@@ -232,8 +232,12 @@ export type HintDiagnosis = z.infer<typeof hintDiagnosisSchema>
|
|||||||
*/
|
*/
|
||||||
export const aiHintFeedbackRequestSchema = z.object({ helpful: z.boolean() })
|
export const aiHintFeedbackRequestSchema = z.object({ helpful: z.boolean() })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级 AI 分析只传班号,数字在服务端按「班级详情」那一套现算 —— 原来是前端把弹框里的
|
||||||
|
* 统计值整包传上来,报告和抽屉里看到的数字可能对不上
|
||||||
|
*/
|
||||||
export const classAnalysisRequestSchema = z.object({
|
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({
|
export const classPkAnalysisRequestSchema = z.object({
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const rankRowSchema = z.object({
|
|||||||
change: z.number().int().nullable(),
|
change: z.number().int().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 我和对手每周的名次,给「我和对手的名次」那张走势图 */
|
/** 我和前后一名每周的名次,给「我和前后一名的名次」那张走势图 */
|
||||||
export const rankTrendSchema = z.object({
|
export const rankTrendSchema = z.object({
|
||||||
/** 每个点的时刻(周一零点,也就是上周日晚上),最后一个是现在 */
|
/** 每个点的时刻(周一零点,也就是上周日晚上),最后一个是现在 */
|
||||||
points: z.array(z.string()),
|
points: z.array(z.string()),
|
||||||
@@ -101,3 +101,48 @@ export type RankBoard = z.infer<typeof rankBoardSchema>
|
|||||||
export type ClassBattleItem = z.infer<typeof classBattleItemSchema>
|
export type ClassBattleItem = z.infer<typeof classBattleItemSchema>
|
||||||
export type WeeklyChampion = z.infer<typeof weeklyChampionSchema>
|
export type WeeklyChampion = z.infer<typeof weeklyChampionSchema>
|
||||||
export type RankHiddenRequest = z.infer<typeof rankHiddenRequestSchema>
|
export type RankHiddenRequest = z.infer<typeof rankHiddenRequestSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级详情(设计稿「班级详情 · 老师 / 学生」)。原来弹框里的四分位数、四分位距、标准差、
|
||||||
|
* 综合分都去掉了,只留一眼看得懂的数:人均、中间那位同学、做对过的人、前后 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<typeof classDetailSchema>
|
||||||
Reference in new issue
Block a user