Files
OJ2/apps/web/src/oj/api.ts
yuetsh 78a2fb4fce 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>
2026-08-25 17:57:55 -06:00

486 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type AiAnalysisRecord,
type Contest as OjContest,
type ContestAccess,
type ContestList,
type ActivityRankItem,
type FormatCodeResponse,
type Metrics,
type TutorialSummary,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
type UserRank,
type ProblemRank,
type CreateSubmissionResponse,
type ProblemAuthor,
type ProblemListItem,
type YearlyAc,
type ProblemList,
type CreateFlowchartResponse,
type FlowchartCurrent,
type FlowchartDetail,
type FlowchartList,
type FlowchartSubmission,
type AiDetail,
type DurationData,
type HeatmapItem,
type LoginSummary,
type ProblemSet,
type ProblemSetBadge,
type ProblemSetList,
type ProblemSetProblem,
type ProblemSetProgressList,
type UserBadge,
problemDetailSchema,
submissionDetailSchema,
type FlowchartStatistics,
type SubmissionStatistics,
} from "@oj2/contract"
import api2 from "utils/api2"
import { filterResult } from "oj/transforms"
import type {
Announcement,
ContestRank,
Profile,
Message,
SubmissionListItem,
Exercise,
Problem,
ReactionKey,
ReactionState,
Submission,
SubmissionListPayload,
SubmitCodePayload,
WebsiteConfig,
Tutorial,
} from "utils/types"
/**
* 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的
* 键名映射,抄漏一个字段就是静默 undefined。
*/
function detailProblem(value: unknown): Problem {
return problemDetailSchema.parse(value) as Problem
}
export function getWebsiteConfig() {
return api2.get<WebsiteConfig>("site")
}
export async function getProblemList(
offset = 0,
limit = 10,
searchParams: Record<string, unknown> = {},
) {
const res = await api2.get<ProblemList>("problems", {
params: { paging: true, offset, limit, ...searchParams },
})
return {
results: res.data.results.map(filterResult),
total: res.data.total,
}
}
export function getAuthors(all = false) {
return api2.get<ProblemAuthor[]>("problem-authors", {
params: { all: all ? "1" : "0" },
})
}
export async function getProblem(problemID: string, contestID: string) {
const endpoint = contestID
? `contests/${encodeURIComponent(contestID)}/problems/${encodeURIComponent(problemID)}`
: `problems/${encodeURIComponent(problemID)}`
const response = await api2.get<unknown>(endpoint)
return { error: null, data: detailProblem(response.data) }
}
// 未登录返回 "0",登录后返回百分比字符串
export function getProblemBeatRate(problemID: number) {
return api2.get<string>(`problems/${problemID}/beat-count`)
}
export async function getSubmission(id: string) {
const response = await api2.get<unknown>(
`submissions/${encodeURIComponent(id)}`,
)
return {
error: null,
data: submissionDetailSchema.parse(response.data) as Submission,
}
}
export function submitCode(data: SubmitCodePayload) {
return api2.post<CreateSubmissionResponse>("submissions", data)
}
export function formatCode(data: { code: string; language: string }) {
const languages: Record<string, string> = {
Python3: "python",
C: "c",
"C++": "cpp",
SQL: "sql",
}
return api2.post<FormatCodeResponse>("code/format", {
code: data.code,
language: languages[data.language] ?? data.language.toLowerCase(),
})
}
export function getSubmissions(params: Partial<SubmissionListPayload>) {
const endpoint = params.contestId
? `contests/${encodeURIComponent(params.contestId)}/submissions`
: "submissions"
// 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让
// 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE
return api2.get<{ results: SubmissionListItem[]; total: number }>(endpoint, {
// contestId 走的是路径page 只有前端分页器用
params: { ...params, contestId: undefined, page: undefined },
})
}
export function getRankOfProblem(problemId: string) {
return api2.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
}
export function getTodaySubmissionCount(language?: string) {
return api2.get<number>("submissions/today-count", { params: { language } })
}
export function adminRejudge(id: string) {
return api2.post<{ ok: boolean }>(
`submissions/${encodeURIComponent(id)}/rejudge`,
)
}
export function getSubmissionStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
return api2.get<SubmissionStatistics>("submissions/statistics", {
params: { ...duration, problemId: problemID, username },
})
}
/**
* 全服榜单。上限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) {
return api2.get<ActivityRankItem[]>("rankings/activity", {
params: { start },
})
}
export function getClassRank(grade?: number | null) {
return api2.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
}
export function getUserClassRank(
scope?: "all" | "window",
offset?: number,
limit?: number,
) {
return api2.get<ClassUserRank>("me/class-rank", {
params: { scope, offset, limit },
})
}
export function getClassPK(
classNames: string[],
startTime?: string,
endTime?: string,
) {
return api2.post<ClassComparisonResponse>("classes/comparison", {
classNames,
...(startTime ? { startTime } : {}),
...(endTime ? { endTime } : {}),
})
}
export function getContestList(query: {
offset: number
limit: number
keyword: string
status: string
tag: string
}) {
return api2.get<ContestList>("contests", { params: query })
}
export function getContest(id: string) {
return api2.get<OjContest>(`contests/${encodeURIComponent(id)}`)
}
export function getContestAccess(id: string) {
return api2.get<ContestAccess>(`contests/${encodeURIComponent(id)}/access`)
}
// 注意和 GET /access 不一样:这个返回裸 true密码错是 403 走 catch
export function checkContestPassword(contestID: string, password: string) {
return api2.post<boolean>(
`contests/${encodeURIComponent(contestID)}/access`,
{
password,
},
)
}
export async function getContestProblems(contestID: string) {
const res = await api2.get<ProblemListItem[]>(
`contests/${encodeURIComponent(contestID)}/problems`,
)
return res.data.map(filterResult)
}
export function getContestRank(
contestID: string,
query: { limit: number; offset: number },
) {
// submissionInfo 在契约里是 Record<string, unknown>JSONB 原文),
// 前端在这里收窄成 SubmissionInfo见 utils/types 的 ContestRank
return api2.get<{ results: ContestRank[]; total: number }>(
`contests/${encodeURIComponent(contestID)}/rank`,
{ params: query },
)
}
export function uploadAvatar(file: File) {
const form = new window.FormData()
form.append("image", file)
return api2.post("me/avatar", form, {
headers: { "content-type": "multipart/form-data" },
})
}
export function updateProfile(data: { realName: string; mood: string }) {
return api2.put<Profile>("me/profile", data)
}
export function getAnnouncementList(offset = 0, limit = 10) {
return api2.get<{ results: Announcement[]; total: number }>("announcements", {
params: { limit, offset },
})
}
export function getAnnouncement(id: number) {
return api2.get<Announcement>(`announcements/${id}`)
}
export function createMessage(data: {
recipient: number
message: string
submission: string
}) {
return api2.post("messages", {
recipientId: data.recipient,
message: data.message,
submissionId: data.submission,
})
}
export function getMessageList(offset = 0, limit = 10) {
// language 的收窄同 getSubmissions见那里的说明
return api2.get<{ results: Message[]; total: number }>("messages", {
params: { limit, offset },
})
}
export function getReaction(problemID: number) {
return api2.get<ReactionState>(`problems/${problemID}/reaction`)
}
export function setReaction(problemID: number, type: ReactionKey) {
return api2.post<ReactionState>(`problems/${problemID}/reaction`, { type })
}
// TODO: 这个API有问题
export function refreshUserProblemDisplayIds() {
return api2.post("me/problem-display-ids/refresh")
}
export function getMetrics(userid: number) {
return api2.get<Metrics>(`users/${userid}/metrics`)
}
export function getTutorial(id: number) {
return api2.get<Tutorial>(`tutorials/${id}`)
}
export function getTutorials(type: "python" | "c") {
return api2.get<TutorialSummary[]>("tutorials", { params: { type } })
}
export function getAIDetailData(start: string, end: string, username?: string) {
return api2.get<AiDetail>("ai/detail", { params: { start, end, username } })
}
export function getAIDurationData(
end: string,
duration: string,
username?: string,
) {
return api2.get<DurationData[]>("ai/duration", {
params: { end, duration, username },
})
}
export function getAIHeatmapData(username?: string) {
return api2.get<HeatmapItem[]>("ai/heatmap", {
params: username ? { username } : {},
})
}
export function getAILoginSummary() {
return api2.get<LoginSummary>("ai/login-summary")
}
export function getAIPinnedReport() {
return api2.get<AiAnalysisRecord | null>("ai/pinned")
}
// ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) {
return api2
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => ({
...response,
data: response.data.map(filterResult),
}))
}
export type { YearlyAc as YearlyACData } from "@oj2/contract"
export function getProblemYearlyAC(problemId: string) {
return api2.get<YearlyAc[]>(
`problems/${encodeURIComponent(problemId)}/yearly-ac`,
)
}
// ==================== 流程图相关API ====================
export function submitFlowchart(data: {
problemId: number
mermaidCode: string
flowchartData: Record<string, unknown> // 压缩之后的,元数据太长了
}) {
return api2.post<CreateFlowchartResponse>("flowcharts", data)
}
export function getFlowchartSubmission(id: string) {
return api2.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
}
export function getFlowchartSubmissions(params: {
username?: string
problemId?: string
myself?: string
offset?: number
limit?: number
today?: string
grade?: string
}) {
return api2.get<FlowchartList>("flowcharts", { params })
}
export function getFlowchartStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
return api2.get<FlowchartStatistics>("flowcharts/statistics", {
params: { ...duration, problemId: problemID, username },
})
}
export function retryFlowchartSubmission(submissionId: string) {
return api2.post<{ status: string }>(
`flowcharts/${encodeURIComponent(submissionId)}/retry`,
)
}
export function getCurrentProblemFlowchartSubmission(problemId: number) {
return api2.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
}
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
return api2.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
params: { page },
})
}
// ==================== 题单相关API ====================
export function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return api2.get<ProblemSetList>("problem-sets", {
params: { offset, limit, keyword, difficulty, status },
})
}
export function getProblemSetDetail(id: number) {
return api2.get<ProblemSet>(`problem-sets/${id}`)
}
export function getProblemSetProblems(problemSetId: number) {
return api2.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
}
export function joinProblemSet(problemSetId: number) {
return api2.post("problem-set-progress", { problemSetId })
}
export function updateProblemSetProgress(
problemSetId: number,
problemId: number,
submissionId: string,
) {
return api2.put("problem-set-progress", {
problemSetId,
problemId,
submissionId,
})
}
export function getUserBadges(username?: string) {
return api2.get<UserBadge[]>(
`users/${encodeURIComponent(username ?? "me")}/badges`,
)
}
export function getProblemSetBadges(problemSetId: number) {
return api2.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
}
export function getProblemSetUserProgress(
problemSetId: number,
params?: {
limit?: number
offset?: number
className?: string
completionStatus?: "" | "completed" | "in_progress" | "not_started"
},
) {
return api2.get<ProblemSetProgressList>(
`problem-sets/${problemSetId}/user-progress`,
{ params },
)
}
export async function getExercises(tutorialId: number): Promise<Exercise[]> {
const res = await api2.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
return res.data
}