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:
@@ -164,15 +164,12 @@ export function getSubmissionStatistics(
|
||||
})
|
||||
}
|
||||
|
||||
export function getRank(
|
||||
offset: number,
|
||||
limit: number,
|
||||
n: number,
|
||||
username?: string,
|
||||
) {
|
||||
return api2.get<UserRank>("rankings/users", {
|
||||
params: { offset, limit, username, top: n },
|
||||
})
|
||||
/**
|
||||
* 全服榜单。上限(100 名)由服务端定,调用方只管翻页 ——
|
||||
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
|
||||
*/
|
||||
export function getRank(offset: number, limit: number) {
|
||||
return api2.get<UserRank>("rankings/users", { params: { offset, limit } })
|
||||
}
|
||||
|
||||
export function getActivityRank(start: string) {
|
||||
|
||||
@@ -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<Rank[]>([])
|
||||
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({
|
||||
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<Rank>[] = [
|
||||
{
|
||||
title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"),
|
||||
@@ -178,6 +193,9 @@ const columns: DataTableColumn<Rank>[] = [
|
||||
},
|
||||
() => 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<Rank>[] = [
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("已解决", "streamline-emojis:raised-fist-1"),
|
||||
key: "accepted_number",
|
||||
key: "acceptedNumber",
|
||||
width: 120,
|
||||
align: "center",
|
||||
},
|
||||
@@ -210,7 +228,7 @@ const columns: DataTableColumn<Rank>[] = [
|
||||
"提交数",
|
||||
"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(
|
||||
</n-grid>
|
||||
<n-card>
|
||||
<template #header>全服 Top100</template>
|
||||
<n-data-table :data="data" :columns="columns" />
|
||||
<n-data-table
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:row-class-name="rowClassName"
|
||||
/>
|
||||
<template #footer>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="query.page"
|
||||
v-model:limit="query.limit"
|
||||
/>
|
||||
<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
|
||||
:total="total"
|
||||
v-model:page="query.page"
|
||||
v-model:limit="query.limit"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-card>
|
||||
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
|
||||
@@ -800,6 +835,10 @@ watch(
|
||||
</template>
|
||||
|
||||
<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__content),
|
||||
.stat-total-ac :deep(.n-number-animation),
|
||||
|
||||
Reference in New Issue
Block a user