From 24af385f332dbecc2f1fe63bde50de711ccb1285 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 17 Sep 2026 01:53:37 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E6=8E=92=E5=90=8D):=20=E5=8A=A0=E6=9C=AC?= =?UTF-8?q?=E5=91=A8=E8=BF=9B=E6=AD=A5=E6=A6=9C=EF=BC=8C=E6=8C=89=E6=9C=AC?= =?UTF-8?q?=E5=91=A8=E9=A6=96=E6=AC=A1=20AC=20=E6=95=B0=E6=8E=92=EF=BC=8C?= =?UTF-8?q?=E6=AF=8F=E5=91=A8=E4=B8=80=E6=B8=85=E9=9B=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 存量榜(/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 --- apps/api/src/routes/account.ts | 123 ++++++++++++++++++++++++++ apps/api/src/time.ts | 13 +++ apps/web/src/oj/api.ts | 9 ++ apps/web/src/oj/rank/list.vue | 146 ++++++++++++++++++++++++++++++- apps/web/src/utils/types.ts | 3 + packages/contract/src/account.ts | 30 +++++++ 6 files changed, 323 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index 6c5d30d..74cc556 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -12,6 +12,8 @@ import { type ProblemRank, type RankProfile, type UserRank, + type WeeklyRank, + type WeeklyRankItem, } from "@oj2/contract" import { and, @@ -28,9 +30,11 @@ import { lte, min, ne, + notExists, or, sql, } from "drizzle-orm" +import { alias } from "drizzle-orm/pg-core" import { Hono } from "hono" import { hashPassword } from "../auth/password" @@ -42,6 +46,7 @@ import { failure, success } from "../http" import { JudgeStatus } from "../judge/status" import { getBooleanOption } from "../services/options" import { getUserProfileById } from "../services/profile" +import { weekStart } from "../time" import { isTeacherOrAbove, 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) => { const user = c.get("user")! const [problem] = await db diff --git a/apps/api/src/time.ts b/apps/api/src/time.ts index eb7635c..b1484cd 100644 --- a/apps/api/src/time.ts +++ b/apps/api/src/time.ts @@ -71,6 +71,19 @@ export function todayStart(now: Date | number | string = new Date()): string { ).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 { const wall = toWallClock(instant) diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 6d70f56..9845bdb 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -11,6 +11,7 @@ import { type ClassRankItem, type ClassUserRank, type UserRank, + type WeeklyRank, type ProblemRank, type CreateSubmissionResponse, type ProblemAuthor, @@ -209,6 +210,14 @@ export function getActivityRank(start: string) { }) } +/** + * 本周进步榜。`scope` 只有两个取值,服务端认不出的一律当 global —— + * 班级榜要求调用者有班级,教师/超管拿到的是 400,所以别在没班级时切过去。 + */ +export function getWeeklyRank(scope: "global" | "class") { + return api.get("rankings/weekly", { params: { scope } }) +} + export function getClassRank(grade?: number | null) { return api.get("rankings/classes", { params: { grade } }) } diff --git a/apps/web/src/oj/rank/list.vue b/apps/web/src/oj/rank/list.vue index a61df08..bbfad61 100644 --- a/apps/web/src/oj/rank/list.vue +++ b/apps/web/src/oj/rank/list.vue @@ -5,6 +5,7 @@ import type { ClassUserRank, MyRank, Rank, + WeeklyRankItem, } from "utils/types" import { formatISO, sub, type Duration } from "date-fns" import { NButton, NFlex } from "naive-ui" @@ -15,9 +16,10 @@ import { getRank, getUserClassRank, getClassPK, + getWeeklyRank, } from "oj/api" 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 { ChartType, LONG_DURATION_OPTIONS } from "utils/constants" import { renderTableTitle } from "utils/renders" @@ -72,6 +74,30 @@ const myClassQuery = reactive({ limit: 10, }) +/** + * 本周进步榜。默认落在**本班** —— 全服榜上中位学生仍然看不到自己,班内 30 个人的 + * 周榜才是「我这周排第几」有答案的那张。没有班级(教师、超管、没入班的账号)才退回全服。 + */ +const weeklyScope = ref<"global" | "class">("global") +const weeklyData = ref([]) +const weeklyMe = ref(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 classDetailData = ref(null) const classDetailLoading = ref(false) @@ -291,6 +317,55 @@ const subOptions = computed( durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!, ) +const weeklyColumns: DataTableColumn[] = [ + { + 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(() => { // 「全服 Top10」就是榜单第一页的前 10 条:挂载时 init() 取的正是 offset=0&limit=10, // 再单发一次一模一样的 /rankings/users 只会让这张图排在日活后面出来。 @@ -300,6 +375,7 @@ onMounted(() => { listActivity() listClassRank() listMyClassRank() + initWeeklyRank() }) const classColumns: DataTableColumn[] = [ @@ -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( () => classQuery.grade, () => { @@ -532,6 +622,60 @@ watch( + + + + + + + +