feat(排名): 排名页重做成领奖台加全班赛道,一眼看出自己第几、该追谁、谁在追自己
Deploy / deploy (push) Canceled after 0s

- 一张榜两个开关:本班 / 本年级 / 全服 × 这周 / 这学期 / 全部,默认本班 · 这学期。
  原来最大那张全服 Top100 里,这学期在用的 25 级学生一个都进不去
- 做对一样多的不再按「提交少的在前」排,改成先做到的在前;名次旁边写比周一升降了几名
- 「你」那张卡:第几名、再做对几道上领奖台,要追的人早你几秒、追你的人晚你几秒
- 本年级 / 全服只列前面一段和你附近一段,中间折起来,点开再看
- 班级对抗按人均做对比,另有我和对手的名次走势、这周本班、每周冠军
- 老师不上榜,选班看;每行「⋯」能把怀疑抄代码的学生设成不计入排名(新增 user.rank_hidden_at,
  首页周榜同样不算他),随时能恢复
- 去掉全服 Top10、日活两张图、在线人数和班级详情弹框里的统计词,以及它们背后的旧接口

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-10 07:26:15 +08:00
1 parent 0cee4ef779
commit 1c6e2b9d82
31 files changed
+6633 -1392

No files matched your search

-9
View File
@@ -41,15 +41,6 @@ export async function onlineUserIds() {
return new Set(members.map(Number).filter(Number.isInteger))
}
/**
* 在线人数。前台榜单页要的就是这一个数 —— 不必像 onlineUserIds 那样把成员全拉回来,
* ZCOUNT 让 Redis 自己数(O(log N))。这里不顺手清过期成员:清理是写操作,
* 而这个端点是匿名可访问的。
*/
export async function onlineCount() {
return redis.zcount(PRESENCE_KEY, Date.now() - ONLINE_WINDOW_MS, "+inf")
}
/** 登出、被禁用、被踢下线:立刻从在线名单里摘掉,别等窗口自然过期 */
export async function clearOnline(userId: number) {
await redis.zrem(PRESENCE_KEY, String(userId))
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "rank_hidden_at" timestamp with time zone;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -211,6 +211,13 @@
"when": 1791555848163,
"tag": "0029_regrant_problemset_badges",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1791587559546,
"tag": "0030_add_user_rank_hidden",
"breakpoints": true
}
]
}
+5
View File
@@ -1259,6 +1259,11 @@ export const user = pgTable(
problemPermission: text("problem_permission").notNull().$type<ProblemPermission>(),
rawPassword: varchar("raw_password", { length: 20 }),
className: text("class_name"),
/**
* 老师把这个学生「不计入排名」的时刻,null = 正常上榜。怀疑抄代码时用,随时能恢复
* (见 routes/ranking.ts)。只管排名页和首页周榜,比赛排名不受它影响
*/
rankHiddenAt: timestamp("rank_hidden_at", { withTimezone: true, mode: "string" }),
},
(table) => [
unique("user_username_key").on(table.username),
+2
View File
@@ -10,6 +10,7 @@ import { judgeServerRoutes } from "./routes/judge-server"
import { contestRoutes } from "./routes/contest"
import { contentRoutes } from "./routes/content"
import { classroomRoutes } from "./routes/classroom"
import { rankingRoutes } from "./routes/ranking"
import { problemsetRoutes } from "./routes/problemset"
import { achievementRoutes } from "./routes/achievement"
import { aiRoutes } from "./routes/ai"
@@ -36,6 +37,7 @@ app.route("/api", siteRoutes)
app.route("/api", contestRoutes)
app.route("/api", contentRoutes)
app.route("/api", classroomRoutes)
app.route("/api", rankingRoutes)
app.route("/api", problemsetRoutes)
app.route("/api", achievementRoutes)
app.route("/api", aiRoutes)
+7 -197
View File
@@ -5,25 +5,18 @@ import {
registerRequestSchema,
STUDENT_ROLES,
updateProfileRequestSchema,
type ActivityRankItem,
type KnowledgeLevel,
type KnowledgeMap,
type Metrics,
type MyRank,
type ProblemRank,
type RankProfile,
type UserRank,
type WeeklyRank,
type WeeklyRankItem,
} from "@oj2/contract"
import {
and,
asc,
count,
countDistinct,
desc,
eq,
gt,
gte,
inArray,
isNull,
@@ -31,7 +24,6 @@ import {
lte,
max,
min,
ne,
notExists,
or,
sql,
@@ -40,7 +32,6 @@ import { alias } from "drizzle-orm/pg-core"
import { Hono } from "hono"
import { hashPassword } from "../auth/password"
import { onlineUserIds } from "../auth/presence"
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import { config } from "../config"
import { db, schema } from "../db"
@@ -51,7 +42,7 @@ import { sniffImageExtension } from "../services/image"
import { getUserProfileById } from "../services/profile"
import { clientIp, countAttempt, lockoutRemaining, type AttemptRule } from "../services/throttling"
import { localTime, weekStart } from "../time"
import { isTeacherOrAbove, asRecord, queryInteger, sampleUser } from "./helpers"
import { asRecord, queryInteger, sampleUser } from "./helpers"
export const accountRoutes = new Hono<AppEnv>()
@@ -134,7 +125,7 @@ accountRoutes.post("/users", async (c) => {
accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
// 对齐旧后端 account/views/oj.py 的 UserProfileAPI.get 首行:
// `if not user.is_authenticated: return self.success()` —— 匿名一律返回空,
// 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
// 否则用户名可经排名接口(/rankings/board)公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
if (!c.get("user")) return success(c, null)
const [target] = await db
.select({ id: schema.user.id })
@@ -209,200 +200,17 @@ accountRoutes.get("/users/:id/metrics", async (c) => {
})
/**
* 全服榜单的大小。**写死在服务端,不接受调用方传** —— 上限是这个端点的属性,
* 不是调用方的选择。
*
* 之前它是个 `top` 查询参数,三个调用方各传各的(100 / 10 / 0),
* 而 top 又会覆盖 limit 与 offset,total 却按全量人数算 —— 于是分页器算出几十页、
* 页页内容相同(36e4ac2)。「全服 Top10」不需要另一个上限,取 limit=10&offset=0 即可;
* 后台那个「不限量」的用法搬去了 /admin/rankings/users。
*/
const LEADERBOARD_SIZE = 100
/** 入榜人群:正常状态的学生与学生管理员。教师和超管不参与排名。 */
const leaderboardWhere = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
)
/**
* 榜单排序:AC 多的在前 → 同 AC 时提交少的在前 → 再同就按 id。
*
* 第三档不是凑数:前两个键完全相同的学生在真实数据里成片存在(都是 0/0),
* 没有稳定的兜底键时 postgres 每次返回的顺序可以不同,翻页会看到重复或漏掉的人。
*/
const leaderboardOrder = [
desc(schema.userProfile.acceptedNumber),
asc(schema.userProfile.submissionNumber),
asc(schema.user.id),
]
accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, {
min: 1,
max: LEADERBOARD_SIZE,
})
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
// 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset,**不取决于总人数** ——
// 真人不够时数据库自己会少返回。不拿 total 当上限,三段查询就能并发发出去,
// 端点延迟从「四个来回相加」变成「最慢的那个」。越界页一条不剩,直接不发 SQL。
const pageLimit = Math.max(0, Math.min(limit, LEADERBOARD_SIZE - offset))
// 谁在线只给老师看,学生那边整列都是 null(见 rankProfileSchema.isOnline)
const [totalRow, rows, me, online] = await Promise.all([
db
.select({ value: count() })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(leaderboardWhere)
.then(([row]) => row),
pageLimit === 0
? []
: db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(leaderboardWhere)
.orderBy(...leaderboardOrder)
.limit(pageLimit)
.offset(offset),
myLeaderboardRank(c.get("user")?.id),
isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null,
])
return success(c, {
results: rows.map((row) => serializeRankRow(row, online)),
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
me,
} satisfies UserRank)
})
function serializeRankRow(
{
profile,
user,
}: {
profile: typeof schema.userProfile.$inferSelect
user: typeof schema.user.$inferSelect
},
online: Set<number> | null = null,
) {
return {
id: profile.id,
user: sampleUser(user, profile.realName),
acceptedNumber: profile.acceptedNumber,
submissionNumber: profile.submissionNumber,
mood: profile.mood,
isOnline: online ? online.has(user.id) : null,
} satisfies RankProfile
}
/**
* 「我」的全服名次,登录且身份入榜时才有。
*
* 名次 = 排在我前面的人数 + 1,三个排序键**逐级**比较,与列表的 orderBy 逐字对应 ——
* 少比一级就会出现「显示第 7 名、实际排在表格第 9 行」这种对不上的情况。
* 三个键全等才算并列,此时名次相同。
*/
async function myLeaderboardRank(userId: number | undefined) {
if (!userId) return null
const [mine] = await db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(and(leaderboardWhere, eq(schema.user.id, userId)))
.limit(1)
if (!mine) return null
const { acceptedNumber, submissionNumber } = mine.profile
const [ahead] = await db
.select({ value: count() })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
leaderboardWhere,
or(
gt(schema.userProfile.acceptedNumber, acceptedNumber),
and(
eq(schema.userProfile.acceptedNumber, acceptedNumber),
lt(schema.userProfile.submissionNumber, submissionNumber),
),
and(
eq(schema.userProfile.acceptedNumber, acceptedNumber),
eq(schema.userProfile.submissionNumber, submissionNumber),
lt(schema.user.id, userId),
),
),
),
)
return {
...serializeRankRow(mine),
rank: (ahead?.value ?? 0) + 1,
} satisfies MyRank
}
accountRoutes.get("/rankings/activity", async (c) => {
const start = c.req.query("start")
if (!start || Number.isNaN(Date.parse(start)))
return failure(c, 400, "invalid-start", "start time is required")
/**
* 按 **user_id** 聚合,名字从 user 表取。按 `submission.username` 分组的话,
* 改过名的学生会裂成新旧两条各算各的 AC 题数 —— 排名被拆低,运气不好还会以
* 两个名字同时挂在榜上。同 `/submissions/statistics` 那批。
*
* innerJoin user 顺带把已删号学生的孤儿提交挡在外面,不用再兜底名字。
*/
const rows = await db
.select({
username: schema.user.username,
value: countDistinct(schema.submission.problemId),
})
.from(schema.submission)
.innerJoin(schema.user, eq(schema.submission.userId, schema.user.id))
.where(
and(
isNull(schema.submission.contestId),
gte(schema.submission.createTime, start),
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
eq(schema.user.isDisabled, false),
ne(schema.user.adminType, "Super Admin"),
),
)
.groupBy(schema.submission.userId, schema.user.username)
.orderBy(desc(countDistinct(schema.submission.problemId)))
.limit(10)
return success(
c,
rows.map(
(row) =>
({
username: row.username,
count: row.value,
}) satisfies ActivityRankItem,
),
)
})
/**
* 周榜的榜面大小。**存量榜(`/rankings/users`)解决的是「谁最强」,周榜解决的是
* 「这一周谁在往前走」** —— 后者每周一清零,所以榜面短一点更像「这周的头名」,
* 首页周榜的榜面大小。周榜解决的是「这一周谁在往前走」—— 每周一清零,所以榜面短一点更像「这周的头名」,
* 长了反而又变成一张追不上的总表。榜外的人靠 `me` 单独看到自己的名次。
*/
const WEEKLY_BOARD_SIZE = 10
/** 算「解决」的两个状态:AST_CHECK_FAILED 也是答案对了,与 /rankings/activity 同口径 */
/** 算「解决」的两个状态:AST_CHECK_FAILED 也是答案对了,与排名页(routes/ranking.ts)同口径 */
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/**
* 本周进步榜:按**本周首次 AC 的题目数**排名,每周一 0:00(东八区)清零。
*
* 和 `/rankings/users` 的区别不只是加了时间窗:那张榜排的是 `user_profile` 的存量
* AC 总数,名次几乎不动,中位学生看一眼就知道追不上,等于负反馈。这张榜的分母是
* 「这一周」,谁都可能进前十。
*
* 「首次 AC」是靠 NOT EXISTS 排掉本周之前已经通过过的 (user, problem) 对,不是简单
* 数本周 AC 的去重题数 —— 后者把老题重交一遍也算成绩,一分钟能刷满一屏。
* 相关子查询的四个条件正好是 `submission_public_metrics_idx`
@@ -417,10 +225,12 @@ accountRoutes.get("/rankings/weekly", optionalAuth, async (c) => {
const start = weekStart()
// 入榜人群与全服榜一致(leaderboardWhere):正常状态的学生与学生管理员
// 入榜人群与排名页一致(routes/ranking.ts 的 rankedStudents):正常状态的学生与学生管理员,
// 老师设成不计入排名的不算
const audience = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
isNull(schema.user.rankHiddenAt),
className ? eq(schema.user.className, className) : undefined,
)
const thisWeek = and(
+1 -62
View File
@@ -10,8 +10,6 @@ import {
type ClassActivityProblem,
type ClassComparison,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
type LastVisit,
type LessonLanguage,
FLOWCHART_PASS_GRADES,
@@ -39,7 +37,7 @@ import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { accepted } from "../services/learning-stats"
import { calendarDay, dayStart, localTime } from "../time"
import { queryInteger, rounded } from "./helpers"
import { rounded } from "./helpers"
export const classroomRoutes = new Hono<AppEnv>()
@@ -108,65 +106,6 @@ function sampleStdDev(values: number[]) {
)
}
classroomRoutes.get("/rankings/classes", async (c) => {
const grade = c.req.query("grade")?.trim()
if (!grade || !/^\d+$/.test(grade)) return failure(c, 400, "invalid-grade", "grade is required")
const users = await loadClassUsers(undefined, grade)
const groups = new Map<string, ClassUser[]>()
for (const user of users)
groups.set(user.className, [...(groups.get(user.className) ?? []), user])
const result = [...groups]
.map(([className, members]) => {
const totalAc = members.reduce((sum, member) => sum + member.acceptedNumber, 0)
const totalSubmission = members.reduce((sum, member) => sum + member.submissionNumber, 0)
return {
className,
userCount: members.length,
totalAc,
totalSubmission,
avgAc: rounded(totalAc / members.length),
acRate: totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0,
}
})
.sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission)
return success(
c,
result.map((item, index) => ({ ...item, rank: index + 1 }) satisfies ClassRankItem),
)
})
classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
const user = c.get("user")!
if (!user.className) return failure(c, 400, "class-missing", "用户没有班级信息")
const members = (await loadClassUsers([user.className])).sort(
(a, b) => b.acceptedNumber - a.acceptedNumber || a.submissionNumber - b.submissionNumber,
)
const ranks = members.map((member, index) => ({
userId: member.userId,
username: member.username,
acceptedNumber: member.acceptedNumber,
submissionNumber: member.submissionNumber,
rank: index + 1,
}))
const myRank = ranks.find((rank) => rank.userId === user.id)?.rank ?? -1
const showAll = c.req.query("scope") === "all"
let selected = ranks
if (showAll) {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
selected = ranks.slice(offset, offset + limit)
} else if (myRank > 0 && ranks.length > 10) {
const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10)
selected = ranks.slice(start, start + 10)
}
return success(c, {
className: user.className,
myRank,
total: ranks.length,
ranks: selected,
} satisfies ClassUserRank)
})
/**
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
+461
View File
@@ -0,0 +1,461 @@
import {
rankHiddenRequestSchema,
rankPeriodSchema,
rankScopeSchema,
STUDENT_ROLES,
type ClassBattleItem,
type RankBoard,
type RankPeriod,
type RankRow,
type RankScope,
type SampleUser,
type WeeklyChampion,
} from "@oj2/contract"
import {
and,
countDistinct,
desc,
eq,
gte,
inArray,
isNotNull,
isNull,
like,
min,
ne,
type SQL,
} from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { dayStart, termStart, weekStart } from "../time"
import { isTeacherOrAbove, rounded, sampleUser } from "./helpers"
/**
* 排名页(设计稿「排名重设计」G1–G3)。口径见契约 `ranking.ts` 的注释:
* 做对多的在前,一样多的先做到的在前;「做对」= 这道题第一次做对落在这段时间里。
*/
export const rankingRoutes = new Hono<AppEnv>()
/** AST_CHECK_FAILED 也是答案对了,与周榜、课堂条同口径 */
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 本年级 / 全服的榜面:前面这么多人 + 我附近一段,中间折起来 */
const BOARD_HEAD = 16
/** 我附近一段:前面 5 个、后面 7 个(前面的人是要追的,多给后面几个看「谁在追我」) */
const WINDOW_BEFORE = 5
const WINDOW_AFTER = 7
/** 没有「我」(老师、没上榜)时榜面给多少 */
const BOARD_HEAD_NO_ME = 30
/** 走势图往回看几周 */
const TREND_WEEKS = 4
/** 每周冠军列几周 */
const CHAMPION_WEEKS = 3
const WEEK_MS = 7 * 86_400_000
/** 入榜人群:正常状态的学生与学生管理员,老师设成不计入排名的不算 */
const rankedStudents = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
isNull(schema.user.rankHiddenAt),
)!
interface Entrant {
id: number
username: string
className: string | null
avatar: string | null
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
times: number[]
stamps: string[]
}
interface Standing {
entrant: Entrant
rank: number
solved: number
reachedAt: string | null
}
function audienceWhere(scope: RankScope, className: string | null) {
if (scope === "class") return and(rankedStudents, eq(schema.user.className, className!))!
if (scope === "grade") return and(rankedStudents, like(schema.user.className, `${className}%`))!
return rankedStudents
}
function avatarOf(avatar: string | null) {
return !avatar || avatar.endsWith("/default.png") ? null : avatar
}
/**
* 这群人、从 `since` 起每道题第一次做对的时刻。
*
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
* 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE
* contest_id IS NULL),全服全部历史实测 24ms。
*/
async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> {
const firstSolved = min(schema.submission.createTime)
const [people, solves] = await Promise.all([
db
.select({
id: schema.user.id,
username: schema.user.username,
className: schema.user.className,
avatar: schema.userProfile.avatar,
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where),
db
.select({ userId: schema.submission.userId, at: firstSolved })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
where,
isNull(schema.submission.contestId),
inArray(schema.submission.result, SOLVED_RESULTS),
),
)
.groupBy(schema.submission.userId, schema.submission.problemId)
.having(since ? gte(firstSolved, since) : undefined),
])
const byUser = new Map<number, string[]>()
for (const row of solves) {
if (!row.at) continue
const list = byUser.get(row.userId)
if (list) list.push(row.at)
else byUser.set(row.userId, [row.at])
}
return people.map((person) => {
const pairs = (byUser.get(person.id) ?? [])
.map((stamp) => [Date.parse(stamp), stamp] as const)
.sort((a, b) => a[0] - b[0])
return {
id: person.id,
username: person.username,
className: person.className,
avatar: avatarOf(person.avatar),
times: pairs.map(([time]) => time),
stamps: pairs.map(([, stamp]) => stamp),
}
})
}
/**
* `cutoff` 那一刻的名次(不给就是现在)。一样多的先做到的在前,再一样按 id ——
* 第三档不是凑数:同一秒做到的确实有(一节课的最后一道),没有稳定的兜底键名次会跳。
* `includeZero`:本班把一道没做对的也排上(全班名单),本年级 / 全服不排。
*/
function standings(entrants: Entrant[], cutoff: number | null, includeZero: boolean) {
const list = entrants
.map((entrant) => {
let solved = entrant.times.length
if (cutoff !== null) {
solved = 0
while (solved < entrant.times.length && entrant.times[solved]! < cutoff) solved++
}
return {
entrant,
solved,
reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY,
reachedAt: solved ? entrant.stamps[solved - 1]! : null,
}
})
.filter((row) => includeZero || row.solved > 0)
.sort((a, b) => b.solved - a.solved || a.reached - b.reached || a.entrant.id - b.entrant.id)
return list.map((row, index): Standing => ({
entrant: row.entrant,
rank: index + 1,
solved: row.solved,
reachedAt: row.reachedAt,
}))
}
function toRow(standing: Standing, change: number | null): RankRow {
const { entrant } = standing
return {
rank: standing.rank,
user: sampleUser(entrant, null),
avatar: entrant.avatar,
className: entrant.className,
solved: standing.solved,
reachedAt: standing.reachedAt,
change,
} satisfies RankRow
}
function periodStart(period: RankPeriod) {
if (period === "week") return weekStart()
if (period === "term") return termStart()
return null
}
/**
* 老师打开排名页默认看哪个班:自己最近布置过作业的班 → 最近一周交题人数最多的班。
* 不记老师上次选的:统计面板吃过那个亏(上一节课的班悄悄留在框里)。
*/
async function defaultClassFor(teacherId: number) {
const [lesson] = await db
.select({ className: schema.classLesson.className })
.from(schema.classLesson)
.where(eq(schema.classLesson.createdBy, teacherId))
.orderBy(desc(schema.classLesson.day), desc(schema.classLesson.updatedAt))
.limit(1)
if (lesson) return lesson.className
const users = countDistinct(schema.submission.userId)
const [busiest] = await db
.select({ className: schema.user.className, users })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
rankedStudents,
isNotNull(schema.user.className),
ne(schema.user.className, ""),
gte(schema.submission.createTime, new Date(Date.now() - WEEK_MS).toISOString()),
),
)
.groupBy(schema.user.className)
.orderBy(desc(users))
.limit(1)
if (busiest?.className) return busiest.className
// 放假回来第一次打开:一周里没人交过题,就看最后一个交题的学生是哪个班
const [latest] = await db
.select({ className: schema.user.className })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(and(rankedStudents, isNotNull(schema.user.className), ne(schema.user.className, "")))
.orderBy(desc(schema.submission.createTime))
.limit(1)
return latest?.className ?? null
}
/**
* 这次请求看的是哪个班 / 哪个年级。学生只能看自己的班和年级;老师用 `className` 选,
* 不给就猜(`defaultClassFor`)。返回 undefined 表示该报「没有班级」。
*/
async function resolveGroup(
user: AppEnv["Variables"]["user"],
scope: RankScope,
requested: string | undefined,
): Promise<string | null | undefined> {
if (scope === "all") return null
let className = user?.className || null
if (isTeacherOrAbove(user)) className = requested || (await defaultClassFor(user!.id))
if (!className) return undefined
return scope === "class" ? className : className.slice(0, 2)
}
rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
const user = c.get("user")
const scope = rankScopeSchema.safeParse(c.req.query("scope"))
const period = rankPeriodSchema.safeParse(c.req.query("period"))
if (!scope.success || !period.success)
return failure(c, 400, "invalid-query", "排名范围或时间段不对")
const group = await resolveGroup(user, scope.data, c.req.query("className")?.trim())
if (group === undefined) return failure(c, 400, "class-missing", "没有班级,看不了本班和本年级")
if (scope.data === "grade" && !/^\d{2}$/.test(group!))
return failure(c, 400, "invalid-grade", "年级不对")
const start = periodStart(period.data)
const changeSince = period.data === "week" ? dayStart() : weekStart()
const includeZero = scope.data === "class"
const isTeacher = isTeacherOrAbove(user)
const [entrants, mine, hiddenUsers] = await Promise.all([
loadEntrants(audienceWhere(scope.data, group), start),
user && !isTeacher
? db
.select({ rankHiddenAt: schema.user.rankHiddenAt })
.from(schema.user)
.where(eq(schema.user.id, user.id))
.then(([row]) => row)
: undefined,
isTeacher && scope.data === "class"
? db
.select({ id: schema.user.id, username: schema.user.username })
.from(schema.user)
.where(
and(
eq(schema.user.className, group!),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
isNotNull(schema.user.rankHiddenAt),
),
)
.orderBy(schema.user.username)
: [],
])
const now = standings(entrants, null, includeZero)
const before = new Map(
standings(entrants, Date.parse(changeSince), includeZero)
.filter((row) => row.solved > 0)
.map((row) => [row.entrant.id, row.rank]),
)
const rowOf = (standing: Standing | undefined) =>
standing
? toRow(
standing,
before.has(standing.entrant.id) ? before.get(standing.entrant.id)! - standing.rank : null,
)
: null
// 一道没做对就不算上榜(本班的全班名单里也列着他,但没有名次可说,也没有对手)
const meIndex = user ? now.findIndex((row) => row.entrant.id === user.id && row.solved > 0) : -1
const me = meIndex >= 0 ? now[meIndex] : undefined
const full = includeZero || c.req.query("full") === "1"
let picked = now
if (!full) {
const head = me ? BOARD_HEAD : BOARD_HEAD_NO_ME
picked = now.filter(
(_, index) =>
index < head || (me && index >= meIndex - WINDOW_BEFORE && index <= meIndex + WINDOW_AFTER),
)
}
let trend: RankBoard["trend"] = null
if (me && period.data !== "week") {
const thisWeek = Date.parse(weekStart())
const points = Array.from(
{ length: TREND_WEEKS },
(_, k) => thisWeek - (TREND_WEEKS - 1 - k) * WEEK_MS,
).filter((point) => !start || point > Date.parse(start))
const tracked = [now[meIndex - 1], me, now[meIndex + 1]].filter((row): row is Standing => !!row)
const snapshots = points.map((point) => {
const ranks = new Map(
standings(entrants, point, includeZero)
.filter((row) => row.solved > 0)
.map((row) => [row.entrant.id, row.rank]),
)
return ranks
})
trend = {
points: [...points.map((point) => new Date(point).toISOString()), new Date().toISOString()],
series: tracked.map((row) => ({
userId: row.entrant.id,
ranks: [...snapshots.map((ranks) => ranks.get(row.entrant.id) ?? null), row.rank],
})),
}
}
return success(c, {
scope: scope.data,
period: period.data,
className: group,
start,
changeSince,
total: now.length,
rows: picked.map((row) => rowOf(row)!),
complete: picked.length === now.length,
me: rowOf(me),
ahead: rowOf(meIndex > 0 ? now[meIndex - 1] : undefined),
behind: rowOf(meIndex >= 0 ? now[meIndex + 1] : undefined),
trend,
hidden: !!mine?.rankHiddenAt,
hiddenUsers: hiddenUsers.map((row): SampleUser => sampleUser(row, null)),
} satisfies RankBoard)
})
/**
* 班级对抗:全服每个班这学期人均做对几道,外加这周人均涨了多少。人均而不是总数 ——
* 班级人数从 11 到 58 都有,比总数等于比人多。这学期一道没做对的班不列(还没开始用)。
* 不计入排名的人分子分母都不算:抄来的题不该替全班加分。
*/
rankingRoutes.get("/rankings/classes", async (c) => {
const where = and(
rankedStudents,
isNotNull(schema.user.className),
ne(schema.user.className, ""),
)!
const term = termStart()
const week = Date.parse(weekStart())
const entrants = await loadEntrants(where, term)
const classes = new Map<string, { members: number; term: number; week: number }>()
for (const entrant of entrants) {
const entry = classes.get(entrant.className!) ?? { members: 0, term: 0, week: 0 }
entry.members++
entry.term += entrant.times.length
entry.week += entrant.times.filter((time) => time >= week).length
classes.set(entrant.className!, entry)
}
const result = [...classes]
.filter(([, entry]) => entry.term > 0)
.map(([className, entry]) => ({
className,
members: entry.members,
perCapita: rounded(entry.term / entry.members, 1),
weekGain: rounded(entry.week / entry.members, 1),
}))
.sort((a, b) => b.perCapita - a.perCapita || b.weekGain - a.weekGain)
return success(
c,
result.map((item, index) => ({ ...item, rank: index + 1 }) satisfies ClassBattleItem),
)
})
/** 本班每周冠军:最近几个已经结束的周,每周新做对最多的那个人(一样多先做到的) */
rankingRoutes.get("/rankings/champions", optionalAuth, async (c) => {
const className = await resolveGroup(c.get("user"), "class", c.req.query("className")?.trim())
if (!className) return failure(c, 400, "class-missing", "没有班级")
const thisWeek = Date.parse(weekStart())
const since = new Date(thisWeek - CHAMPION_WEEKS * WEEK_MS).toISOString()
const entrants = await loadEntrants(audienceWhere("class", className), since)
const champions: WeeklyChampion[] = []
for (let k = 1; k <= CHAMPION_WEEKS; k++) {
const from = thisWeek - k * WEEK_MS
const inWeek = entrants.map((entrant) => {
const keep = entrant.times
.map((time, index) => [time, entrant.stamps[index]!] as const)
.filter(([time]) => time >= from && time < from + WEEK_MS)
return {
...entrant,
times: keep.map(([time]) => time),
stamps: keep.map(([, stamp]) => stamp),
}
})
const [top] = standings(inWeek, null, false)
if (!top) continue
champions.push({
weekStart: new Date(from).toISOString(),
user: sampleUser(top.entrant, null),
avatar: top.entrant.avatar,
solved: top.solved,
} satisfies WeeklyChampion)
}
return success(c, champions)
})
/**
* 老师把一个学生设成不计入排名 / 恢复。怀疑抄代码时用(用户 2026-10-10 定的)。
* 所有排名页的榜和首页周榜都不出现他;他自己看得到「不计入排名」;比赛排名不受影响。
*/
rankingRoutes.put("/rankings/hidden/:userId", requireTeacher, async (c) => {
const userId = Number(c.req.param("userId"))
if (!Number.isInteger(userId)) return failure(c, 400, "invalid-user", "用户不对")
const body = await parseBody(c, rankHiddenRequestSchema)
if (!body.success) return body.response
const updated = await db
.update(schema.user)
.set({ rankHiddenAt: body.data.hidden ? new Date().toISOString() : null })
.where(and(eq(schema.user.id, userId), inArray(schema.user.adminType, [...STUDENT_ROLES])))
.returning({ id: schema.user.id })
if (!updated.length) return failure(c, 404, "user-not-found", "没有这个学生")
return success(c, null)
})
+1 -10
View File
@@ -1,9 +1,8 @@
import type { OnlineCount, Quote, WebsiteConfig } from "@oj2/contract"
import type { Quote, WebsiteConfig } from "@oj2/contract"
import { asc, desc, eq } from "drizzle-orm"
import { Hono } from "hono"
import { resolve } from "node:path"
import { onlineCount } from "../auth/presence"
import { config } from "../config"
import { db, schema } from "../db"
import { failure, success } from "../http"
@@ -26,14 +25,6 @@ siteRoutes.get("/site", async (c) => {
} satisfies WebsiteConfig)
})
/**
* 当前在线人数。匿名可读 —— 一个聚合数字不暴露任何人的身份,
* 而榜单页本身就允许匿名看。谁在线是另一回事,只在 /rankings/users 里对老师下发。
*/
siteRoutes.get("/site/online", async (c) => {
return success(c, { count: await onlineCount() } satisfies OnlineCount)
})
// 数据集读不到时的兜底(本机旁边没有 sentences-bundle 就会走这里)
const fallbackQuotes = [
{
+12
View File
@@ -83,6 +83,18 @@ export function weekStart(now: Date | number | string = new Date()): string {
return new Date((today - (weekday - 1)) * DAY_MS - OFFSET_MS).toISOString()
}
/**
* 「这学期」从哪天零点(东八区)算起:秋季学期 9 月 1 日起(到次年 1 月都算),春季学期
* 2 月 1 日起(7、8 月的暑假也归春季,开学前没人做题,不影响)。返回 ISO 字符串。
*/
export function termStart(now: Date | number | string = new Date()): string {
const wall = toWallClock(now)
const year = wall.getUTCFullYear()
const month = wall.getUTCMonth() + 1
const [termYear, termMonth] = month >= 9 ? [year, 9] : month >= 2 ? [year, 2] : [year - 1, 9]
return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString()
}
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
export function shiftMonthsByCalendar(instant: Date, months: number): Date {
const wall = toWallClock(instant)
+34 -27
View File
@@ -6,7 +6,6 @@ import {
type ContestList,
type ContestClassView,
type ContestScoreboard,
type ActivityRankItem,
type FormatCodeResponse,
type Metrics,
type TutorialSummary,
@@ -16,9 +15,11 @@ import {
type ClassLesson,
type KnowledgeMap,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
type UserRank,
type ClassBattleItem,
type RankBoard,
type RankPeriod,
type RankScope,
type WeeklyChampion,
type WeeklyRank,
type ProblemRank,
type CreateSubmissionResponse,
@@ -64,7 +65,6 @@ import type {
Submission,
SubmissionListPayload,
SubmitCodePayload,
OnlineCount,
Tutorial,
TutorialProgress,
} from "utils/types"
@@ -77,11 +77,6 @@ import type {
*/
const loadDetailSchemas = () => import("./detailSchemas")
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
export function getOnlineCount() {
return api.get<OnlineCount>("site/online")
}
export async function getProblemList(
offset = 0,
limit = 10,
@@ -227,19 +222,41 @@ export function getSubmissionStatisticsGrid(
}
/**
* 全服榜单。上限(100 名)由服务端定,调用方只管翻页 ——
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
* 排名页的榜:本班 / 本年级 / 全服 × 这周 / 这学期 / 全部。学生看自己的班和年级,
* 老师用 `className` 选班(不给就是最近上课的班)。`full` 把本年级 / 全服中间折起来的那段也要回来
*/
export function getRank(offset: number, limit: number) {
return api.get<UserRank>("rankings/users", { params: { offset, limit } })
export function getRankBoard(
scope: RankScope,
period: RankPeriod,
options: { className?: string; full?: boolean } = {},
) {
return api.get<RankBoard>("rankings/board", {
params: {
scope,
period,
className: options.className || undefined,
full: options.full ? 1 : undefined,
},
})
}
export function getActivityRank(start: string) {
return api.get<ActivityRankItem[]>("rankings/activity", {
params: { start },
/** 班级对抗:全服各班这学期人均做对,外加这周人均涨了多少 */
export function getClassBattle() {
return api.get<ClassBattleItem[]>("rankings/classes")
}
/** 本班最近几周的每周冠军 */
export function getWeeklyChampions(className?: string) {
return api.get<WeeklyChampion[]>("rankings/champions", {
params: { className: className || undefined },
})
}
/** 老师把学生设成不计入排名 / 恢复 */
export function setRankHidden(userId: number, hidden: boolean) {
return api.put<null>(`rankings/hidden/${userId}`, { hidden })
}
/**
* 本周进步榜。`scope` 只有两个取值,服务端认不出的一律当 global ——
* 班级榜要求调用者有班级,教师/超管拿到的是 400,所以别在没班级时切过去。
@@ -281,16 +298,6 @@ export function getLastVisit() {
return api.get<LastVisit>("me/last-visit")
}
export function getClassRank(grade?: number | null) {
return api.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
}
export function getUserClassRank(scope?: "all" | "window", offset?: number, limit?: number) {
return api.get<ClassUserRank>("me/class-rank", {
params: { scope, offset, limit },
})
}
export function getClassPK(classNames: string[], startTime?: string, endTime?: string) {
return api.post<ClassComparisonResponse>("classes/comparison", {
classNames,
+1 -1
View File
@@ -60,7 +60,7 @@ const aiLoading = aiStream.waiting
const aiContent = ref("")
const showAIModal = ref(false)
// 长时段和榜单页同一份(LONG_DURATION_OPTIONS),外加一个「全部时间」
// 长时段选项(LONG_DURATION_OPTIONS),外加一个「全部时间」
const timeRangeOptions: SelectOption[] = [
{ label: "全部时间", value: "" },
...LONG_DURATION_OPTIONS,
-106
View File
@@ -1,106 +0,0 @@
<script setup lang="ts">
import { Bar } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
Colors,
} from "chart.js"
import { ChartType } from "utils/constants"
import type { Rank } from "utils/types"
// 仅注册柱状图所需的 Chart.js 组件
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, Colors)
const props = defineProps<{ rankData: Rank[]; type: ChartType }>()
const data = computed(() => {
const labels = props.rankData.map((rank) => rank.user.username)
const datasets: any[] = [
{
label: props.type === ChartType.Rank ? "已解决" : "做题数",
data: props.rankData.map((rank) => rank.acceptedNumber),
backgroundColor: [
"rgba(255, 99, 132, 0.2)",
"rgba(255, 159, 64, 0.2)",
"rgba(55, 66, 250, 0.2)",
"rgba(75, 192, 192, 0.2)",
"rgba(54, 162, 235, 0.2)",
"rgba(153, 102, 255, 0.2)",
"rgba(48, 51, 107, 0.2)",
"rgba(249, 202, 36, 0.2)",
"rgba(106, 176, 76, 0.2)",
"rgba(119, 139, 235, 0.2)",
],
borderColor: [
"rgba(255, 99, 132, 0.6)",
"rgba(255, 159, 64, 0.6)",
"rgba(55, 66, 250, 0.6)",
"rgba(75, 192, 192, 0.6)",
"rgba(54, 162, 235, 0.6)",
"rgba(153, 102, 255, 0.6)",
"rgba(48, 51, 107, 0.6)",
"rgba(249, 202, 36, 0.6)",
"rgba(106, 176, 76, 0.6)",
"rgba(119, 139, 235, 0.6)",
],
hoverBackgroundColor: [
"rgba(255, 99, 132, 0.8)",
"rgba(255, 159, 64, 0.8)",
"rgba(55, 66, 250, 0.8)",
"rgba(75, 192, 192, 0.8)",
"rgba(54, 162, 235, 0.8)",
"rgba(153, 102, 255, 0.8)",
"rgba(48, 51, 107, 0.8)",
"rgba(249, 202, 36, 0.8)",
"rgba(106, 176, 76, 0.8)",
"rgba(119, 139, 235, 0.8)",
],
hoverBorderColor: [
"rgba(255, 99, 132, 1)",
"rgba(255, 159, 64, 1)",
"rgba(55, 66, 250, 1)",
"rgba(75, 192, 192, 1)",
"rgba(54, 162, 235, 1)",
"rgba(153, 102, 255, 1)",
"rgba(48, 51, 107, 1)",
"rgba(249, 202, 36, 1)",
"rgba(106, 176, 76, 1)",
"rgba(119, 139, 235, 1)",
],
borderWidth: 2,
},
]
if (props.type === ChartType.Rank) {
datasets.push({
label: "总提交数",
data: props.rankData.map((rank) => rank.submissionNumber),
hidden: true,
})
}
return {
labels,
datasets,
}
})
const options = {
maintainAspectRatio: false,
}
</script>
<template>
<div class="chart">
<Bar :data="data" :options="options" />
</div>
</template>
<style scoped>
.chart {
height: 500px;
margin: 20px 0;
}
</style>
@@ -0,0 +1,164 @@
<script setup lang="ts">
import type { ClassBattleItem } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import { useRankPalette } from "../palette"
/**
* 班级对抗:全服各班这学期人均做对,绿字是这周人均涨了多少。按人均比,因为班级人数从
* 十来个到五十几个都有。列前 7 个班,自己的班不在里面就补在最后。
*/
const props = defineProps<{ items: ClassBattleItem[]; mine: string | null; teacher?: boolean }>()
const theme = useThemeVars()
const palette = useRankPalette()
const LIMIT = 7
const shown = computed(() => {
const head = props.items.slice(0, LIMIT)
const own = props.items.find((item) => item.className === props.mine)
return own && own.rank > LIMIT ? [...head, own] : head
})
const max = computed(() => Math.max(1, ...props.items.map((item) => item.perCapita)))
const summary = computed(() => {
const own = props.items.find((item) => item.className === props.mine)
if (!own) return ""
const who = props.teacher ? "这个班" : "你们班"
const faster = props.items.filter((item) => item.weekGain > own.weekGain).length + 1
const speed = own.weekGain > 0 ? ` · 这周涨得第 ${faster} 快` : ""
if (own.rank === 1) return `${who}第 1${speed}`
const prev = props.items[own.rank - 2]!
const need = Math.floor(prev.perCapita - own.perCapita) + 1
return `${who}第 ${own.rank} · 每人再做对 ${need} 道就超过${shortLabel(prev.className)}${speed}`
})
/** 同年级的班只说「3 班」,别的年级说全名 */
function shortLabel(className: string) {
if (props.mine && className.slice(0, 2) === props.mine.slice(0, 2))
return ` ${className.slice(2)} 班`
return ` ${classLabel(className)}`
}
</script>
<template>
<div class="battle">
<div class="title">
<b>班级对抗</b><span class="muted">人均做对 · 全服 · 绿字是这周涨的</span>
</div>
<div
v-for="item in shown"
:key="item.className"
class="row"
:class="{ mine: item.className === mine }"
>
<span class="rank">{{ item.rank }}</span>
<span class="name">{{ classLabel(item.className) }}</span>
<span class="bar-box">
<span
class="bar"
:style="{
width: `${(item.perCapita / max) * 100}%`,
background: item.className === mine ? palette.me : palette.bar,
}"
/>
</span>
<span class="value">{{ item.perCapita }}</span>
<span class="gain">{{ item.weekGain > 0 ? `+${item.weekGain}` : "" }}</span>
</div>
<span v-if="!items.length" class="muted">这学期还没有班做对题</span>
<span v-if="summary" class="summary">{{ summary }}</span>
</div>
</template>
<style scoped>
.battle {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 1px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
}
.title {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 4px;
}
.muted {
font-size: 12px;
color: v-bind("theme.textColor3");
}
.row {
height: 22px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.row.mine {
margin: 0 -8px;
padding: 0 8px;
border-radius: 4px;
background: rgba(24, 160, 88, 0.12);
}
.rank {
width: 14px;
font-size: 12px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.name {
width: 86px;
white-space: nowrap;
}
.mine .name {
font-weight: 700;
color: #18a058;
}
.bar-box {
flex-grow: 1;
height: 10px;
position: relative;
}
.bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 2px 5px 5px 2px;
}
.value {
width: 32px;
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.gain {
width: 34px;
font-size: 11px;
color: #18a058;
font-variant-numeric: tabular-nums;
}
.summary {
margin-top: 6px;
font-size: 12px;
color: #18a058;
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { useDark } from "@vueuse/core"
import { rgba } from "oj/submission/composables/tone"
import { USERNAME_CLASS_RE } from "utils/constants"
/**
* 排名页的头像。九成以上的学生没换过头像(1797 个号是 default.png),一排一模一样的
* 默认图分不出人,所以默认头像画成「名字最后一个字 + 彩色圆」,颜色按用户名固定 ——
* 同一个人在领奖台、赛道、对手卡里是同一个颜色。自己是实心绿。
*/
const props = defineProps<{
username: string
avatar: string | null
size: number
me?: boolean
}>()
const isDark = useDark()
/** 头像文件丢了(备份恢复后没拷上传目录、被删)就退回名字那个字,别露出破图 */
const broken = ref(false)
watch(
() => props.avatar,
() => (broken.value = false),
)
const PALETTE = ["#2f6fd0", "#c76a12", "#6a4bd8", "#0f7a6c", "#b4315f", "#4d5967", "#8a6a00"]
const letter = computed(() => {
const name = props.username.replace(USERNAME_CLASS_RE, "") || props.username
return Array.from(name).at(-1) ?? "?"
})
const colors = computed(() => {
if (props.me) return { background: "#18a058", color: "#ffffff" }
let hash = 0
for (const char of props.username) hash = (hash * 31 + char.codePointAt(0)!) >>> 0
const base = PALETTE[hash % PALETTE.length]!
return { background: rgba(base, isDark.value ? 0.28 : 0.13), color: base }
})
</script>
<template>
<img
v-if="avatar && !broken"
class="avatar"
:src="avatar"
alt=""
@error="broken = true"
:style="{ width: `${size}px`, height: `${size}px` }"
/>
<span
v-else
class="avatar letter"
aria-hidden="true"
:style="{
width: `${size}px`,
height: `${size}px`,
fontSize: `${Math.round(size * 0.5)}px`,
...colors,
}"
>{{ letter }}</span
>
</template>
<style scoped>
.avatar {
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
}
.letter {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
line-height: 1;
}
</style>
+273
View File
@@ -0,0 +1,273 @@
<script setup lang="ts">
import type { RankRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import UserName from "shared/components/UserName.vue"
import { gapText, toPass } from "../utils"
import RankAvatar from "./RankAvatar.vue"
export interface MiniRank {
label: string
rank: number | null
total: number | null
go: () => void
}
/**
* 「你」那张卡:大字第几名、比周一升了几名,一句最能推人一把的话(离领奖台几道 / 升了几名),
* 再是两个对手 —— 前面那个要追的、后面那个在追你的,差几秒、差几道都写出来。
*/
const props = defineProps<{
me: RankRow
ahead: RankRow | null
behind: RankRow | null
/** 领奖台第 3 名(我不在台上时用来算「再做对几道上领奖台」) */
third: RankRow | null
total: number
label: string
minis: MiniRank[]
}>()
const theme = useThemeVars()
const headline = computed(() => {
const { me, ahead, third } = props
if (me.rank === 1) return "你是第一名,后面的人在追你"
if (me.rank <= 3 && ahead)
return `你在领奖台上 · 再做对 ${toPass(me, ahead)} 道就是第 ${ahead.rank} 名`
if (third) {
const need = toPass(me, third)
if (need <= 3) return `再做对 ${need} 道,你就上领奖台`
}
if ((me.change ?? 0) >= 3) return `比周一升了 ${me.change} 名`
if (ahead) return `再做对 ${toPass(me, ahead)} 道就是第 ${ahead.rank} 名`
return ""
})
function chaseNote(me: RankRow, ahead: RankRow) {
if (ahead.solved === me.solved && ahead.reachedAt && me.reachedAt)
return `早你 ${gapText(ahead.reachedAt, me.reachedAt)}做到 ${me.solved} 道`
return `比你多 ${ahead.solved - me.solved} 道`
}
function threatNote(me: RankRow, behind: RankRow) {
if (behind.solved === me.solved && behind.reachedAt && me.reachedAt)
return `只比你晚 ${gapText(me.reachedAt, behind.reachedAt)},再做对 1 道就超过你`
if (!behind.solved) return "还没做对"
return `比你少 ${me.solved - behind.solved} 道`
}
</script>
<template>
<div class="me-card">
<div class="head">
<RankAvatar :username="me.user.username" :avatar="me.avatar" :size="46" me />
<div class="place">
<span class="label">{{ label }}</span>
<div class="big">
<span>第</span><b>{{ me.rank }}</b
><span>名</span>
<span class="total">/ {{ total }}</span>
<span v-if="(me.change ?? 0) > 0" class="up">↑{{ me.change }}</span>
<span v-else-if="(me.change ?? 0) < 0" class="down">↓{{ -me.change! }}</span>
</div>
</div>
</div>
<div v-if="headline" class="headline">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 19V5" />
<path d="M5 12l7-7 7 7" />
</svg>
<b>{{ headline }}</b>
</div>
<div v-if="ahead" class="rival">
<span class="tag chase">要追</span>
<RankAvatar :username="ahead.user.username" :avatar="ahead.avatar" :size="20" />
<UserName :username="ahead.user.username" class="rival-name" />
<span class="note">{{ chaseNote(me, ahead) }}</span>
</div>
<div v-if="behind" class="rival">
<span class="tag threat">追你</span>
<RankAvatar :username="behind.user.username" :avatar="behind.avatar" :size="20" />
<UserName :username="behind.user.username" class="rival-name" />
<span class="note">{{ threatNote(me, behind) }}</span>
</div>
<div v-if="minis.length" class="minis">
<button v-for="mini in minis" :key="mini.label" class="mini" @click="mini.go">
<span class="mini-label">{{ mini.label }}</span>
<b v-if="mini.rank"
>第 {{ mini.rank }}<small v-if="mini.total"> / {{ mini.total }}</small></b
>
<b v-else class="none">没上榜</b>
</button>
</div>
</div>
</template>
<style scoped>
.me-card {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid rgba(24, 160, 88, 0.45);
border-radius: 6px;
background: rgba(24, 160, 88, 0.07);
}
.head {
display: flex;
align-items: center;
gap: 12px;
}
.place {
display: flex;
flex-direction: column;
}
.label {
font-size: 12px;
color: v-bind("theme.textColor2");
}
.big {
display: flex;
align-items: baseline;
gap: 4px;
color: #18a058;
}
.big b {
font-size: 44px;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.total {
font-size: 13px;
color: v-bind("theme.textColor3");
margin-left: 2px;
}
.up,
.down {
margin-left: 8px;
font-size: 15px;
font-weight: 600;
}
.down {
color: v-bind("theme.errorColor");
}
.headline {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
border: 1px dashed rgba(24, 160, 88, 0.6);
background: v-bind("theme.cardColor");
color: #18a058;
font-size: 14px;
}
.rival {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
min-width: 0;
}
.rival-name {
flex-shrink: 0;
max-width: 120px;
}
.note {
font-size: 12px;
color: v-bind("theme.textColor3");
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag {
font-size: 11px;
font-weight: 600;
border-radius: 3px;
padding: 0 5px;
line-height: 16px;
white-space: nowrap;
flex-shrink: 0;
}
.tag.chase {
color: #2f6fd0;
background: rgba(47, 111, 208, 0.12);
}
.tag.threat {
color: #c76a12;
background: rgba(199, 106, 18, 0.12);
}
.minis {
display: flex;
gap: 6px;
}
.mini {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
padding: 6px 10px;
border: 0;
border-radius: 4px;
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor1");
font: inherit;
cursor: pointer;
}
.mini:hover {
color: v-bind("theme.primaryColor");
}
.mini-label {
font-size: 11px;
color: v-bind("theme.textColor3");
white-space: nowrap;
}
.mini b {
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.mini small {
font-size: 11px;
font-weight: 400;
color: v-bind("theme.textColor3");
}
.mini .none {
font-weight: 400;
color: v-bind("theme.textColor3");
}
</style>
@@ -1,31 +0,0 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
interface Props {
page: number
limit: number
index: number
}
const props = defineProps<Props>()
const index = computed(() => props.index + (props.page - 1) * props.limit + 1)
const tooltip = computed(() => {
if (index.value === 1) return "🏅 金牌"
if (index.value === 2) return "🥈 银牌"
if (index.value === 3) return "🥉 铜牌"
return ""
})
</script>
<template>
<span v-if="index > 3">{{ index }}</span>
<n-tooltip v-else>
<template #trigger>
<n-icon :size="24">
<Icon v-if="index === 1" icon="fluent-emoji:1st-place-medal"></Icon>
<Icon v-if="index === 2" icon="fluent-emoji:2nd-place-medal"></Icon>
<Icon v-if="index === 3" icon="fluent-emoji:3rd-place-medal"></Icon>
</n-icon>
</template>
{{ tooltip }}
</n-tooltip>
</template>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import type { RankRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import UserName from "shared/components/UserName.vue"
import { useRankPalette } from "../palette"
import RankAvatar from "./RankAvatar.vue"
/**
* 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。
* 你要追的人站在台上时台阶描蓝边、挂「你要追的」,和赛道里的蓝条是一回事。
*/
const props = defineProps<{
rows: RankRow[]
meId?: number
chaseId?: number
threatId?: number
}>()
defineEmits<{ open: [username: string] }>()
const theme = useThemeVars()
const palette = useRankPalette()
/** 台阶从左到右是 2、1、3 */
const steps = computed(() =>
[2, 1, 3]
.map((place) => ({ place, row: props.rows[place - 1] }))
.filter((step): step is { place: number; row: RankRow } => !!step.row),
)
const HEIGHTS: Record<number, number> = { 1: 50, 2: 38, 3: 30 }
</script>
<template>
<div class="podium">
<div v-for="{ place, row } in steps" :key="place" class="step" :class="`p${place}`">
<div class="top">
<svg
v-if="place === 1"
class="crown"
width="18"
height="18"
viewBox="0 0 24 24"
fill="#f2c94c"
stroke="#9a6700"
stroke-width="1.6"
stroke-linejoin="round"
role="img"
aria-label="第一名"
>
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
<span v-if="row.user.id === chaseId" class="tag chase">你要追的</span>
<span v-else-if="row.user.id === threatId" class="tag threat">在追你</span>
<span v-else-if="row.user.id === meId" class="tag me">你</span>
</div>
<RankAvatar
:username="row.user.username"
:avatar="row.avatar"
:size="place === 1 ? 34 : 28"
:me="row.user.id === meId"
/>
<button class="name" @click="$emit('open', row.user.username)">
<UserName :username="row.user.username" />
</button>
<div
class="block"
:style="{
height: `${HEIGHTS[place]}px`,
background: palette.medal[place - 1]!.background,
boxShadow:
row.user.id === chaseId
? `inset 0 0 0 2px ${palette.chase}`
: row.user.id === meId
? `inset 0 0 0 2px ${palette.me}`
: undefined,
}"
>
<span class="place" :style="{ color: palette.medal[place - 1]!.color }">{{ place }}</span>
<span class="solved"
><b>{{ row.solved }}</b
><small>道</small></span
>
</div>
</div>
<span v-if="!steps.length" class="empty">还没有人做对,现在做对 1 道就是第一名</span>
</div>
</template>
<style scoped>
.podium {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 6px;
margin: 0 16px;
border-bottom: 2px solid v-bind("theme.dividerColor");
min-height: 60px;
}
.step {
width: 170px;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.top {
height: 18px;
display: flex;
align-items: center;
gap: 4px;
}
.tag {
font-size: 11px;
font-weight: 600;
border-radius: 3px;
padding: 0 5px;
line-height: 16px;
white-space: nowrap;
}
.tag.chase {
color: v-bind("palette.chase");
background: rgba(47, 111, 208, 0.12);
}
.tag.threat {
color: v-bind("palette.threat");
background: rgba(199, 106, 18, 0.12);
}
.tag.me {
color: v-bind("palette.me");
background: rgba(24, 160, 88, 0.12);
}
.name {
max-width: 100%;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 13px;
cursor: pointer;
display: flex;
min-width: 0;
}
.block {
width: 100%;
box-sizing: border-box;
border-radius: 6px 6px 0 0;
display: flex;
align-items: flex-start;
justify-content: center;
gap: 10px;
padding-top: 5px;
}
.place {
font-size: 22px;
font-weight: 800;
line-height: 1;
}
.solved {
font-variant-numeric: tabular-nums;
}
.solved b {
font-size: 16px;
}
.solved small {
font-size: 11px;
color: v-bind("theme.textColor3");
margin-left: 1px;
}
.empty {
padding: 18px 0;
color: v-bind("theme.textColor3");
font-size: 13px;
}
</style>
@@ -0,0 +1,323 @@
<script setup lang="ts">
import type { RankRow } from "@oj2/contract"
import { useThemeVars, type DropdownOption } from "naive-ui"
import UserName from "shared/components/UserName.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useRankPalette } from "../palette"
import RankAvatar from "./RankAvatar.vue"
/**
* 赛道:每人一根条,条越长做对越多,一样长的先做到的排前面(设计稿 G1 / G2)。
* 桌面两栏,机房 1366×768 一屏放得下一个班;本年级 / 全服只给前面一段 + 我附近一段,
* 名次断开的地方折起来,点了再把中间那段要回来。
*/
const props = defineProps<{
rows: RankRow[]
total: number
complete: boolean
/** 条的满格是几道(第 2 名的数,第 1 名常常一骑绝尘) */
scale: number
meId?: number
chaseId?: number
threatId?: number
hotId?: number
/** 本年级 / 全服里「你们班」的班号,这些人的条浅绿 */
mateClass?: string | null
teacher?: boolean
expanding?: boolean
}>()
const emit = defineEmits<{
open: [username: string]
expand: []
submissions: [username: string]
hide: [row: RankRow]
}>()
const theme = useThemeVars()
const palette = useRankPalette()
const { isDesktop } = useBreakpoints()
type Item =
| { kind: "row"; row: RankRow }
| { kind: "gap"; from: number; to: number; tail?: boolean; mine?: boolean }
const items = computed<Item[]>(() => {
const list: Item[] = []
let last = props.rows[0] ? props.rows[0].rank - 1 : 0
for (const row of props.rows) {
if (row.rank > last + 1) list.push({ kind: "gap", from: last + 1, to: row.rank - 1 })
list.push({ kind: "row", row })
last = row.rank
}
if (!props.complete && last < props.total)
list.push({ kind: "gap", from: last + 1, to: props.total, tail: true })
return list
})
/** 两栏:前一半、后一半。手机一栏 */
const columns = computed(() => {
if (!isDesktop.value) return [items.value]
const half = Math.ceil(items.value.length / 2)
return [items.value.slice(0, half), items.value.slice(half)]
})
function barColor(row: RankRow) {
const id = row.user.id
if (id === props.meId) return palette.value.me
if (id === props.chaseId) return palette.value.chase
if (id === props.threatId) return palette.value.threat
if (props.mateClass && row.className === props.mateClass) return palette.value.mate
return palette.value.bar
}
function width(row: RankRow) {
if (!row.solved) return "0"
return `${Math.max(1.5, Math.min(100, (row.solved / Math.max(1, props.scale)) * 100))}%`
}
const menu: DropdownOption[] = [
{ label: "看他的提交", key: "submissions" },
{ label: "不计入排名…", key: "hide" },
]
function onMenu(key: string, row: RankRow) {
if (key === "submissions") emit("submissions", row.user.username)
else emit("hide", row)
}
</script>
<template>
<div class="track" :class="{ single: !isDesktop }">
<div v-for="(column, index) in columns" :key="index" class="column">
<template
v-for="item in column"
:key="item.kind === 'row' ? item.row.user.id : `gap${item.from}`"
>
<button
v-if="item.kind === 'gap'"
class="gap"
:disabled="expanding"
@click="emit('expand')"
>
第 {{ item.from }}–{{ item.to }} 名 · {{ item.to - item.from + 1 }} 人 · 展开 ▾
</button>
<div v-else class="lane" :class="{ me: item.row.user.id === meId, zero: !item.row.solved }">
<span class="rank">{{ item.row.solved ? item.row.rank : "—" }}</span>
<RankAvatar
:username="item.row.user.username"
:avatar="item.row.avatar"
:size="16"
:me="item.row.user.id === meId"
/>
<button class="who" @click="emit('open', item.row.user.username)">
<UserName :username="item.row.user.username" />
</button>
<span class="bar-box">
<span class="bar" :style="{ width: width(item.row), background: barColor(item.row) }" />
</span>
<span class="solved">{{ item.row.solved }}</span>
<span class="change">
<span v-if="(item.row.change ?? 0) > 0" class="up">↑{{ item.row.change }}</span>
<span v-else-if="(item.row.change ?? 0) < 0" class="down"
>↓{{ -item.row.change! }}</span
>
<span v-else-if="item.row.change === 0" class="flat">—</span>
</span>
<span class="tag-box">
<span v-if="item.row.user.id === chaseId" class="tag chase">要追</span>
<span v-else-if="item.row.user.id === threatId" class="tag threat">追你</span>
<span v-else-if="item.row.user.id === hotId" class="tag hot">冲得最猛</span>
</span>
<n-dropdown
v-if="teacher"
trigger="click"
:options="menu"
@select="(key: string) => onMenu(key, item.row)"
>
<button class="more" aria-label="更多">⋯</button>
</n-dropdown>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.track {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 36px;
padding: 10px 24px 14px;
}
.track.single {
grid-template-columns: 1fr;
padding: 10px 12px 14px;
}
.column {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.lane {
height: 18px;
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
}
.single .lane {
height: 26px;
}
.single .who {
width: 96px;
}
.single .tag-box {
width: auto;
}
.lane.me {
margin: 0 -8px;
padding: 0 8px;
height: 20px;
border-radius: 4px;
background: rgba(24, 160, 88, 0.12);
}
.lane.zero {
opacity: 0.55;
}
.rank {
width: 28px;
text-align: right;
font-size: 12px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.me .rank {
color: #18a058;
font-weight: 700;
}
.who {
width: 108px;
flex-shrink: 0;
padding: 0;
border: 0;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
display: flex;
min-width: 0;
}
.bar-box {
flex-grow: 1;
height: 11px;
position: relative;
min-width: 40px;
}
.bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 2px 6px 6px 2px;
}
.solved {
width: 26px;
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.change {
width: 34px;
font-size: 12px;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.up {
color: #18a058;
font-weight: 600;
}
.down {
color: v-bind("theme.errorColor");
}
.flat {
color: v-bind("theme.textColor3");
}
.tag-box {
width: 58px;
display: flex;
flex-shrink: 0;
}
.tag {
font-size: 11px;
font-weight: 600;
border-radius: 3px;
padding: 0 5px;
line-height: 16px;
white-space: nowrap;
}
.tag.chase {
color: v-bind("palette.chase");
background: rgba(47, 111, 208, 0.12);
}
.tag.threat {
color: v-bind("palette.threat");
background: rgba(199, 106, 18, 0.12);
}
.tag.hot {
color: #ffffff;
background: #c76a12;
}
.more {
width: 18px;
padding: 0;
border: 0;
background: none;
color: v-bind("theme.textColor2");
font-weight: 700;
cursor: pointer;
}
.gap {
height: 22px;
margin: 4px 0;
border: 0;
border-top: 1px dashed v-bind("theme.borderColor");
border-bottom: 1px dashed v-bind("theme.borderColor");
background: none;
font: inherit;
font-size: 12px;
color: v-bind("theme.textColor3");
cursor: pointer;
}
.gap:hover {
color: v-bind("theme.primaryColor");
}
</style>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import type { RankTrend } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { parseTime } from "utils/functions"
/**
* 我和两个对手每周日晚上的名次。名次越小越好,所以纵轴倒过来(第 1 在最上面)。
* 线的末端直接写名字,不要图例 —— 三条线,看名字比对颜色快。
*/
const props = defineProps<{
trend: RankTrend
lines: { userId: number; label: string; color: string; strong?: boolean }[]
}>()
const theme = useThemeVars()
const W = 400
const H = 190
const PAD = { left: 40, right: 64, top: 12, bottom: 26 }
const series = computed(() =>
props.lines
.map((line) => ({
...line,
ranks: props.trend.series.find((s) => s.userId === line.userId)?.ranks,
}))
.filter((line): line is typeof line & { ranks: (number | null)[] } => !!line.ranks),
)
const range = computed(() => {
const values = series.value.flatMap((line) => line.ranks.filter((v): v is number => v !== null))
const lo = Math.max(1, Math.min(...values, 1e9) - 1)
const hi = Math.max(lo + 2, Math.max(...values, 0) + 1)
return { lo, hi }
})
const ticks = computed(() => {
const { lo, hi } = range.value
const step = Math.max(1, Math.ceil((hi - lo) / 5))
const list: number[] = []
for (let v = lo; v <= hi; v += step) list.push(v)
return list
})
const n = computed(() => props.trend.points.length)
function x(i: number) {
return PAD.left + ((W - PAD.left - PAD.right) * i) / Math.max(1, n.value - 1)
}
function y(rank: number) {
const { lo, hi } = range.value
return PAD.top + ((rank - lo) / (hi - lo)) * (H - PAD.top - PAD.bottom)
}
/** 点是周一零点,说成前一天(周日晚上);最后一个点是现在 */
const labels = computed(() =>
props.trend.points.map((point, i) =>
i === n.value - 1 ? "现在" : parseTime(new Date(Date.parse(point) - 1), "M月D日"),
),
)
function path(ranks: (number | null)[]) {
let d = ""
ranks.forEach((rank, i) => {
if (rank === null) return
d += `${d && ranks[i - 1] !== null ? "L" : "M"}${x(i).toFixed(1)},${y(rank).toFixed(1)} `
})
return d
}
/** 线尾的名字按纵坐标排开,挨得太近往下错开 */
const ends = computed(() => {
const list = series.value
.map((line) => {
const last = line.ranks.at(-1)
return last == null
? null
: { y: y(last), text: `${line.label} 第${last}`, color: line.color }
})
.filter((end): end is { y: number; text: string; color: string } => !!end)
.sort((a, b) => a.y - b.y)
for (let i = 1; i < list.length; i++) {
if (list[i]!.y - list[i - 1]!.y < 14) list[i]!.y = list[i - 1]!.y + 14
}
return list
})
</script>
<template>
<svg :viewBox="`0 0 ${W} ${H}`" class="chart" role="img" aria-label="我和对手每周的名次">
<g v-for="tick in ticks" :key="tick">
<line :x1="PAD.left" :x2="W - PAD.right" :y1="y(tick)" :y2="y(tick)" class="grid" />
<text :x="PAD.left - 6" :y="y(tick) + 4" text-anchor="end" class="axis">第 {{ tick }}</text>
</g>
<text
v-for="(label, i) in labels"
:key="label"
:x="x(i)"
:y="H - 8"
text-anchor="middle"
class="axis"
>
{{ label }}
</text>
<g v-for="line in series" :key="line.userId">
<path
:d="path(line.ranks)"
fill="none"
:stroke="line.color"
:stroke-width="line.strong ? 3 : 1.8"
stroke-linejoin="round"
stroke-linecap="round"
/>
<template v-for="(rank, i) in line.ranks" :key="i">
<circle
v-if="rank !== null"
:cx="x(i)"
:cy="y(rank)"
:r="line.strong ? 3.5 : 2.5"
class="dot"
:stroke="line.color"
/>
</template>
</g>
<text
v-for="end in ends"
:key="end.text"
:x="W - PAD.right + 8"
:y="end.y + 4"
:fill="end.color"
class="end"
>
{{ end.text }}
</text>
</svg>
</template>
<style scoped>
.chart {
width: 100%;
height: auto;
display: block;
}
.grid {
stroke: v-bind("theme.dividerColor");
stroke-width: 1;
}
.axis {
font-size: 11px;
fill: v-bind("theme.textColor3");
}
.dot {
fill: v-bind("theme.cardColor");
stroke-width: 2;
}
.end {
font-size: 12px;
font-weight: 600;
}
</style>
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
import { useDark } from "@vueuse/core"
/**
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、你要追的蓝、
* 在追你的橙、本年级 / 全服里你们班的人浅绿,其余灰;前三名金银铜。
* 暗色下灰条和浅绿换深一档,不然在暗底上发白。
*/
export function useRankPalette() {
const isDark = useDark()
return computed(() => ({
me: "#18a058",
chase: "#2f6fd0",
threat: "#c76a12",
mate: isDark.value ? "#2f6b47" : "#a8dcbd",
bar: isDark.value ? "#4a525c" : "#c9d3dc",
medal: [
{
color: isDark.value ? "#e2b33b" : "#9a6700",
background: isDark.value ? "rgba(240,180,40,0.22)" : "#fbf1d9",
},
{
color: isDark.value ? "#a7b1bd" : "#5f6b7a",
background: isDark.value ? "rgba(120,135,155,0.22)" : "#eef1f4",
},
{
color: isDark.value ? "#c58b62" : "#8c5a3c",
background: isDark.value ? "rgba(170,110,70,0.22)" : "#f6ece5",
},
],
}))
}
export type RankPalette = ReturnType<typeof useRankPalette>["value"]
+63
View File
@@ -0,0 +1,63 @@
import type { RankPeriod, RankRow, RankScope } from "@oj2/contract"
import { classLabel } from "oj/submission/utils"
export const SCOPE_OPTIONS: { value: RankScope; label: string }[] = [
{ value: "class", label: "本班" },
{ value: "grade", label: "本年级" },
{ value: "all", label: "全服" },
]
export const PERIOD_OPTIONS: { value: RankPeriod; label: string }[] = [
{ value: "week", label: "这周" },
{ value: "term", label: "这学期" },
{ value: "all", label: "全部" },
]
export function scopeLabel(scope: RankScope) {
return SCOPE_OPTIONS.find((option) => option.value === scope)!.label
}
export function periodLabel(period: RankPeriod) {
return PERIOD_OPTIONS.find((option) => option.value === period)!.label
}
/** 榜的标题:本班写班名,本年级写「25 级」,全服就是「全服」 */
export function groupTitle(scope: RankScope, className: string | null) {
if (scope === "class" && className) return classLabel(className)
if (scope === "grade" && className) return `${className} 级`
return "全服"
}
/**
* 两个人做到同样多道的时间差,说成学生的话:「6 秒」「2 分 40 秒」「3 小时」「2 天」。
* 一节课的最后一道,全班常常差在几秒到几十秒 —— 这就是竞争感的来源,秒要写出来。
*/
export function gapText(earlier: string, later: string) {
const seconds = Math.max(0, Math.round((Date.parse(later) - Date.parse(earlier)) / 1000))
if (seconds === 0) return "同一秒"
if (seconds < 60) return `${seconds} 秒`
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60)
const rest = seconds % 60
return rest ? `${minutes} 分 ${rest} 秒` : `${minutes} 分钟`
}
if (seconds < 86_400) return `${Math.floor(seconds / 3600)} 小时`
return `${Math.floor(seconds / 86_400)} 天`
}
/**
* 再做对几道能超过 `target`:一样多时先做到的在前,后做到的人只能靠多做 1 道超过 ——
* 所以永远是「差几道 + 1」。
*/
export function toPass(me: RankRow, target: RankRow) {
return target.solved - me.solved + 1
}
/** 这张榜上升得最猛的人(至少升 3 名才算),给他挂「冲得最猛」 */
export function hottest(rows: RankRow[]) {
let best: RankRow | null = null
for (const row of rows) {
if ((row.change ?? 0) >= 3 && (!best || row.change! > best.change!)) best = row
}
return best
}
+3 -9
View File
@@ -254,11 +254,6 @@ export const CODE_TEMPLATES = {
SQL: blankTemplate,
} as const
export enum ChartType {
Rank,
Activity,
}
// 成就稀有度
export const RARITY_LABEL: Record<AchievementRarity, string> = {
bronze: "青铜",
@@ -329,11 +324,10 @@ export const DURATION_OPTIONS = [
] as const
/**
* 榜单和班级对比用的长时段,一周起步。
* 班级对比用的长时段,一周起步。
*
* 这两个页面看的是长期趋势,「本节课内」这种窗口在那儿没有意义 —— 全班一小时内的
* AC 数拉出来比不出什么。原来 rank/list.vue 和 class/pk.vue 各手抄了一份同样的五条,
* pk.vue 里还留着「与 rank/list.vue 保持一致」的注释,现在从上面那份派生。
* 看的是长期趋势,「本节课内」这种窗口在那儿没有意义 —— 全班一小时内的
* AC 数拉出来比不出什么。从上面那份派生,不另抄一份。
*/
export const LONG_DURATION_OPTIONS = DURATION_OPTIONS.filter(
(option) => !["hours:1", "hours:2", "days:1"].includes(option.value),
+2 -3
View File
@@ -271,12 +271,11 @@ export type { SessionUser } from "@oj2/contract"
export type Rank = RankProfile
/** 榜单里「我」的位置:比 Rank 多一个全服名次 */
export type { MyRank } from "@oj2/contract"
/** 本周进步榜:`rank` 是周榜名次,跟存量总榜的名次没有关系 */
export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract"
export type { ClassComparison, ClassRankItem, ClassUserRank } from "@oj2/contract"
export type { ClassComparison } from "@oj2/contract"
/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */
export type { ClassActivity, ClassActivityProblem, LastVisit } from "@oj2/contract"
@@ -310,7 +309,7 @@ export type BlankContest = Omit<
*/
export type { ContestSubmissionInfo } from "@oj2/contract"
export type { WebsiteConfig, OnlineCount } from "@oj2/contract"
export type { WebsiteConfig } from "@oj2/contract"
export type {
JudgeServer as Server,
+1 -25
View File
@@ -1,6 +1,6 @@
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { sampleUserSchema } from "./common"
import { userProfileSchema } from "./auth"
export const registerRequestSchema = z.object({
@@ -37,27 +37,6 @@ export const rankProfileSchema = z.object({
isOnline: z.boolean().nullable().default(null),
})
/**
* 榜单里「我」的位置。`rank` 是**全服名次**,与当前翻到第几页无关 ——
* 前 100 名之外的学生也拿得到,页面靠它单独显示一行。
*
* 名次口径与列表的排序完全一致(AC 降序 → 提交数升序 → id 升序),
* 所以「我的名次」和「我在表格里的行号」永远对得上;三个键都相同才算并列。
*/
export const myRankSchema = rankProfileSchema.extend({
rank: z.number().int().positive(),
})
export const userRankSchema = paginatedSchema(rankProfileSchema).extend({
/** 未登录、或身份不入榜(教师/超管)时为 null */
me: myRankSchema.nullable(),
})
export const activityRankItemSchema = z.object({
username: z.string(),
count: z.number().int().nonnegative(),
})
/**
* 周榜的一行。`solvedCount` 是**本周首次 AC 的题目数** —— 不是「本周 AC 过的去重题数」。
* 后者会把上周就做出来的题重交一次也算成本周成绩,等于给刷榜留了个口子;
@@ -99,9 +78,6 @@ export type RegisterRequest = z.infer<typeof registerRequestSchema>
export type UpdateProfileRequest = z.infer<typeof updateProfileRequestSchema>
export type ProblemRank = z.infer<typeof problemRankSchema>
export type RankProfile = z.infer<typeof rankProfileSchema>
export type UserRank = z.infer<typeof userRankSchema>
export type MyRank = z.infer<typeof myRankSchema>
export type ActivityRankItem = z.infer<typeof activityRankItemSchema>
export type WeeklyRankItem = z.infer<typeof weeklyRankItemSchema>
export type WeeklyRank = z.infer<typeof weeklyRankSchema>
export type Metrics = z.infer<typeof metricsSchema>
-28
View File
@@ -3,31 +3,6 @@ import { z } from "zod"
import { judgeStatusSchema } from "./judge-status"
import { lessonLanguageSchema as lessonLanguageValueSchema } from "./language"
export const classRankItemSchema = z.object({
className: z.string(),
userCount: z.number().int(),
totalAc: z.number().int(),
totalSubmission: z.number().int(),
avgAc: z.number(),
acRate: z.number(),
rank: z.number().int(),
})
export const classUserRankItemSchema = z.object({
userId: z.number().int(),
username: z.string(),
acceptedNumber: z.number().int(),
submissionNumber: z.number().int(),
rank: z.number().int(),
})
export const classUserRankSchema = z.object({
className: z.string(),
myRank: z.number().int(),
total: z.number().int(),
ranks: z.array(classUserRankItemSchema),
})
export const classComparisonSchema = z.object({
className: z.string(),
userCount: z.number().int(),
@@ -225,8 +200,6 @@ export const classLessonSchema = z.object({
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
})
export type ClassRankItem = z.infer<typeof classRankItemSchema>
export type ClassUserRank = z.infer<typeof classUserRankSchema>
export type ClassComparison = z.infer<typeof classComparisonSchema>
export type ClassComparisonResponse = z.infer<typeof classComparisonResponseSchema>
@@ -240,5 +213,4 @@ export type ClassBoardStudent = z.infer<typeof classBoardStudentSchema>
export type ClassBoardCell = z.infer<typeof classBoardCellSchema>
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema>
export type ClassUserRankItem = z.infer<typeof classUserRankItemSchema>
export type ClassComparisonRequest = z.infer<typeof classComparisonRequestSchema>
+1
View File
@@ -12,6 +12,7 @@ export * from "./judge-status"
export * from "./language"
export * from "./problem"
export * from "./problemset"
export * from "./ranking"
export * from "./roles"
export * from "./site"
export * from "./submission"
+103
View File
@@ -0,0 +1,103 @@
import { z } from "zod"
import { sampleUserSchema } from "./common"
/**
* 排名页(设计稿「排名重设计」G1–G3)。用户要的是**激发竞争、一眼看出排名**,所以:
*
* - **同样多不并列**:做对多的在前,一样多的**先做到的在前**(`reachedAt` 早的在前)。
* 课堂驱动下一个班十几个人同分是常态,原来按「提交少的在前」排等于罚多试的人;
* 按先后排,名次差的是「这节课谁先做完」。
* - 「做对」= 这道题**第一次**做对落在这段时间里(比赛里的提交不算),老题重交不算成绩。
*/
export const rankScopeSchema = z.enum(["class", "grade", "all"])
export const rankPeriodSchema = z.enum(["week", "term", "all"])
export const rankRowSchema = z.object({
rank: z.number().int().positive(),
user: sampleUserSchema,
/** 自己上传的头像;还是默认头像时为 null,前端画名字最后一个字 */
avatar: z.string().nullable(),
className: z.string().nullable(),
solved: z.number().int().nonnegative(),
/** 做到这个数的时刻(最后一道新做对的题);一道没做对时为 null */
reachedAt: z.string().nullable(),
/**
* 比 `changeSince` 那一刻升了几名,正数是升。那时还一道没做对(不在榜上)时为 null ——
* 从无到有不算「升了几名」,否则开学第一周人人都是 ↑30。
*/
change: z.number().int().nullable(),
})
/** 我和对手每周的名次,给「我和对手的名次」那张走势图 */
export const rankTrendSchema = z.object({
/** 每个点的时刻(周一零点,也就是上周日晚上),最后一个是现在 */
points: z.array(z.string()),
series: z.array(
z.object({
userId: z.number().int(),
/** 和 points 一一对应;那时还没上榜为 null */
ranks: z.array(z.number().int().positive().nullable()),
}),
),
})
export const rankBoardSchema = z.object({
scope: rankScopeSchema,
period: rankPeriodSchema,
/** 本班 = 班号;本年级 = 年级(班号前两位,如 "25");全服 = null */
className: z.string().nullable(),
/** 这段时间从哪一刻算起;「全部」为 null */
start: z.string().nullable(),
/** `change` 拿哪一刻比:这周 = 今天零点,这学期 / 全部 = 本周一零点 */
changeSince: z.string(),
/** 榜上一共几个人。本班把一道没做对的也算上(全班名单),本年级 / 全服只算做对过的 */
total: z.number().int().nonnegative(),
/**
* 本班、或者带了 `full=1` 时是全部;否则是前面一段 + 我附近一段(`complete = false`),
* 中间折起来,按名次断开的地方就是折叠处
*/
rows: z.array(rankRowSchema),
complete: z.boolean(),
/** 我在这张榜上的那一行。没登录、老师、不计入排名、或者这段时间一道没做对时为 null */
me: rankRowSchema.nullable(),
/** 名次紧挨在我前面、后面的人 */
ahead: rankRowSchema.nullable(),
behind: rankRowSchema.nullable(),
/** 只有「这学期」「全部」且有 me 时才有:这周清零,画走势没意义 */
trend: rankTrendSchema.nullable(),
/** 我被老师设成了不计入排名(学生自己看得到这句话,别人看不到他) */
hidden: z.boolean(),
/** 老师看本班时:这个班不计入排名的人。其余情况为空 */
hiddenUsers: z.array(sampleUserSchema),
})
/** 班级对抗:人均做对,按这学期算;`weekGain` 是这周人均涨了多少 */
export const classBattleItemSchema = z.object({
rank: z.number().int().positive(),
className: z.string(),
members: z.number().int().positive(),
perCapita: z.number(),
weekGain: z.number(),
})
/** 本班每周冠军:最近几个已经结束的周,每周做对新题最多的人 */
export const weeklyChampionSchema = z.object({
weekStart: z.string(),
user: sampleUserSchema,
avatar: z.string().nullable(),
solved: z.number().int().positive(),
})
export const rankHiddenRequestSchema = z.object({
hidden: z.boolean(),
})
export type RankScope = z.infer<typeof rankScopeSchema>
export type RankPeriod = z.infer<typeof rankPeriodSchema>
export type RankRow = z.infer<typeof rankRowSchema>
export type RankTrend = z.infer<typeof rankTrendSchema>
export type RankBoard = z.infer<typeof rankBoardSchema>
export type ClassBattleItem = z.infer<typeof classBattleItemSchema>
export type WeeklyChampion = z.infer<typeof weeklyChampionSchema>
export type RankHiddenRequest = z.infer<typeof rankHiddenRequestSchema>
-6
View File
@@ -11,11 +11,6 @@ export const websiteConfigSchema = z.object({
enableMaxkb: z.boolean(),
})
/** 当前在线人数。只有聚合值 —— 「某某在不在线」是个人状态,不往匿名接口放 */
export const onlineCountSchema = z.object({
count: z.number().int().nonnegative(),
})
/**
* 一言。`from` 是出处(作品),`fromWho` 是谁说的,`type` 是数据集的分类名(动画、诗词……,界面不显示,
* 只拿来判断出处要不要加书名号),
@@ -31,4 +26,3 @@ export const quoteSchema = z.object({
export type WebsiteConfig = z.infer<typeof websiteConfigSchema>
export type Quote = z.infer<typeof quoteSchema>
export type OnlineCount = z.infer<typeof onlineCountSchema>