起因是 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>
486 lines
13 KiB
TypeScript
486 lines
13 KiB
TypeScript
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
|
||
}
|