Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce0093fc29 | ||
|
|
d83804b814 |
No files matched your search
@@ -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,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)
|
|
||||||
})
|
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 } : {}),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
+486
-858
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user