- 一张榜两个开关:本班 / 本年级 / 全服 × 这周 / 这学期 / 全部,默认本班 · 这学期。 原来最大那张全服 Top100 里,这学期在用的 25 级学生一个都进不去 - 做对一样多的不再按「提交少的在前」排,改成先做到的在前;名次旁边写比周一升降了几名 - 「你」那张卡:第几名、再做对几道上领奖台,要追的人早你几秒、追你的人晚你几秒 - 本年级 / 全服只列前面一段和你附近一段,中间折起来,点开再看 - 班级对抗按人均做对比,另有我和对手的名次走势、这周本班、每周冠军 - 老师不上榜,选班看;每行「⋯」能把怀疑抄代码的学生设成不计入排名(新增 user.rank_hidden_at, 首页周榜同样不算他),随时能恢复 - 去掉全服 Top10、日活两张图、在线人数和班级详情弹框里的统计词,以及它们背后的旧接口 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0cee4ef779
commit
1c6e2b9d82
31 files changed
+6633
-1392
No files matched your search
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 人以上(老师点的题),
|
||||
|
||||
@@ -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,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 = [
|
||||
{
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+795
-878
File diff suppressed because it is too large.
Load diff
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user