Files
OJ2/apps/web/src/oj/api.ts
T
xuyueandClaude Opus 5.5 e37ad982c0 revert(提交列表): 撤掉班级下拉里补「近半年有提交的班」那一版,下拉回到只用系统设定的班级列表
改成在右栏名字旁加「只看这个班」过滤按钮(下一条)。
This reverts commit 61ec7d7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:00:38 +08:00

553 lines
17 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 AiHintFeedbackRequest,
type Contest,
type ContestAccess,
type ContestList,
type ActivityRankItem,
type FormatCodeResponse,
type Metrics,
type TutorialSummary,
type ClassActivity,
type ClassBoard,
type ClassLesson,
type KnowledgeMap,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
type UserRank,
type WeeklyRank,
type ProblemRank,
type CreateSubmissionResponse,
type ProblemAuthor,
type ProblemListItem,
type YearlyAc,
type ProblemList,
type CreateFlowchartResponse,
type FlowchartScores,
type FlowchartList,
type FlowchartSubmission,
type AiDetail,
type DurationData,
type HeatmapItem,
type SolvedList,
type ProblemSet,
type ProblemSetBadge,
type ProblemSetList,
type ProblemSetProblem,
type ProblemSetProgressList,
type UserBadge,
type FlowchartStatistics,
type SubmissionStatistics,
type SubmissionStatisticsGrid,
type TodaySubmissionStatistics,
} from "@oj2/contract"
import api from "utils/api"
import { contract } from "utils/contract"
import { toProblemRow } from "oj/transforms"
import type {
Announcement,
AnnouncementListItem,
ContestRank,
Profile,
Message,
SubmissionListItem,
Exercise,
ProblemDetail,
ReactionKey,
ReactionState,
Submission,
SubmissionListPayload,
SubmitCodePayload,
OnlineCount,
Tutorial,
TutorialProgress,
} from "utils/types"
/**
* 题目详情 / 提交详情的 schema 按需加载,和请求并行发出。静态 import 会把 zod
* 运行时带进首页 —— 题目列表的 getProblemList 也在这个文件里。
* 闸门本身仍是同步的(等 schema 到了再返回),submissionDetailSchema 有 `.catch()`,
* 放行原文和解析结果不等价,所以不能像 /me 那样改成 contractDeferred。
*/
const loadDetailSchemas = () => import("./detailSchemas")
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
export function getOnlineCount() {
return api.get<OnlineCount>("site/online")
}
export async function getProblemList(
offset = 0,
limit = 10,
searchParams: Record<string, unknown> = {},
) {
const res = await api.get<ProblemList>("problems", {
params: { paging: true, offset, limit, ...searchParams },
})
return {
results: res.results.map(toProblemRow),
total: res.total,
}
}
export function getAuthors(all = false) {
return api.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 [{ problemDetailSchema }, response] = await Promise.all([
loadDetailSchemas(),
api.get<unknown>(endpoint),
])
// 形状即契约 —— 之前这里手抄了一份 camel→snake 的键名映射,抄漏一个字段就是
// 静默 undefined。走 `contract()` 而不是裸 `parse()`:原来是
// `problemDetailSchema.parse(v) as ProblemDetail`,`as` 把校验结果又断言回去、
// 等于没校验,而 `parse` 抛错会让整个题目页白屏。现在形状不符时记一条控制台
// 分歧再放行原始数据。
const problem: ProblemDetail = contract("GET /problems/:id", problemDetailSchema, response)
return problem
}
// 未登录返回 "0",登录后返回百分比字符串
export function getProblemBeatRate(problemID: number) {
return api.get<string>(`problems/${problemID}/beat-count`)
}
export async function getSubmission(id: string): Promise<Submission> {
const [{ submissionDetailSchema }, response] = await Promise.all([
loadDetailSchemas(),
api.get<unknown>(`submissions/${encodeURIComponent(id)}`),
])
return contract("GET /submissions/:id", submissionDetailSchema, response)
}
export function submitCode(data: SubmitCodePayload) {
return api.post<CreateSubmissionResponse>("submissions", data)
}
export function formatCode(data: { code: string; language: string }) {
const languages: Record<string, string> = {
Python: "python",
C: "c",
"C++": "cpp",
SQL: "sql",
}
return api.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 api.get<{ results: SubmissionListItem[]; total: number; unknownProblems: string[] }>(
endpoint,
{
// contestId 走的是路径,page 只有前端分页器用
params: { ...params, contestId: undefined, page: undefined },
},
)
}
export function getRankOfProblem(problemId: string) {
return api.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
}
export function getTodaySubmissionCount(language?: string) {
return api.get<number>("submissions/today-count", { params: { language } })
}
/** 「今日提交数」标签点开的统计。公开接口,口径同那颗标签:今天 + 非比赛提交 */
export function getTodaySubmissionStatistics() {
return api.get<TodaySubmissionStatistics>("submissions/today-statistics")
}
export function adminRejudge(id: string) {
return api.post<{ ok: boolean }>(`submissions/${encodeURIComponent(id)}/rejudge`)
}
export function getSubmissionStatistics(
duration: { start?: string; end: string },
problemDisplayId?: string,
username?: string,
/** 班级(user.class_name)精确匹配。别再拼 `ks231` 塞进 username —— 会连带 2311、2312 班 */
className?: string,
) {
return api.get<SubmissionStatistics>("submissions/statistics", {
params: { ...duration, problemDisplayId, username, className },
})
}
/** 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema) */
export function getSubmissionStatisticsGrid(
duration: { start?: string; end: string },
problemDisplayId?: string,
username?: string,
className?: string,
) {
return api.get<SubmissionStatisticsGrid>("submissions/statistics/grid", {
params: { ...duration, problemDisplayId, username, className },
})
}
/**
* 全服榜单。上限(100 名)由服务端定,调用方只管翻页 ——
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
*/
export function getRank(offset: number, limit: number) {
return api.get<UserRank>("rankings/users", { params: { offset, limit } })
}
export function getActivityRank(start: string) {
return api.get<ActivityRankItem[]>("rankings/activity", {
params: { start },
})
}
/**
* 本周进步榜。`scope` 只有两个取值,服务端认不出的一律当 global ——
* 班级榜要求调用者有班级,教师/超管拿到的是 400,所以别在没班级时切过去。
*/
export function getWeeklyRank(scope: "global" | "class") {
return api.get<WeeklyRank>("rankings/weekly", { params: { scope } })
}
/** 课堂看板(老师)。不给班级时后端猜最近两小时在交题的那个班 */
export function getClassBoard(className?: string) {
return api.get<ClassBoard>("classroom/board", { params: { className } })
}
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和看板一样 */
export function getClassLesson(className?: string) {
return api.get<ClassLesson>("classroom/lesson", { params: { className } })
}
/** 给这个班布置今天的题,空数组 = 清掉 */
export function setClassLesson(className: string, problemDisplayIds: string[]) {
return api.put<null>("classroom/lesson", { className, problemDisplayIds })
}
/** 我的知识点地图(只有自己的,不能查别人) */
export function getKnowledgeMap() {
return api.get<KnowledgeMap>("me/knowledge")
}
export function getClassActivity() {
return api.get<ClassActivity>("me/class-activity")
}
export function getClassRank(grade?: number | null) {
return api.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
}
export function getUserClassRank(scope?: "all" | "window", offset?: number, limit?: number) {
return api.get<ClassUserRank>("me/class-rank", {
params: { scope, offset, limit },
})
}
export function getClassPK(classNames: string[], startTime?: string, endTime?: string) {
return api.post<ClassComparisonResponse>("classes/comparison", {
classNames,
...(startTime ? { startTime } : {}),
...(endTime ? { endTime } : {}),
})
}
export function getContestList(query: {
offset: number
limit: number
keyword: string
status: string
tag: string
}) {
return api.get<ContestList>("contests", { params: query })
}
export function getContest(id: string) {
return api.get<Contest>(`contests/${encodeURIComponent(id)}`)
}
export function getContestAccess(id: string) {
return api.get<ContestAccess>(`contests/${encodeURIComponent(id)}/access`)
}
// 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch
export function checkContestPassword(contestID: string, password: string) {
return api.post<boolean>(`contests/${encodeURIComponent(contestID)}/access`, {
password,
})
}
export async function getContestProblems(contestID: string) {
const res = await api.get<ProblemListItem[]>(`contests/${encodeURIComponent(contestID)}/problems`)
return res.map(toProblemRow)
}
export function getContestRank(contestID: string, query: { limit: number; offset: number }) {
// submissionInfo 在契约里是 Record<string, unknown>(JSONB 原文),
// 前端在这里收窄成 ContestSubmissionInfo,见 utils/types 的 ContestRank
return api.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 api.post("me/avatar", form, {
headers: { "content-type": "multipart/form-data" },
})
}
export function updateProfile(data: { realName: string; mood: string }) {
return api.put<Profile>("me/profile", data)
}
export function getAnnouncementList(offset = 0, limit = 10) {
return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
params: { limit, offset },
})
}
export function getAnnouncement(id: number) {
return api.get<Announcement>(`announcements/${id}`)
}
export function getMessageList(offset = 0, limit = 10) {
// language 的收窄同 getSubmissions,见那里的说明
return api.get<{ results: Message[]; total: number }>("messages", {
params: { limit, offset },
})
}
export function getReaction(problemID: number) {
return api.get<ReactionState>(`problems/${problemID}/reaction`)
}
export function setReaction(problemID: number, type: ReactionKey) {
return api.post<ReactionState>(`problems/${problemID}/reaction`, { type })
}
export function getMetrics(userid: number) {
return api.get<Metrics>(`users/${userid}/metrics`)
}
export function getTutorial(id: number) {
return api.get<Tutorial>(`tutorials/${id}`)
}
export function getTutorials(type: "python" | "c") {
return api.get<TutorialSummary[]>("tutorials", { params: { type } })
}
export function getAIDetailData(start: string, end: string, username?: string) {
return api.get<AiDetail>("ai/detail", { params: { start, end, username } })
}
export function getAISolved(
start: string,
end: string,
offset: number,
limit: number,
username?: string,
) {
return api.get<SolvedList>("ai/solved", {
params: { start, end, offset, limit, username },
})
}
export function getAIDurationData(end: string, duration: string, username?: string) {
return api.get<DurationData[]>("ai/duration", {
params: { end, duration, username },
})
}
export function getAIHeatmapData(username?: string) {
return api.get<HeatmapItem[]>("ai/heatmap", {
params: username ? { username } : {},
})
}
export function getAIPinnedReport() {
return api.get<AiAnalysisRecord | null>("ai/pinned")
}
/** 学生评价一条 AI 提示。id 来自 /ai/hint 流的 done 事件,可以改票 */
export function submitHintFeedback(hintId: number, helpful: boolean) {
return api.post<null>(`ai/hint/${hintId}/feedback`, {
helpful,
} satisfies AiHintFeedbackRequest)
}
// ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) {
return api
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => response.map(toProblemRow))
}
export type { YearlyAc as YearlyACData } from "@oj2/contract"
export function getProblemYearlyAC(problemId: string) {
return api.get<YearlyAc[]>(`problems/${encodeURIComponent(problemId)}/yearly-ac`)
}
// ==================== 流程图相关API ====================
export function submitFlowchart(data: {
problemId: number
mermaidCode: string
flowchartData: Record<string, unknown> // 压缩之后的,元数据太长了
}) {
return api.post<CreateFlowchartResponse>("flowcharts", data)
}
export function getFlowchartSubmission(id: string) {
return api.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
}
export function getFlowchartSubmissions(params: {
username?: string
/** "1" = username 整名匹配,同代码提交列表 */
exactUsername?: "1"
className?: string
problemDisplayId?: string
myself?: string
offset?: number
limit?: number
today?: string
grade?: string
}) {
return api.get<FlowchartList>("flowcharts", { params })
}
export function getFlowchartStatistics(
duration: { start?: string; end: string },
problemDisplayId?: string,
username?: string,
className?: string,
) {
return api.get<FlowchartStatistics>("flowcharts/statistics", {
params: { ...duration, problemDisplayId, username, className },
})
}
export function retryFlowchartSubmission(submissionId: string) {
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
}
/** 自己在这道题上评完的每一次(早的在前),加入题单之前的已经滤掉 */
export function getFlowchartScores(problemId: number) {
return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`)
}
// ==================== 题单相关API ====================
export function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return api.get<ProblemSetList>("problem-sets", {
params: { offset, limit, keyword, difficulty, status },
})
}
export function getProblemSetDetail(id: number) {
return api.get<ProblemSet>(`problem-sets/${id}`)
}
export function getProblemSetProblems(problemSetId: number) {
return api.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
}
export function joinProblemSet(problemSetId: number) {
return api.post("problem-set-progress", { problemSetId })
}
export function getUserBadges(username?: string) {
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? "me")}/badges`)
}
export function getProblemSetBadges(problemSetId: number) {
return api.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 api.get<ProblemSetProgressList>(`problem-sets/${problemSetId}/user-progress`, { params })
}
export function getExercises(tutorialId: number): Promise<Exercise[]> {
return api.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
}
/**
* 上报一次练一练的作答。`answer` 是给老师看的一句人话(「选了 A、C」),
* 只在做错时才有意义,做对了不用带。
*
* 截到 200 字符再发:后端契约卡的就是 200,填空题填了一整段的话,
* 不截就是一个 400,而学生这边什么都看不见 —— 留痕失败得静悄悄的。
*/
export function reportExerciseAttempt(
exerciseId: number,
payload: { correct: boolean; answer?: string },
) {
return fetch(`/api/exercises/${exerciseId}/attempts`, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
correct: payload.correct,
answer: payload.answer?.slice(0, 200),
}),
}).catch(() => undefined)
}
export function getLearnProgress(type: "python" | "c") {
return api.get<TutorialProgress[]>("learn/progress", { params: { type } })
}
/**
* 上报自学留痕。`opened` 为真表示刚进这一课,否则只是补停留时长。
*
* 走裸 fetch 而不是 axios,是为了 `keepalive`:离开页面那一下的最后一次上报,
* axios 发出去也会随页面卸载被浏览器掐掉,学生每节课的最后一段时长就永远丢了。
* 失败一律吞掉 —— 留痕是旁路,不该让学生看到任何报错。
*/
export function reportLearnProgress(
tutorialId: number,
payload: { seconds: number; opened: boolean },
) {
return fetch(`/api/tutorials/${tutorialId}/progress`, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => undefined)
}