diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index fb98c85..b565d33 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -5,6 +5,7 @@ import { activityRankItemSchema, metricsSchema, problemRankSchema, + myRankSchema, rankProfileSchema, registerRequestSchema, updateProfileRequestSchema, @@ -17,10 +18,11 @@ import { countDistinct, desc, eq, + gt, gte, - ilike, inArray, isNull, + lt, lte, min, 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 })) }) -accountRoutes.get("/rankings/users", async (c) => { - const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) +/** + * 全服榜单的大小。**写死在服务端,不接受调用方传** —— 上限是这个端点的属性, + * 不是调用方的选择。 + * + * 之前它是个 `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, ["Regular User", "Student Admin"]), + 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 }) - const top = queryInteger(c.req.query("top"), 0, { min: 0, max: 10_000 }) - const username = c.req.query("username")?.trim() ?? "" - const where = and( - inArray(schema.user.adminType, ["Regular User", "Student Admin"]), - eq(schema.user.isDisabled, false), - gte(schema.userProfile.acceptedNumber, 0), - username ? ilike(schema.user.username, `%${username}%`) : undefined, - ) + const [totalRow] = await db.select({ value: count() }).from(schema.userProfile) - .innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where) - // top 只是「榜单取前 N 名」的上限,分页仍要在这 N 条之内生效: - // 否则 top=100 时每页都返回同样的 100 条,而 total 又是全量人数,翻页翻不动 - 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 - const rows = pageLimit === 0 ? [] : await 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)) + .innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)) + .where(leaderboardWhere) + const total = Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE) + + // 末页可能只剩不足 limit 条,越界页一条不剩 —— 后者直接不发 SQL + const pageLimit = Math.max(0, Math.min(limit, total - offset)) + const rows = pageLimit === 0 ? [] : await 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) - 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, user: sampleUser(user, profile.realName), acceptedNumber: profile.acceptedNumber, submissionNumber: profile.submissionNumber, 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) => { const start = c.req.query("start") diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index db31310..458cd9a 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -1,8 +1,10 @@ import { adminUserListSchema, + adminUserRankSchema, adminUserSchema, deleteUsersRequestSchema, importUsersRequestSchema, + rankProfileSchema, resetPasswordResponseSchema, updateUserRequestSchema, } from "@oj2/contract" @@ -13,7 +15,7 @@ import { Hono } from "hono" import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" import { db, schema } from "../../db" import { failure, success } from "../../http" -import { queryInteger } from "../helpers" +import { queryInteger, sampleUser } from "../helpers" export const adminAccountRoutes = new Hono() @@ -79,6 +81,51 @@ function selectUser(id: number) { .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) => { const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index bdc21e9..49ab240 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -139,6 +139,13 @@ export function batchTagProblems( }) } +// 用户排名(后台版,无 100 名上限;公开榜单是 oj/api.ts 的 getRank) +export function getAdminUserRank(offset: number, limit: number, keyword: string) { + return api2.get("admin/rankings/users", { + params: { offset, limit, keyword }, + }) +} + // 用户列表 export function getUserList( offset = 0, @@ -707,6 +714,7 @@ export function getPinnedAIReports() { import type { AdminAchievement, AchievementMetric as MetricOption, + AdminUserRank, } from "@oj2/contract" export type { AdminAchievement, MetricOption } diff --git a/apps/web/src/admin/setting/config.vue b/apps/web/src/admin/setting/config.vue index 8387af1..f4e9044 100644 --- a/apps/web/src/admin/setting/config.vue +++ b/apps/web/src/admin/setting/config.vue @@ -19,11 +19,6 @@ import { } from "../api" import { useUserStore } from "shared/store/user" -interface Testcase { - id: string - create_time: string -} - const message = useMessage() const configStore = useConfigStore() const userStore = useUserStore() @@ -41,7 +36,7 @@ watch( { immediate: true }, ) -const testcaseColumns: DataTableColumn[] = [ +const testcaseColumns: DataTableColumn[] = [ { title: "测试用例 ID", key: "id" }, { title: "选项", @@ -102,13 +97,13 @@ const serverColumns: DataTableColumn[] = [ { title: "服务器 URL", key: "serviceUrl", width: 200 }, { title: "上一次心跳", - key: "last_heartbeat", + key: "lastHeartbeat", render: (row) => parseTime(row.lastHeartbeat, "YYYY-MM-DD HH:mm:ss"), width: 120, }, { title: "创建时间", - key: "create_time", + key: "createTime", render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"), width: 120, }, diff --git a/apps/web/src/admin/setting/home.vue b/apps/web/src/admin/setting/home.vue index 4ba8a7a..272195d 100644 --- a/apps/web/src/admin/setting/home.vue +++ b/apps/web/src/admin/setting/home.vue @@ -2,12 +2,11 @@ import { h, onMounted, reactive, ref, watch } from "vue" import { useRouter } from "vue-router" import { NButton } from "naive-ui" -import { getRank } from "oj/api" import Pagination from "shared/components/Pagination.vue" import { useUserStore } from "shared/store/user" import { getACRate } from "utils/functions" import type { Rank } from "utils/types" -import { getBaseInfo, randomUser10 } from "../api" +import { getAdminUserRank, getBaseInfo, randomUser10 } from "../api" const userCount = ref(0) const submissionCount = ref(0) @@ -72,7 +71,7 @@ onMounted(async () => { async function listRanks() { 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 total.value = res.data.total } diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 7b13798..1c32095 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -164,15 +164,12 @@ export function getSubmissionStatistics( }) } -export function getRank( - offset: number, - limit: number, - n: number, - username?: string, -) { - return api2.get("rankings/users", { - params: { offset, limit, username, top: n }, - }) +/** + * 全服榜单。上限(100 名)由服务端定,调用方只管翻页 —— + * 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。 + */ +export function getRank(offset: number, limit: number) { + return api2.get("rankings/users", { params: { offset, limit } }) } export function getActivityRank(start: string) { diff --git a/apps/web/src/oj/rank/list.vue b/apps/web/src/oj/rank/list.vue index 8baab6f..87bcf5e 100644 --- a/apps/web/src/oj/rank/list.vue +++ b/apps/web/src/oj/rank/list.vue @@ -3,6 +3,7 @@ import type { ClassComparison, ClassRankItem as ClassRank, ClassUserRank, + MyRank, Rank, } from "utils/types" import { formatISO, sub, type Duration } from "date-fns" @@ -40,6 +41,10 @@ const userStore = useUserStore() const { isDesktop } = useBreakpoints() const data = ref([]) const total = ref(0) +/** 我的全服名次;未登录、教师/超管不入榜时为 null */ +const me = ref(null) +/** 我在前 100 名之外 —— 榜上高亮不到我,另起一行显示 */ +const meOffBoard = computed(() => !!me.value && me.value.rank > total.value) const query = reactive({ limit: 10, page: 1, @@ -145,12 +150,22 @@ async function analyzeSingleClassWithAI() { async function init() { 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 total.value = res.data.total + me.value = res.data.me 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[] = [ { title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"), @@ -178,6 +193,9 @@ const columns: DataTableColumn[] = [ }, () => row.user.username, ), + isMe(row) + ? h(Icon, { width: 20, icon: "fluent-emoji:person-raising-hand" }) + : null, h( NButton, { @@ -201,7 +219,7 @@ const columns: DataTableColumn[] = [ }, { title: renderTableTitle("已解决", "streamline-emojis:raised-fist-1"), - key: "accepted_number", + key: "acceptedNumber", width: 120, align: "center", }, @@ -210,7 +228,7 @@ const columns: DataTableColumn[] = [ "提交数", "streamline-ultimate-color:space-rocket-earth", ), - key: "submission_number", + key: "submissionNumber", width: 120, align: "center", }, @@ -247,8 +265,9 @@ async function listActivity() { })) } +// 「全服 Top10」就是同一个榜的第一页 —— 上限由服务端定,这里只要前 10 条 async function listRank() { - const res = await getRank(0, 10, 10) + const res = await getRank(0, 10) rankChart.value = res.data.results } @@ -509,13 +528,29 @@ watch( - + @@ -800,6 +835,10 @@ watch(