存量榜(/rankings/users、班级榜)排的都是 user_profile 的 AC 总数,名次几乎不动, 中位学生看一眼就知道追不上 —— 榜单在那批人身上是负反馈。这张榜的分母换成「这一周」, 每周一 0:00(东八区)清零,谁都可能进前十。 口径是**本周首次 AC 的题目数**,不是「本周 AC 过的去重题数」:后者把上周就做出来的题 重交一遍也算成绩,一分钟能刷满一屏。靠 NOT EXISTS 排掉本周之前已通过的 (user, problem) 对,四个条件正好是 submission_public_metrics_idx 的全部列。 - time.ts 加 weekStart():localWeekday 的 0 是周日,要先折成 7,否则周日单独成一周 - GET /rankings/weekly?scope=global|class,入榜人群与全服榜一致(教师/超管不参与) - 前端默认落在本班 —— 全服周榜上中位学生仍然看不到自己,班内 30 人那张才有答案 - 本周一题没做出来时 me 是 null,footer 那句「做出 1 题就能上榜」照样出现: 它是说给还没上榜的人听的,而那正是最需要被推一把的一批 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,8 @@ import {
|
|||||||
type ProblemRank,
|
type ProblemRank,
|
||||||
type RankProfile,
|
type RankProfile,
|
||||||
type UserRank,
|
type UserRank,
|
||||||
|
type WeeklyRank,
|
||||||
|
type WeeklyRankItem,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
@@ -28,9 +30,11 @@ import {
|
|||||||
lte,
|
lte,
|
||||||
min,
|
min,
|
||||||
ne,
|
ne,
|
||||||
|
notExists,
|
||||||
or,
|
or,
|
||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
import { alias } from "drizzle-orm/pg-core"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { hashPassword } from "../auth/password"
|
import { hashPassword } from "../auth/password"
|
||||||
@@ -42,6 +46,7 @@ import { failure, success } from "../http"
|
|||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus } from "../judge/status"
|
||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
import { getUserProfileById } from "../services/profile"
|
import { getUserProfileById } from "../services/profile"
|
||||||
|
import { weekStart } from "../time"
|
||||||
import {
|
import {
|
||||||
isTeacherOrAbove,
|
isTeacherOrAbove,
|
||||||
objectValue,
|
objectValue,
|
||||||
@@ -394,6 +399,124 @@ accountRoutes.get("/rankings/activity", async (c) => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 周榜的榜面大小。**存量榜(`/rankings/users`)解决的是「谁最强」,周榜解决的是
|
||||||
|
* 「这一周谁在往前走」** —— 后者每周一清零,所以榜面短一点更像「这周的头名」,
|
||||||
|
* 长了反而又变成一张追不上的总表。榜外的人靠 `me` 单独看到自己的名次。
|
||||||
|
*/
|
||||||
|
const WEEKLY_BOARD_SIZE = 10
|
||||||
|
|
||||||
|
/** 算「解决」的两个状态:AST_CHECK_FAILED 也是答案对了,与 /rankings/activity 同口径 */
|
||||||
|
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`
|
||||||
|
* (user_id, problem_id, result, create_time,WHERE contest_id IS NULL)的全部列,
|
||||||
|
* 而且外层已经把行数收在「本周的 AC」这一小撮上,不会退化成按人全表回查。
|
||||||
|
*/
|
||||||
|
accountRoutes.get("/rankings/weekly", optionalAuth, async (c) => {
|
||||||
|
const user = c.get("user")
|
||||||
|
const scope = c.req.query("scope") === "class" ? "class" : "global"
|
||||||
|
const className = scope === "class" ? (user?.className ?? null) : null
|
||||||
|
if (scope === "class" && !className)
|
||||||
|
return failure(c, 400, "class-missing", "用户没有班级信息")
|
||||||
|
|
||||||
|
const start = weekStart()
|
||||||
|
|
||||||
|
// 入榜人群与全服榜一致(leaderboardWhere):正常状态的学生与学生管理员
|
||||||
|
const audience = and(
|
||||||
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
className ? eq(schema.user.className, className) : undefined,
|
||||||
|
)
|
||||||
|
const thisWeek = and(
|
||||||
|
isNull(schema.submission.contestId),
|
||||||
|
gte(schema.submission.createTime, start),
|
||||||
|
audience,
|
||||||
|
)
|
||||||
|
|
||||||
|
const earlier = alias(schema.submission, "earlier")
|
||||||
|
const [solvedRows, submittedRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
userId: schema.submission.userId,
|
||||||
|
username: schema.user.username,
|
||||||
|
value: countDistinct(schema.submission.problemId),
|
||||||
|
})
|
||||||
|
.from(schema.submission)
|
||||||
|
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
thisWeek,
|
||||||
|
inArray(schema.submission.result, ACCEPTED_RESULTS),
|
||||||
|
notExists(
|
||||||
|
db
|
||||||
|
.select({ one: sql`1` })
|
||||||
|
.from(earlier)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(earlier.userId, schema.submission.userId),
|
||||||
|
eq(earlier.problemId, schema.submission.problemId),
|
||||||
|
isNull(earlier.contestId),
|
||||||
|
inArray(earlier.result, ACCEPTED_RESULTS),
|
||||||
|
lt(earlier.createTime, start),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.submission.userId, schema.user.username),
|
||||||
|
db
|
||||||
|
.select({ userId: schema.submission.userId, value: count() })
|
||||||
|
.from(schema.submission)
|
||||||
|
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||||
|
.where(thisWeek)
|
||||||
|
.groupBy(schema.submission.userId),
|
||||||
|
])
|
||||||
|
|
||||||
|
const submissions = new Map(
|
||||||
|
submittedRows.map((row) => [row.userId, row.value]),
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* 排序键与全服榜同构:解决多的在前 → 同解决数时提交少的在前 → 再同按 id。
|
||||||
|
* 第三档同样不是凑数,周榜上「都是 1 题」的学生成片存在,没有稳定兜底键时
|
||||||
|
* postgres 每次返回的顺序可以不同,刷新一下名次就变了。
|
||||||
|
*/
|
||||||
|
const ranked = solvedRows
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.value - a.value ||
|
||||||
|
(submissions.get(a.userId) ?? 0) - (submissions.get(b.userId) ?? 0) ||
|
||||||
|
a.userId - b.userId,
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(row, index) =>
|
||||||
|
({
|
||||||
|
user: sampleUser({ id: row.userId, username: row.username }, null),
|
||||||
|
solvedCount: row.value,
|
||||||
|
submissionCount: submissions.get(row.userId) ?? 0,
|
||||||
|
rank: index + 1,
|
||||||
|
}) satisfies WeeklyRankItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
return success(c, {
|
||||||
|
start,
|
||||||
|
scope,
|
||||||
|
className,
|
||||||
|
total: ranked.length,
|
||||||
|
results: ranked.slice(0, WEEKLY_BOARD_SIZE),
|
||||||
|
me: ranked.find((row) => row.user.id === user?.id) ?? null,
|
||||||
|
} satisfies WeeklyRank)
|
||||||
|
})
|
||||||
|
|
||||||
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const [problem] = await db
|
const [problem] = await db
|
||||||
|
|||||||
@@ -71,6 +71,19 @@ export function todayStart(now: Date | number | string = new Date()): string {
|
|||||||
).toISOString()
|
).toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「东八区本周一」的零点,返回 ISO 字符串。周榜按自然周清零,周一起算。
|
||||||
|
*
|
||||||
|
* `localWeekday` 的 0 是周日(跟 `Date#getDay()` 同一套编号),直接拿来减会把周日
|
||||||
|
* 算成「本周第一天」,于是周日一整天单独成一周、周一又清零一次 —— 所以先把 0 折成 7,
|
||||||
|
* 得到的 `weekday - 1` 才是「从本周一到今天过了几个日历日」。
|
||||||
|
*/
|
||||||
|
export function weekStart(now: Date | number | string = new Date()): string {
|
||||||
|
const today = dayNumber(calendarDay(now))
|
||||||
|
const weekday = localWeekday(today) || 7
|
||||||
|
return new Date((today - (weekday - 1)) * DAY_MS - OFFSET_MS).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
|
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
|
||||||
export function shiftMonthsByCalendar(instant: Date, months: number): Date {
|
export function shiftMonthsByCalendar(instant: Date, months: number): Date {
|
||||||
const wall = toWallClock(instant)
|
const wall = toWallClock(instant)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type ClassRankItem,
|
type ClassRankItem,
|
||||||
type ClassUserRank,
|
type ClassUserRank,
|
||||||
type UserRank,
|
type UserRank,
|
||||||
|
type WeeklyRank,
|
||||||
type ProblemRank,
|
type ProblemRank,
|
||||||
type CreateSubmissionResponse,
|
type CreateSubmissionResponse,
|
||||||
type ProblemAuthor,
|
type ProblemAuthor,
|
||||||
@@ -209,6 +210,14 @@ export function getActivityRank(start: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本周进步榜。`scope` 只有两个取值,服务端认不出的一律当 global ——
|
||||||
|
* 班级榜要求调用者有班级,教师/超管拿到的是 400,所以别在没班级时切过去。
|
||||||
|
*/
|
||||||
|
export function getWeeklyRank(scope: "global" | "class") {
|
||||||
|
return api.get<WeeklyRank>("rankings/weekly", { params: { scope } })
|
||||||
|
}
|
||||||
|
|
||||||
export function getClassRank(grade?: number | null) {
|
export function getClassRank(grade?: number | null) {
|
||||||
return api.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
|
return api.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ClassUserRank,
|
ClassUserRank,
|
||||||
MyRank,
|
MyRank,
|
||||||
Rank,
|
Rank,
|
||||||
|
WeeklyRankItem,
|
||||||
} from "utils/types"
|
} from "utils/types"
|
||||||
import { formatISO, sub, type Duration } from "date-fns"
|
import { formatISO, sub, type Duration } from "date-fns"
|
||||||
import { NButton, NFlex } from "naive-ui"
|
import { NButton, NFlex } from "naive-ui"
|
||||||
@@ -15,9 +16,10 @@ import {
|
|||||||
getRank,
|
getRank,
|
||||||
getUserClassRank,
|
getUserClassRank,
|
||||||
getClassPK,
|
getClassPK,
|
||||||
|
getWeeklyRank,
|
||||||
} from "oj/api"
|
} from "oj/api"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { durationFromValue, getACRate } from "utils/functions"
|
import { durationFromValue, getACRate, parseTime } from "utils/functions"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { ChartType, LONG_DURATION_OPTIONS } from "utils/constants"
|
import { ChartType, LONG_DURATION_OPTIONS } from "utils/constants"
|
||||||
import { renderTableTitle } from "utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
@@ -72,6 +74,30 @@ const myClassQuery = reactive({
|
|||||||
limit: 10,
|
limit: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本周进步榜。默认落在**本班** —— 全服榜上中位学生仍然看不到自己,班内 30 个人的
|
||||||
|
* 周榜才是「我这周排第几」有答案的那张。没有班级(教师、超管、没入班的账号)才退回全服。
|
||||||
|
*/
|
||||||
|
const weeklyScope = ref<"global" | "class">("global")
|
||||||
|
const weeklyData = ref<WeeklyRankItem[]>([])
|
||||||
|
const weeklyMe = ref<WeeklyRankItem | null>(null)
|
||||||
|
const weeklyTotal = ref(0)
|
||||||
|
const weeklyStart = ref("")
|
||||||
|
/**
|
||||||
|
* 我入不入这张榜。教师/超管本来就不参与排名(服务端 me 恒为 null),未登录同理 ——
|
||||||
|
* 这两种情况下 footer 那句「做出 1 题就能上榜」是说给不相干的人听的,不该出现。
|
||||||
|
*/
|
||||||
|
const weeklyMeEligible = computed(
|
||||||
|
() => userStore.isAuthed && !userStore.isTeacherOrAbove,
|
||||||
|
)
|
||||||
|
/** 我在榜面之外(或本周还没做出题)—— 榜上高亮不到我,footer 另起一行 */
|
||||||
|
const weeklyMeOffBoard = computed(
|
||||||
|
() =>
|
||||||
|
weeklyMeEligible.value &&
|
||||||
|
(!weeklyMe.value ||
|
||||||
|
!weeklyData.value.some((row) => row.rank === weeklyMe.value!.rank)),
|
||||||
|
)
|
||||||
|
|
||||||
const showClassDetailModal = ref(false)
|
const showClassDetailModal = ref(false)
|
||||||
const classDetailData = ref<ClassComparison | null>(null)
|
const classDetailData = ref<ClassComparison | null>(null)
|
||||||
const classDetailLoading = ref(false)
|
const classDetailLoading = ref(false)
|
||||||
@@ -291,6 +317,55 @@ const subOptions = computed<Duration>(
|
|||||||
durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!,
|
durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const weeklyColumns: DataTableColumn<WeeklyRankItem>[] = [
|
||||||
|
{
|
||||||
|
title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"),
|
||||||
|
key: "rank",
|
||||||
|
width: 80,
|
||||||
|
align: "center",
|
||||||
|
// rank 是服务端给的周榜名次,不是行号 —— 换算回 Index 要的 0 基下标
|
||||||
|
render: (row) => h(Index, { index: row.rank - 1, page: 1, limit: 10 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: renderTableTitle(
|
||||||
|
"用户",
|
||||||
|
"streamline-emojis:smiling-face-with-sunglasses",
|
||||||
|
),
|
||||||
|
key: "username",
|
||||||
|
minWidth: 160,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
text: true,
|
||||||
|
type: "info",
|
||||||
|
onClick: () => router.push("/user?name=" + row.user.username),
|
||||||
|
},
|
||||||
|
() => row.user.username,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: renderTableTitle("本周新解决", "fluent-emoji:party-popper"),
|
||||||
|
key: "solvedCount",
|
||||||
|
width: 120,
|
||||||
|
align: "center",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: renderTableTitle("本周提交", "streamline-emojis:rocket"),
|
||||||
|
key: "submissionCount",
|
||||||
|
width: 110,
|
||||||
|
align: "center",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function initWeeklyRank() {
|
||||||
|
if (!userStore.user) await userStore.getMyProfile()
|
||||||
|
// 有班级就默认看班内榜。改值会触发上面那个 watch 去取数,
|
||||||
|
// 这里再调一次 listWeeklyRank 就是重复发一次请求
|
||||||
|
if (userStore.user?.className) weeklyScope.value = "class"
|
||||||
|
else await listWeeklyRank()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 「全服 Top10」就是榜单第一页的前 10 条:挂载时 init() 取的正是 offset=0&limit=10,
|
// 「全服 Top10」就是榜单第一页的前 10 条:挂载时 init() 取的正是 offset=0&limit=10,
|
||||||
// 再单发一次一模一样的 /rankings/users 只会让这张图排在日活后面出来。
|
// 再单发一次一模一样的 /rankings/users 只会让这张图排在日活后面出来。
|
||||||
@@ -300,6 +375,7 @@ onMounted(() => {
|
|||||||
listActivity()
|
listActivity()
|
||||||
listClassRank()
|
listClassRank()
|
||||||
listMyClassRank()
|
listMyClassRank()
|
||||||
|
initWeeklyRank()
|
||||||
})
|
})
|
||||||
|
|
||||||
const classColumns: DataTableColumn<ClassRank>[] = [
|
const classColumns: DataTableColumn<ClassRank>[] = [
|
||||||
@@ -469,6 +545,20 @@ async function listMyClassRank() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listWeeklyRank() {
|
||||||
|
try {
|
||||||
|
const res = await getWeeklyRank(weeklyScope.value)
|
||||||
|
weeklyData.value = res.results
|
||||||
|
weeklyMe.value = res.me
|
||||||
|
weeklyTotal.value = res.total
|
||||||
|
weeklyStart.value = res.start
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(weeklyScope, listWeeklyRank)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => classQuery.grade,
|
() => classQuery.grade,
|
||||||
() => {
|
() => {
|
||||||
@@ -532,6 +622,60 @@ watch(
|
|||||||
</n-card>
|
</n-card>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
|
<n-card>
|
||||||
|
<template #header>
|
||||||
|
<n-flex align="center" :size="8">
|
||||||
|
<span>本周进步榜</span>
|
||||||
|
<n-text depth="3" style="font-size: 13px">
|
||||||
|
{{ weeklyStart ? parseTime(weeklyStart, "M月D日") + "起" : "" }} ·
|
||||||
|
每周一清零
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-select
|
||||||
|
v-if="userStore.user?.className"
|
||||||
|
style="width: 140px"
|
||||||
|
:options="[
|
||||||
|
{ label: '本班', value: 'class' },
|
||||||
|
{ label: '全服', value: 'global' },
|
||||||
|
]"
|
||||||
|
v-model:value="weeklyScope"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<n-data-table
|
||||||
|
v-if="weeklyData.length"
|
||||||
|
:data="weeklyData"
|
||||||
|
:columns="weeklyColumns"
|
||||||
|
:row-class-name="
|
||||||
|
(row: WeeklyRankItem) =>
|
||||||
|
weeklyMe && row.rank === weeklyMe.rank ? 'me-row' : ''
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<n-empty
|
||||||
|
v-else
|
||||||
|
style="padding: 20px 0"
|
||||||
|
description="这周还没有人解决新题目 —— 现在做出一题就是第一名"
|
||||||
|
/>
|
||||||
|
<!--
|
||||||
|
本周一题没做出来时 weeklyMe 是 null,这一行照样要出现:它是这张榜对
|
||||||
|
「还没上榜的人」说的话,而那恰好是最需要被推一把的那批学生。
|
||||||
|
-->
|
||||||
|
<template #footer v-if="weeklyMeOffBoard">
|
||||||
|
<n-tag type="info" round :bordered="false">
|
||||||
|
<template #icon>
|
||||||
|
<Icon width="18" icon="fluent-emoji:person-raising-hand" />
|
||||||
|
</template>
|
||||||
|
<template v-if="weeklyMe">
|
||||||
|
我这周第 {{ weeklyMe.rank }} 名(共 {{ weeklyTotal }} 人上榜)·
|
||||||
|
新解决 {{ weeklyMe.solvedCount }} 题
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
我这周还没有解决新题目,做出 1 题就能上榜
|
||||||
|
</template>
|
||||||
|
</n-tag>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
<n-card>
|
<n-card>
|
||||||
<template #header>全服 Top100</template>
|
<template #header>全服 Top100</template>
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
|
|||||||
@@ -305,6 +305,9 @@ export type Rank = RankProfile
|
|||||||
/** 榜单里「我」的位置:比 Rank 多一个全服名次 */
|
/** 榜单里「我」的位置:比 Rank 多一个全服名次 */
|
||||||
export type { MyRank } from "@oj2/contract"
|
export type { MyRank } from "@oj2/contract"
|
||||||
|
|
||||||
|
/** 本周进步榜:`rank` 是周榜名次,跟存量总榜的名次没有关系 */
|
||||||
|
export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract"
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ClassComparison,
|
ClassComparison,
|
||||||
ClassRankItem,
|
ClassRankItem,
|
||||||
|
|||||||
@@ -56,6 +56,34 @@ export const activityRankItemSchema = z.object({
|
|||||||
count: z.number().int().nonnegative(),
|
count: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 周榜的一行。`solvedCount` 是**本周首次 AC 的题目数** —— 不是「本周 AC 过的去重题数」。
|
||||||
|
* 后者会把上周就做出来的题重交一次也算成本周成绩,等于给刷榜留了个口子;
|
||||||
|
* 而周榜的全部意义是「这一周你往前走了多少」,只有首次通过才算往前走。
|
||||||
|
*/
|
||||||
|
export const weeklyRankItemSchema = z.object({
|
||||||
|
user: sampleUserSchema,
|
||||||
|
solvedCount: z.number().int().positive(),
|
||||||
|
submissionCount: z.number().int().nonnegative(),
|
||||||
|
rank: z.number().int().positive(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const weeklyRankSchema = z.object({
|
||||||
|
/** 本周一 0:00(东八区)对应的 UTC 时刻,前端拿它显示统计区间 */
|
||||||
|
start: z.string(),
|
||||||
|
scope: z.enum(["global", "class"]),
|
||||||
|
/** `scope = "class"` 时是我的班号,全服榜为 null */
|
||||||
|
className: z.string().nullable(),
|
||||||
|
/** 本周有新增 AC 的总人数。榜面只回前几名,这个数是全量 */
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
results: z.array(weeklyRankItemSchema),
|
||||||
|
/**
|
||||||
|
* 我这周的位置。**本周一题没做出来就是 null**,和「没登录」「身份不入榜」同一个值 ——
|
||||||
|
* 这三种情况前端都该显示「做出 1 题就能上榜」那句,不需要区分。
|
||||||
|
*/
|
||||||
|
me: weeklyRankItemSchema.nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
export const problemRankSchema = z.object({
|
export const problemRankSchema = z.object({
|
||||||
className: z.string(),
|
className: z.string(),
|
||||||
rank: z.number().int(),
|
rank: z.number().int(),
|
||||||
@@ -72,6 +100,8 @@ 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 MyRank = z.infer<typeof myRankSchema>
|
||||||
export type ActivityRankItem = z.infer<typeof activityRankItemSchema>
|
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>
|
export type Metrics = z.infer<typeof metricsSchema>
|
||||||
|
|
||||||
export type PublicProfile = z.infer<typeof publicProfileSchema>
|
export type PublicProfile = z.infer<typeof publicProfileSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user