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
+6630 -1389

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)) 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) { export async function clearOnline(userId: number) {
await redis.zrem(PRESENCE_KEY, String(userId)) 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, "when": 1791555848163,
"tag": "0029_regrant_problemset_badges", "tag": "0029_regrant_problemset_badges",
"breakpoints": true "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>(), problemPermission: text("problem_permission").notNull().$type<ProblemPermission>(),
rawPassword: varchar("raw_password", { length: 20 }), rawPassword: varchar("raw_password", { length: 20 }),
className: text("class_name"), className: text("class_name"),
/**
* 老师把这个学生「不计入排名」的时刻,null = 正常上榜。怀疑抄代码时用,随时能恢复
* (见 routes/ranking.ts)。只管排名页和首页周榜,比赛排名不受它影响
*/
rankHiddenAt: timestamp("rank_hidden_at", { withTimezone: true, mode: "string" }),
}, },
(table) => [ (table) => [
unique("user_username_key").on(table.username), 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 { contestRoutes } from "./routes/contest"
import { contentRoutes } from "./routes/content" import { contentRoutes } from "./routes/content"
import { classroomRoutes } from "./routes/classroom" import { classroomRoutes } from "./routes/classroom"
import { rankingRoutes } from "./routes/ranking"
import { problemsetRoutes } from "./routes/problemset" import { problemsetRoutes } from "./routes/problemset"
import { achievementRoutes } from "./routes/achievement" import { achievementRoutes } from "./routes/achievement"
import { aiRoutes } from "./routes/ai" import { aiRoutes } from "./routes/ai"
@@ -36,6 +37,7 @@ app.route("/api", siteRoutes)
app.route("/api", contestRoutes) app.route("/api", contestRoutes)
app.route("/api", contentRoutes) app.route("/api", contentRoutes)
app.route("/api", classroomRoutes) app.route("/api", classroomRoutes)
app.route("/api", rankingRoutes)
app.route("/api", problemsetRoutes) app.route("/api", problemsetRoutes)
app.route("/api", achievementRoutes) app.route("/api", achievementRoutes)
app.route("/api", aiRoutes) app.route("/api", aiRoutes)
+7 -197
View File
@@ -5,25 +5,18 @@ import {
registerRequestSchema, registerRequestSchema,
STUDENT_ROLES, STUDENT_ROLES,
updateProfileRequestSchema, updateProfileRequestSchema,
type ActivityRankItem,
type KnowledgeLevel, type KnowledgeLevel,
type KnowledgeMap, type KnowledgeMap,
type Metrics, type Metrics,
type MyRank,
type ProblemRank, type ProblemRank,
type RankProfile,
type UserRank,
type WeeklyRank, type WeeklyRank,
type WeeklyRankItem, type WeeklyRankItem,
} from "@oj2/contract" } from "@oj2/contract"
import { import {
and, and,
asc,
count, count,
countDistinct, countDistinct,
desc,
eq, eq,
gt,
gte, gte,
inArray, inArray,
isNull, isNull,
@@ -31,7 +24,6 @@ import {
lte, lte,
max, max,
min, min,
ne,
notExists, notExists,
or, or,
sql, sql,
@@ -40,7 +32,6 @@ import { alias } from "drizzle-orm/pg-core"
import { Hono } from "hono" import { Hono } from "hono"
import { hashPassword } from "../auth/password" import { hashPassword } from "../auth/password"
import { onlineUserIds } from "../auth/presence"
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware" import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import { config } from "../config" import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
@@ -51,7 +42,7 @@ import { sniffImageExtension } from "../services/image"
import { getUserProfileById } from "../services/profile" import { getUserProfileById } from "../services/profile"
import { clientIp, countAttempt, lockoutRemaining, type AttemptRule } from "../services/throttling" import { clientIp, countAttempt, lockoutRemaining, type AttemptRule } from "../services/throttling"
import { localTime, weekStart } from "../time" import { localTime, weekStart } from "../time"
import { isTeacherOrAbove, asRecord, queryInteger, sampleUser } from "./helpers" import { asRecord, queryInteger, sampleUser } from "./helpers"
export const accountRoutes = new Hono<AppEnv>() export const accountRoutes = new Hono<AppEnv>()
@@ -134,7 +125,7 @@ accountRoutes.post("/users", async (c) => {
accountRoutes.get("/profiles/:username", optionalAuth, async (c) => { accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
// 对齐旧后端 account/views/oj.py 的 UserProfileAPI.get 首行: // 对齐旧后端 account/views/oj.py 的 UserProfileAPI.get 首行:
// `if not user.is_authenticated: return self.success()` —— 匿名一律返回空, // `if not user.is_authenticated: return self.success()` —— 匿名一律返回空,
// 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。 // 否则用户名可经排名接口(/rankings/board)公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
if (!c.get("user")) return success(c, null) if (!c.get("user")) return success(c, null)
const [target] = await db const [target] = await db
.select({ id: schema.user.id }) .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` 单独看到自己的名次。 * 长了反而又变成一张追不上的总表。榜外的人靠 `me` 单独看到自己的名次。
*/ */
const WEEKLY_BOARD_SIZE = 10 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] const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** /**
* 本周进步榜:按**本周首次 AC 的题目数**排名,每周一 0:00(东八区)清零。 * 本周进步榜:按**本周首次 AC 的题目数**排名,每周一 0:00(东八区)清零。
* *
* 和 `/rankings/users` 的区别不只是加了时间窗:那张榜排的是 `user_profile` 的存量
* AC 总数,名次几乎不动,中位学生看一眼就知道追不上,等于负反馈。这张榜的分母是
* 「这一周」,谁都可能进前十。
*
* 「首次 AC」是靠 NOT EXISTS 排掉本周之前已经通过过的 (user, problem) 对,不是简单 * 「首次 AC」是靠 NOT EXISTS 排掉本周之前已经通过过的 (user, problem) 对,不是简单
* 数本周 AC 的去重题数 —— 后者把老题重交一遍也算成绩,一分钟能刷满一屏。 * 数本周 AC 的去重题数 —— 后者把老题重交一遍也算成绩,一分钟能刷满一屏。
* 相关子查询的四个条件正好是 `submission_public_metrics_idx` * 相关子查询的四个条件正好是 `submission_public_metrics_idx`
@@ -417,10 +225,12 @@ accountRoutes.get("/rankings/weekly", optionalAuth, async (c) => {
const start = weekStart() const start = weekStart()
// 入榜人群与全服榜一致(leaderboardWhere):正常状态的学生与学生管理员 // 入榜人群与排名页一致(routes/ranking.ts 的 rankedStudents):正常状态的学生与学生管理员,
// 老师设成不计入排名的不算
const audience = and( const audience = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]), inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false), eq(schema.user.isDisabled, false),
isNull(schema.user.rankHiddenAt),
className ? eq(schema.user.className, className) : undefined, className ? eq(schema.user.className, className) : undefined,
) )
const thisWeek = and( const thisWeek = and(
+1 -62
View File
@@ -10,8 +10,6 @@ import {
type ClassActivityProblem, type ClassActivityProblem,
type ClassComparison, type ClassComparison,
type ClassComparisonResponse, type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
type LastVisit, type LastVisit,
type LessonLanguage, type LessonLanguage,
FLOWCHART_PASS_GRADES, FLOWCHART_PASS_GRADES,
@@ -39,7 +37,7 @@ 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 { queryInteger, rounded } from "./helpers" import { rounded } from "./helpers"
export const classroomRoutes = new Hono<AppEnv>() 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 秋的实测分布是两极的: * 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题), * (班级, 日, 题) 要么只有 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 { asc, desc, eq } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { resolve } from "node:path" import { resolve } from "node:path"
import { onlineCount } from "../auth/presence"
import { config } from "../config" import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
@@ -26,14 +25,6 @@ siteRoutes.get("/site", async (c) => {
} satisfies WebsiteConfig) } satisfies WebsiteConfig)
}) })
/**
* 当前在线人数。匿名可读 —— 一个聚合数字不暴露任何人的身份,
* 而榜单页本身就允许匿名看。谁在线是另一回事,只在 /rankings/users 里对老师下发。
*/
siteRoutes.get("/site/online", async (c) => {
return success(c, { count: await onlineCount() } satisfies OnlineCount)
})
// 数据集读不到时的兜底(本机旁边没有 sentences-bundle 就会走这里) // 数据集读不到时的兜底(本机旁边没有 sentences-bundle 就会走这里)
const fallbackQuotes = [ 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() 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 { export function shiftMonthsByCalendar(instant: Date, months: number): Date {
const wall = toWallClock(instant) const wall = toWallClock(instant)
+34 -27
View File
@@ -6,7 +6,6 @@ import {
type ContestList, type ContestList,
type ContestClassView, type ContestClassView,
type ContestScoreboard, type ContestScoreboard,
type ActivityRankItem,
type FormatCodeResponse, type FormatCodeResponse,
type Metrics, type Metrics,
type TutorialSummary, type TutorialSummary,
@@ -16,9 +15,11 @@ import {
type ClassLesson, type ClassLesson,
type KnowledgeMap, type KnowledgeMap,
type ClassComparisonResponse, type ClassComparisonResponse,
type ClassRankItem, type ClassBattleItem,
type ClassUserRank, type RankBoard,
type UserRank, type RankPeriod,
type RankScope,
type WeeklyChampion,
type WeeklyRank, type WeeklyRank,
type ProblemRank, type ProblemRank,
type CreateSubmissionResponse, type CreateSubmissionResponse,
@@ -64,7 +65,6 @@ import type {
Submission, Submission,
SubmissionListPayload, SubmissionListPayload,
SubmitCodePayload, SubmitCodePayload,
OnlineCount,
Tutorial, Tutorial,
TutorialProgress, TutorialProgress,
} from "utils/types" } from "utils/types"
@@ -77,11 +77,6 @@ import type {
*/ */
const loadDetailSchemas = () => import("./detailSchemas") const loadDetailSchemas = () => import("./detailSchemas")
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
export function getOnlineCount() {
return api.get<OnlineCount>("site/online")
}
export async function getProblemList( export async function getProblemList(
offset = 0, offset = 0,
limit = 10, limit = 10,
@@ -227,19 +222,41 @@ export function getSubmissionStatisticsGrid(
} }
/** /**
* 全服榜单。上限(100 名)由服务端定,调用方只管翻页 —— * 排名页的榜:本班 / 本年级 / 全服 × 这周 / 这学期 / 全部。学生看自己的班和年级,
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。 * 老师用 `className` 选班(不给就是最近上课的班)。`full` 把本年级 / 全服中间折起来的那段也要回来
*/ */
export function getRank(offset: number, limit: number) { export function getRankBoard(
return api.get<UserRank>("rankings/users", { params: { offset, limit } }) 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", { export function getClassBattle() {
params: { start }, 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 —— * 本周进步榜。`scope` 只有两个取值,服务端认不出的一律当 global ——
* 班级榜要求调用者有班级,教师/超管拿到的是 400,所以别在没班级时切过去。 * 班级榜要求调用者有班级,教师/超管拿到的是 400,所以别在没班级时切过去。
@@ -281,16 +298,6 @@ export function getLastVisit() {
return api.get<LastVisit>("me/last-visit") 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) { export function getClassPK(classNames: string[], startTime?: string, endTime?: string) {
return api.post<ClassComparisonResponse>("classes/comparison", { return api.post<ClassComparisonResponse>("classes/comparison", {
classNames, classNames,
+1 -1
View File
@@ -60,7 +60,7 @@ const aiLoading = aiStream.waiting
const aiContent = ref("") const aiContent = ref("")
const showAIModal = ref(false) const showAIModal = ref(false)
// 长时段和榜单页同一份(LONG_DURATION_OPTIONS),外加一个「全部时间」 // 长时段选项(LONG_DURATION_OPTIONS),外加一个「全部时间」
const timeRangeOptions: SelectOption[] = [ const timeRangeOptions: SelectOption[] = [
{ label: "全部时间", value: "" }, { label: "全部时间", value: "" },
...LONG_DURATION_OPTIONS, ...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, SQL: blankTemplate,
} as const } as const
export enum ChartType {
Rank,
Activity,
}
// 成就稀有度 // 成就稀有度
export const RARITY_LABEL: Record<AchievementRarity, string> = { export const RARITY_LABEL: Record<AchievementRarity, string> = {
bronze: "青铜", bronze: "青铜",
@@ -329,11 +324,10 @@ export const DURATION_OPTIONS = [
] as const ] as const
/** /**
* 榜单和班级对比用的长时段,一周起步。 * 班级对比用的长时段,一周起步。
* *
* 这两个页面看的是长期趋势,「本节课内」这种窗口在那儿没有意义 —— 全班一小时内的 * 看的是长期趋势,「本节课内」这种窗口在那儿没有意义 —— 全班一小时内的
* AC 数拉出来比不出什么。原来 rank/list.vue 和 class/pk.vue 各手抄了一份同样的五条, * AC 数拉出来比不出什么。从上面那份派生,不另抄一份。
* pk.vue 里还留着「与 rank/list.vue 保持一致」的注释,现在从上面那份派生。
*/ */
export const LONG_DURATION_OPTIONS = DURATION_OPTIONS.filter( export const LONG_DURATION_OPTIONS = DURATION_OPTIONS.filter(
(option) => !["hours:1", "hours:2", "days:1"].includes(option.value), (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 export type Rank = RankProfile
/** 榜单里「我」的位置:比 Rank 多一个全服名次 */ /** 榜单里「我」的位置:比 Rank 多一个全服名次 */
export type { MyRank } from "@oj2/contract"
/** 本周进步榜:`rank` 是周榜名次,跟存量总榜的名次没有关系 */ /** 本周进步榜:`rank` 是周榜名次,跟存量总榜的名次没有关系 */
export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract" export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract"
export type { ClassComparison, ClassRankItem, ClassUserRank } 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"
@@ -310,7 +309,7 @@ export type BlankContest = Omit<
*/ */
export type { ContestSubmissionInfo } from "@oj2/contract" export type { ContestSubmissionInfo } from "@oj2/contract"
export type { WebsiteConfig, OnlineCount } from "@oj2/contract" export type { WebsiteConfig } from "@oj2/contract"
export type { export type {
JudgeServer as Server, JudgeServer as Server,
+1 -25
View File
@@ -1,6 +1,6 @@
import { z } from "zod" import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common" import { sampleUserSchema } from "./common"
import { userProfileSchema } from "./auth" import { userProfileSchema } from "./auth"
export const registerRequestSchema = z.object({ export const registerRequestSchema = z.object({
@@ -37,27 +37,6 @@ export const rankProfileSchema = z.object({
isOnline: z.boolean().nullable().default(null), 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 过的去重题数」。 * 周榜的一行。`solvedCount` 是**本周首次 AC 的题目数** —— 不是「本周 AC 过的去重题数」。
* 后者会把上周就做出来的题重交一次也算成本周成绩,等于给刷榜留了个口子; * 后者会把上周就做出来的题重交一次也算成本周成绩,等于给刷榜留了个口子;
@@ -99,9 +78,6 @@ export type RegisterRequest = z.infer<typeof registerRequestSchema>
export type UpdateProfileRequest = z.infer<typeof updateProfileRequestSchema> export type UpdateProfileRequest = z.infer<typeof updateProfileRequestSchema>
export type ProblemRank = z.infer<typeof problemRankSchema> export type ProblemRank = z.infer<typeof problemRankSchema>
export type RankProfile = z.infer<typeof rankProfileSchema> 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 WeeklyRankItem = z.infer<typeof weeklyRankItemSchema>
export type WeeklyRank = z.infer<typeof weeklyRankSchema> export type WeeklyRank = z.infer<typeof weeklyRankSchema>
export type Metrics = z.infer<typeof metricsSchema> export type Metrics = z.infer<typeof metricsSchema>
-28
View File
@@ -3,31 +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 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({ export const classComparisonSchema = z.object({
className: z.string(), className: z.string(),
userCount: z.number().int(), userCount: z.number().int(),
@@ -225,8 +200,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 ClassRankItem = z.infer<typeof classRankItemSchema>
export type ClassUserRank = z.infer<typeof classUserRankSchema>
export type ClassComparison = z.infer<typeof classComparisonSchema> export type ClassComparison = z.infer<typeof classComparisonSchema>
export type ClassComparisonResponse = z.infer<typeof classComparisonResponseSchema> 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 ClassBoardCell = z.infer<typeof classBoardCellSchema>
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema> export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema>
export type ClassUserRankItem = z.infer<typeof classUserRankItemSchema>
export type ClassComparisonRequest = z.infer<typeof classComparisonRequestSchema> export type ClassComparisonRequest = z.infer<typeof classComparisonRequestSchema>
+1
View File
@@ -12,6 +12,7 @@ export * from "./judge-status"
export * from "./language" export * from "./language"
export * from "./problem" export * from "./problem"
export * from "./problemset" export * from "./problemset"
export * from "./ranking"
export * from "./roles" export * from "./roles"
export * from "./site" export * from "./site"
export * from "./submission" 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(), enableMaxkb: z.boolean(),
}) })
/** 当前在线人数。只有聚合值 —— 「某某在不在线」是个人状态,不往匿名接口放 */
export const onlineCountSchema = z.object({
count: z.number().int().nonnegative(),
})
/** /**
* 一言。`from` 是出处(作品),`fromWho` 是谁说的,`type` 是数据集的分类名(动画、诗词……,界面不显示, * 一言。`from` 是出处(作品),`fromWho` 是谁说的,`type` 是数据集的分类名(动画、诗词……,界面不显示,
* 只拿来判断出处要不要加书名号), * 只拿来判断出处要不要加书名号),
@@ -31,4 +26,3 @@ export const quoteSchema = z.object({
export type WebsiteConfig = z.infer<typeof websiteConfigSchema> export type WebsiteConfig = z.infer<typeof websiteConfigSchema>
export type Quote = z.infer<typeof quoteSchema> export type Quote = z.infer<typeof quoteSchema>
export type OnlineCount = z.infer<typeof onlineCountSchema>