feat(排行榜): 重写全服 Top100,补上「我的排名」,后台那条挪进 admin
起因是 Top100 的「已解决」「提交数」两列一直空白:列的 key 还是 snake_case(accepted_number / submission_number),而数据早在c2a8120拆掉 转换层后就是 camelCase 了。naive-ui 按 row[key] 取值,取到 undefined 就渲染 空白、不报错 —— 和d3348f9是同一个病根。 顺着这条线把整个端点重写了: **上限不再由调用方传。** `top` 参数原来有三个调用方各传各的(100 / 10 / 0), 而它会覆盖 limit 与 offset、total 却按全量算,正是36e4ac2那个「每页都是同样 100 条」的成因。现在 100 写死在服务端,参数只剩 limit / offset。 「全服 Top10」不需要另一个上限,它就是这个榜的第一页。 **排序补了第三档 asc(user.id)。** 前两个键完全相同的学生在真实数据里成片存在 (都是 0/0),没有稳定兜底键时 postgres 每次返回的顺序可以不同,翻页会看到重复 或漏掉的人。老代码缺这一档。 **新增 me(我的全服名次)。** 名次 = 排在我前面的人数 + 1,三个排序键逐级比较, 与列表的 orderBy 逐字对应 —— 少比一级就会出现「显示第 7 名、实际排在表格第 9 行」。 榜上高亮我那一行,名次超出 100 时在 footer 单独给一行。未登录、教师/超管返回 null。 **后台那条搬去 /api/admin/rankings/users**(requireSuperAdmin,无上限)。 原来它走的是公开端点的 top=0 分支,也就是任何匿名请求都能 ?top=0&limit=250 翻走全校学生名单和个性签名 —— 而 /profiles/:username 恰恰为了收紧枚举面才做了 「匿名一律返回空」,注释里还专门点了 /rankings/users 的名。这条页面本来就是 requiresSuperAdmin,它调的另外两个接口也都是 requireSuperAdmin,守卫对得上。 顺手去掉恒真条件 gte(acceptedNumber, 0):该列是 notNull default 0。 同一次扫了全仓 218 个表格列定义,筛出 70 个没有 render 的(只有这些才靠 key 直接取值),比对全部类型定义里的字段名 —— 除这两处外没有漏网的。`_id` 和 `test_case` 是真字段名,不能改。另外收掉两处同类的雷: admin/setting/config.vue 手写的 `interface Testcase` 字段名和类型都是错的 (真实数据是 createTime: number,不是 create_time: string),改用契约的 OrphanTestCase;serverColumns 里 last_heartbeat / create_time 两个残留 key 有 render 兜着没出事,一并改正。 实测(造 120 个探针用户,含 3 个 AC 与提交数完全相同的并列,验完已清库): 122 人时 total=100;offset=95 末页 5 条;offset=100 越界返回空且不发 SQL; 并列三人稳定占据前三;student(ac=2) 拿到 rank=121 走「不在榜上」分支; 把 ac 调到 450 时 rank=4 且表格第 4 行正是 student(名次与行号对得上); 升成超管后 me 变 null;后台端点 total=121 无上限、keyword=probe_01 命中 10 条、 未登录 401;传 top=1 已被忽略。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
activityRankItemSchema,
|
activityRankItemSchema,
|
||||||
metricsSchema,
|
metricsSchema,
|
||||||
problemRankSchema,
|
problemRankSchema,
|
||||||
|
myRankSchema,
|
||||||
rankProfileSchema,
|
rankProfileSchema,
|
||||||
registerRequestSchema,
|
registerRequestSchema,
|
||||||
updateProfileRequestSchema,
|
updateProfileRequestSchema,
|
||||||
@@ -17,10 +18,11 @@ import {
|
|||||||
countDistinct,
|
countDistinct,
|
||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
|
gt,
|
||||||
gte,
|
gte,
|
||||||
ilike,
|
|
||||||
inArray,
|
inArray,
|
||||||
isNull,
|
isNull,
|
||||||
|
lt,
|
||||||
lte,
|
lte,
|
||||||
min,
|
min,
|
||||||
or,
|
or,
|
||||||
@@ -145,36 +147,108 @@ accountRoutes.get("/users/:id/metrics", async (c) => {
|
|||||||
return success(c, metricsSchema.parse({ now: new Date().toISOString(), first: row.first, latest: row.latest }))
|
return success(c, metricsSchema.parse({ now: new Date().toISOString(), first: row.first, latest: row.latest }))
|
||||||
})
|
})
|
||||||
|
|
||||||
accountRoutes.get("/rankings/users", async (c) => {
|
/**
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
* 全服榜单的大小。**写死在服务端,不接受调用方传** —— 上限是这个端点的属性,
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
* 不是调用方的选择。
|
||||||
const top = queryInteger(c.req.query("top"), 0, { min: 0, max: 10_000 })
|
*
|
||||||
const username = c.req.query("username")?.trim() ?? ""
|
* 之前它是个 `top` 查询参数,三个调用方各传各的(100 / 10 / 0),
|
||||||
const where = and(
|
* 而 top 又会覆盖 limit 与 offset,total 却按全量人数算 —— 于是分页器算出几十页、
|
||||||
|
* 页页内容相同(36e4ac2)。「全服 Top10」不需要另一个上限,取 limit=10&offset=0 即可;
|
||||||
|
* 后台那个「不限量」的用法搬去了 /admin/rankings/users。
|
||||||
|
*/
|
||||||
|
const LEADERBOARD_SIZE = 100
|
||||||
|
|
||||||
|
/** 入榜人群:正常状态的学生与学生管理员。教师和超管不参与排名。 */
|
||||||
|
const leaderboardWhere = and(
|
||||||
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
|
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
|
||||||
eq(schema.user.isDisabled, false),
|
eq(schema.user.isDisabled, false),
|
||||||
gte(schema.userProfile.acceptedNumber, 0),
|
)
|
||||||
username ? ilike(schema.user.username, `%${username}%`) : undefined,
|
|
||||||
)
|
/**
|
||||||
|
* 榜单排序: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 })
|
||||||
|
|
||||||
const [totalRow] = await db.select({ value: count() }).from(schema.userProfile)
|
const [totalRow] = await db.select({ value: count() }).from(schema.userProfile)
|
||||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||||
// top 只是「榜单取前 N 名」的上限,分页仍要在这 N 条之内生效:
|
.where(leaderboardWhere)
|
||||||
// 否则 top=100 时每页都返回同样的 100 条,而 total 又是全量人数,翻页翻不动
|
const total = Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE)
|
||||||
const total = top > 0 ? Math.min(totalRow?.value ?? 0, top) : (totalRow?.value ?? 0)
|
|
||||||
const pageLimit = top > 0 ? Math.max(0, Math.min(limit, top - offset)) : limit
|
// 末页可能只剩不足 limit 条,越界页一条不剩 —— 后者直接不发 SQL
|
||||||
const rows = pageLimit === 0 ? [] : await db.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
const pageLimit = Math.max(0, Math.min(limit, total - offset))
|
||||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
const rows = pageLimit === 0 ? [] : await db
|
||||||
.orderBy(desc(schema.userProfile.acceptedNumber), asc(schema.userProfile.submissionNumber))
|
.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)
|
.limit(pageLimit).offset(offset)
|
||||||
const results = rows.map(({ profile, user }) => rankProfileSchema.parse({
|
|
||||||
|
return success(c, userRankSchema.parse({
|
||||||
|
results: rows.map(serializeRankRow),
|
||||||
|
total,
|
||||||
|
me: await myLeaderboardRank(c.get("user")?.id),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function serializeRankRow({ profile, user }: {
|
||||||
|
profile: typeof schema.userProfile.$inferSelect
|
||||||
|
user: typeof schema.user.$inferSelect
|
||||||
|
}) {
|
||||||
|
return rankProfileSchema.parse({
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
user: sampleUser(user, profile.realName),
|
user: sampleUser(user, profile.realName),
|
||||||
acceptedNumber: profile.acceptedNumber,
|
acceptedNumber: profile.acceptedNumber,
|
||||||
submissionNumber: profile.submissionNumber,
|
submissionNumber: profile.submissionNumber,
|
||||||
mood: profile.mood,
|
mood: profile.mood,
|
||||||
}))
|
})
|
||||||
return success(c, userRankSchema.parse({ results, total }))
|
}
|
||||||
})
|
|
||||||
|
/**
|
||||||
|
* 「我」的全服名次,登录且身份入榜时才有。
|
||||||
|
*
|
||||||
|
* 名次 = 排在我前面的人数 + 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 myRankSchema.parse({
|
||||||
|
...serializeRankRow(mine),
|
||||||
|
rank: (ahead?.value ?? 0) + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
accountRoutes.get("/rankings/activity", async (c) => {
|
accountRoutes.get("/rankings/activity", async (c) => {
|
||||||
const start = c.req.query("start")
|
const start = c.req.query("start")
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
adminUserListSchema,
|
adminUserListSchema,
|
||||||
|
adminUserRankSchema,
|
||||||
adminUserSchema,
|
adminUserSchema,
|
||||||
deleteUsersRequestSchema,
|
deleteUsersRequestSchema,
|
||||||
importUsersRequestSchema,
|
importUsersRequestSchema,
|
||||||
|
rankProfileSchema,
|
||||||
resetPasswordResponseSchema,
|
resetPasswordResponseSchema,
|
||||||
updateUserRequestSchema,
|
updateUserRequestSchema,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
@@ -13,7 +15,7 @@ import { Hono } from "hono"
|
|||||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
import { failure, success } from "../../http"
|
import { failure, success } from "../../http"
|
||||||
import { queryInteger } from "../helpers"
|
import { queryInteger, sampleUser } from "../helpers"
|
||||||
|
|
||||||
export const adminAccountRoutes = new Hono<AppEnv>()
|
export const adminAccountRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -79,6 +81,51 @@ function selectUser(id: number) {
|
|||||||
.where(eq(schema.user.id, id)).limit(1)
|
.where(eq(schema.user.id, id)).limit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台的用户排名:老师按班级前缀翻学生,**不设 100 名上限**。
|
||||||
|
*
|
||||||
|
* 这份逻辑原来是公开榜单 `/rankings/users` 的 `top=0` 分支,搬过来是因为那意味着
|
||||||
|
* 任何匿名请求都能 `?top=0&limit=250` 翻走全校学生名单和个性签名 ——
|
||||||
|
* 而 `/profiles/:username` 恰恰为了收紧枚举面才做了「匿名一律返回空」。
|
||||||
|
*
|
||||||
|
* 排序口径与公开榜单一致(见 routes/account.ts 的 leaderboardOrder):
|
||||||
|
* AC 降序 → 提交数升序 → id 升序,第三档保证翻页稳定。
|
||||||
|
*/
|
||||||
|
adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
|
||||||
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
|
const where = and(
|
||||||
|
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
keyword ? ilike(schema.user.username, `%${keyword}%`) : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
const [totalRows, rows] = await Promise.all([
|
||||||
|
db.select({ value: count() }).from(schema.userProfile)
|
||||||
|
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where),
|
||||||
|
db.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
||||||
|
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
||||||
|
.orderBy(
|
||||||
|
desc(schema.userProfile.acceptedNumber),
|
||||||
|
asc(schema.userProfile.submissionNumber),
|
||||||
|
asc(schema.user.id),
|
||||||
|
)
|
||||||
|
.limit(limit).offset(offset),
|
||||||
|
])
|
||||||
|
|
||||||
|
return success(c, adminUserRankSchema.parse({
|
||||||
|
results: rows.map(({ profile, user }) => rankProfileSchema.parse({
|
||||||
|
id: profile.id,
|
||||||
|
user: sampleUser(user, profile.realName),
|
||||||
|
acceptedNumber: profile.acceptedNumber,
|
||||||
|
submissionNumber: profile.submissionNumber,
|
||||||
|
mood: profile.mood,
|
||||||
|
})),
|
||||||
|
total: totalRows[0]?.value ?? 0,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
|
adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
|
|||||||
@@ -139,6 +139,13 @@ export function batchTagProblems(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用户排名(后台版,无 100 名上限;公开榜单是 oj/api.ts 的 getRank)
|
||||||
|
export function getAdminUserRank(offset: number, limit: number, keyword: string) {
|
||||||
|
return api2.get<AdminUserRank>("admin/rankings/users", {
|
||||||
|
params: { offset, limit, keyword },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 用户列表
|
// 用户列表
|
||||||
export function getUserList(
|
export function getUserList(
|
||||||
offset = 0,
|
offset = 0,
|
||||||
@@ -707,6 +714,7 @@ export function getPinnedAIReports() {
|
|||||||
import type {
|
import type {
|
||||||
AdminAchievement,
|
AdminAchievement,
|
||||||
AchievementMetric as MetricOption,
|
AchievementMetric as MetricOption,
|
||||||
|
AdminUserRank,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
export type { AdminAchievement, MetricOption }
|
export type { AdminAchievement, MetricOption }
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ import {
|
|||||||
} from "../api"
|
} from "../api"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
|
|
||||||
interface Testcase {
|
|
||||||
id: string
|
|
||||||
create_time: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -41,7 +36,7 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const testcaseColumns: DataTableColumn<Testcase>[] = [
|
const testcaseColumns: DataTableColumn<OrphanTestCase>[] = [
|
||||||
{ title: "测试用例 ID", key: "id" },
|
{ title: "测试用例 ID", key: "id" },
|
||||||
{
|
{
|
||||||
title: "选项",
|
title: "选项",
|
||||||
@@ -102,13 +97,13 @@ const serverColumns: DataTableColumn<Server>[] = [
|
|||||||
{ title: "服务器 URL", key: "serviceUrl", width: 200 },
|
{ title: "服务器 URL", key: "serviceUrl", width: 200 },
|
||||||
{
|
{
|
||||||
title: "上一次心跳",
|
title: "上一次心跳",
|
||||||
key: "last_heartbeat",
|
key: "lastHeartbeat",
|
||||||
render: (row) => parseTime(row.lastHeartbeat, "YYYY-MM-DD HH:mm:ss"),
|
render: (row) => parseTime(row.lastHeartbeat, "YYYY-MM-DD HH:mm:ss"),
|
||||||
width: 120,
|
width: 120,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "创建时间",
|
title: "创建时间",
|
||||||
key: "create_time",
|
key: "createTime",
|
||||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
||||||
width: 120,
|
width: 120,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
import { h, onMounted, reactive, ref, watch } from "vue"
|
import { h, onMounted, reactive, ref, watch } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { NButton } from "naive-ui"
|
import { NButton } from "naive-ui"
|
||||||
import { getRank } from "oj/api"
|
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { getACRate } from "utils/functions"
|
import { getACRate } from "utils/functions"
|
||||||
import type { Rank } from "utils/types"
|
import type { Rank } from "utils/types"
|
||||||
import { getBaseInfo, randomUser10 } from "../api"
|
import { getAdminUserRank, getBaseInfo, randomUser10 } from "../api"
|
||||||
|
|
||||||
const userCount = ref(0)
|
const userCount = ref(0)
|
||||||
const submissionCount = ref(0)
|
const submissionCount = ref(0)
|
||||||
@@ -72,7 +71,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
async function listRanks() {
|
async function listRanks() {
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
const res = await getRank(offset, query.limit, 0, query.classroom)
|
const res = await getAdminUserRank(offset, query.limit, query.classroom)
|
||||||
data.value = res.data.results
|
data.value = res.data.results
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,15 +164,12 @@ export function getSubmissionStatistics(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRank(
|
/**
|
||||||
offset: number,
|
* 全服榜单。上限(100 名)由服务端定,调用方只管翻页 ——
|
||||||
limit: number,
|
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
|
||||||
n: number,
|
*/
|
||||||
username?: string,
|
export function getRank(offset: number, limit: number) {
|
||||||
) {
|
return api2.get<UserRank>("rankings/users", { params: { offset, limit } })
|
||||||
return api2.get<UserRank>("rankings/users", {
|
|
||||||
params: { offset, limit, username, top: n },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActivityRank(start: string) {
|
export function getActivityRank(start: string) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ClassComparison,
|
ClassComparison,
|
||||||
ClassRankItem as ClassRank,
|
ClassRankItem as ClassRank,
|
||||||
ClassUserRank,
|
ClassUserRank,
|
||||||
|
MyRank,
|
||||||
Rank,
|
Rank,
|
||||||
} from "utils/types"
|
} from "utils/types"
|
||||||
import { formatISO, sub, type Duration } from "date-fns"
|
import { formatISO, sub, type Duration } from "date-fns"
|
||||||
@@ -40,6 +41,10 @@ const userStore = useUserStore()
|
|||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
const data = ref<Rank[]>([])
|
const data = ref<Rank[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
/** 我的全服名次;未登录、教师/超管不入榜时为 null */
|
||||||
|
const me = ref<MyRank | null>(null)
|
||||||
|
/** 我在前 100 名之外 —— 榜上高亮不到我,另起一行显示 */
|
||||||
|
const meOffBoard = computed(() => !!me.value && me.value.rank > total.value)
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
limit: 10,
|
limit: 10,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -145,12 +150,22 @@ async function analyzeSingleClassWithAI() {
|
|||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
const res = await getRank(offset, query.limit, 100)
|
const res = await getRank(offset, query.limit)
|
||||||
data.value = res.data.results
|
data.value = res.data.results
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
|
me.value = res.data.me
|
||||||
return res.data.results
|
return res.data.results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMe(row: Rank) {
|
||||||
|
return !!me.value && row.user.id === me.value.user.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高亮我那一行。用 id 比对而不是用户名:用户名会重名到大小写差异上,id 不会
|
||||||
|
function rowClassName(row: Rank) {
|
||||||
|
return isMe(row) ? "me-row" : ""
|
||||||
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<Rank>[] = [
|
const columns: DataTableColumn<Rank>[] = [
|
||||||
{
|
{
|
||||||
title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"),
|
title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"),
|
||||||
@@ -178,6 +193,9 @@ const columns: DataTableColumn<Rank>[] = [
|
|||||||
},
|
},
|
||||||
() => row.user.username,
|
() => row.user.username,
|
||||||
),
|
),
|
||||||
|
isMe(row)
|
||||||
|
? h(Icon, { width: 20, icon: "fluent-emoji:person-raising-hand" })
|
||||||
|
: null,
|
||||||
h(
|
h(
|
||||||
NButton,
|
NButton,
|
||||||
{
|
{
|
||||||
@@ -201,7 +219,7 @@ const columns: DataTableColumn<Rank>[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle("已解决", "streamline-emojis:raised-fist-1"),
|
title: renderTableTitle("已解决", "streamline-emojis:raised-fist-1"),
|
||||||
key: "accepted_number",
|
key: "acceptedNumber",
|
||||||
width: 120,
|
width: 120,
|
||||||
align: "center",
|
align: "center",
|
||||||
},
|
},
|
||||||
@@ -210,7 +228,7 @@ const columns: DataTableColumn<Rank>[] = [
|
|||||||
"提交数",
|
"提交数",
|
||||||
"streamline-ultimate-color:space-rocket-earth",
|
"streamline-ultimate-color:space-rocket-earth",
|
||||||
),
|
),
|
||||||
key: "submission_number",
|
key: "submissionNumber",
|
||||||
width: 120,
|
width: 120,
|
||||||
align: "center",
|
align: "center",
|
||||||
},
|
},
|
||||||
@@ -247,8 +265,9 @@ async function listActivity() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 「全服 Top10」就是同一个榜的第一页 —— 上限由服务端定,这里只要前 10 条
|
||||||
async function listRank() {
|
async function listRank() {
|
||||||
const res = await getRank(0, 10, 10)
|
const res = await getRank(0, 10)
|
||||||
rankChart.value = res.data.results
|
rankChart.value = res.data.results
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,13 +528,29 @@ watch(
|
|||||||
</n-grid>
|
</n-grid>
|
||||||
<n-card>
|
<n-card>
|
||||||
<template #header>全服 Top100</template>
|
<template #header>全服 Top100</template>
|
||||||
<n-data-table :data="data" :columns="columns" />
|
<n-data-table
|
||||||
|
:data="data"
|
||||||
|
:columns="columns"
|
||||||
|
:row-class-name="rowClassName"
|
||||||
|
/>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
<n-flex align="center" justify="space-between" :wrap="false">
|
||||||
|
<!-- 前 100 名之外的学生榜上找不到自己,这里单独给一行 -->
|
||||||
|
<n-tag v-if="meOffBoard" type="info" round :bordered="false">
|
||||||
|
<template #icon>
|
||||||
|
<Icon width="18" icon="fluent-emoji:person-raising-hand" />
|
||||||
|
</template>
|
||||||
|
我的排名:第 {{ me!.rank }} 名 · 已解决 {{ me!.acceptedNumber }} ·
|
||||||
|
提交 {{ me!.submissionNumber }} · 正确率
|
||||||
|
{{ getACRate(me!.acceptedNumber, me!.submissionNumber) }}
|
||||||
|
</n-tag>
|
||||||
|
<span v-else />
|
||||||
<Pagination
|
<Pagination
|
||||||
:total="total"
|
:total="total"
|
||||||
v-model:page="query.page"
|
v-model:page="query.page"
|
||||||
v-model:limit="query.limit"
|
v-model:limit="query.limit"
|
||||||
/>
|
/>
|
||||||
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
</n-card>
|
</n-card>
|
||||||
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
|
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
|
||||||
@@ -800,6 +835,10 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
:deep(.me-row > td) {
|
||||||
|
background-color: rgba(24, 160, 88, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-total-ac :deep(.n-statistic-value),
|
.stat-total-ac :deep(.n-statistic-value),
|
||||||
.stat-total-ac :deep(.n-statistic-value__content),
|
.stat-total-ac :deep(.n-statistic-value__content),
|
||||||
.stat-total-ac :deep(.n-number-animation),
|
.stat-total-ac :deep(.n-number-animation),
|
||||||
|
|||||||
@@ -380,6 +380,9 @@ export type { SessionUser } from "@oj2/contract"
|
|||||||
|
|
||||||
export type Rank = RankProfile
|
export type Rank = RankProfile
|
||||||
|
|
||||||
|
/** 榜单里「我」的位置:比 Rank 多一个全服名次 */
|
||||||
|
export type { MyRank } from "@oj2/contract"
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ClassComparison,
|
ClassComparison,
|
||||||
ClassRankItem,
|
ClassRankItem,
|
||||||
|
|||||||
@@ -29,7 +29,21 @@ export const rankProfileSchema = z.object({
|
|||||||
mood: z.string().nullable(),
|
mood: z.string().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const userRankSchema = paginatedSchema(rankProfileSchema)
|
/**
|
||||||
|
* 榜单里「我」的位置。`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({
|
export const activityRankItemSchema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
@@ -50,5 +64,6 @@ 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 UserRank = z.infer<typeof userRankSchema>
|
||||||
|
export type MyRank = z.infer<typeof myRankSchema>
|
||||||
export type ActivityRankItem = z.infer<typeof activityRankItemSchema>
|
export type ActivityRankItem = z.infer<typeof activityRankItemSchema>
|
||||||
export type Metrics = z.infer<typeof metricsSchema>
|
export type Metrics = z.infer<typeof metricsSchema>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { achievementRaritySchema } from "./achievement"
|
import { achievementRaritySchema } from "./achievement"
|
||||||
|
import { rankProfileSchema } from "./account"
|
||||||
import { paginatedSchema, sampleUserSchema } from "./common"
|
import { paginatedSchema, sampleUserSchema } from "./common"
|
||||||
import { problemDifficultySchema } from "./problem"
|
import { problemDifficultySchema } from "./problem"
|
||||||
|
|
||||||
@@ -269,6 +270,15 @@ export const orphanTestCaseSchema = z.object({
|
|||||||
createTime: z.number(),
|
createTime: z.number(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台的用户排名列表。行的形状与 oj 侧榜单相同,但**没有 100 名的上限**:
|
||||||
|
* 这里不是榜单,是老师按班级前缀翻全班学生的管理视图,要能一直翻到底。
|
||||||
|
*
|
||||||
|
* 独立成一条端点而不是给公开榜单加个「不限量」开关:那个开关一旦存在,
|
||||||
|
* 匿名请求就能拉走全校学生名单和个性签名。
|
||||||
|
*/
|
||||||
|
export const adminUserRankSchema = paginatedSchema(rankProfileSchema)
|
||||||
|
|
||||||
export const dashboardInfoSchema = z.object({
|
export const dashboardInfoSchema = z.object({
|
||||||
userCount: z.number().int(),
|
userCount: z.number().int(),
|
||||||
recentContestCount: z.number().int(),
|
recentContestCount: z.number().int(),
|
||||||
@@ -644,6 +654,7 @@ export type JudgeServer = z.infer<typeof judgeServerSchema>
|
|||||||
export type DashboardInfo = z.infer<typeof dashboardInfoSchema>
|
export type DashboardInfo = z.infer<typeof dashboardInfoSchema>
|
||||||
export type JudgeServerList = z.infer<typeof judgeServerListSchema>
|
export type JudgeServerList = z.infer<typeof judgeServerListSchema>
|
||||||
export type OrphanTestCase = z.infer<typeof orphanTestCaseSchema>
|
export type OrphanTestCase = z.infer<typeof orphanTestCaseSchema>
|
||||||
|
export type AdminUserRank = z.infer<typeof adminUserRankSchema>
|
||||||
|
|
||||||
export type AdminAiReportListItem = z.infer<typeof adminAiReportListItemSchema>
|
export type AdminAiReportListItem = z.infer<typeof adminAiReportListItemSchema>
|
||||||
export type AdminAiReport = z.infer<typeof adminAiReportSchema>
|
export type AdminAiReport = z.infer<typeof adminAiReportSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user