Files
OJ2/apps/web/src/oj/api.ts
T
xuyueandClaude Opus 5.5 3c6e4a1e7c
Deploy / deploy (push) Canceled after 0s
feat(排名): 班级详情改成右侧抽屉、老师能在里面直接出 AI 分析;排名页加上个人智能分析入口;用词改成前一名 / 后一名
- 班级对抗点班名打开班级详情:人均做对和年级比、中间那位同学、做对过的人、全班分布点阵、
  每周人均新做对;去掉四分位数、标准差、综合分。老师多看到「要多关心的同学」(这学期做对不到
  3 道)和 AI 分析,报告直接在抽屉里出
- 班级 AI 分析只传班号,数字由服务端按抽屉同一套现算(services/ranking.ts),报告和抽屉对得上
- 学生「你」那张卡底下加「看我的智能分析」;老师名单「⋯」加「看他的智能分析」;班级对抗右上角
  「班级 PK ›」
- 「要追的 / 追你 / 冲得最猛 / 对手」会被学生读歪,改成「前一名 / 后一名 / 进步最大」,
  对前后一名只说「比你早 / 晚几秒做到几道」
- 老师打开排名页默认看全部,班级下拉框第一项「全部」,选了班就切到本班

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:58:52 +08:00

589 lines
19 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 ContestClassView,
type ContestScoreboard,
type FormatCodeResponse,
type Metrics,
type TutorialSummary,
type ClassActivity,
type LastVisit,
type ClassBoard,
type ClassLesson,
type KnowledgeMap,
type ClassComparisonResponse,
type ClassBattleItem,
type ClassDetail,
type RankBoard,
type RankPeriod,
type RankScope,
type WeeklyChampion,
type WeeklyRank,
type ProblemRank,
type CreateSubmissionResponse,
type ProblemAuthor,
type ProblemListItem,
type ProblemStats,
type ProblemList,
type ProblemProgress,
type CreateFlowchartResponse,
type FlowchartScores,
type FlowchartList,
type FlowchartSubmission,
type AiDetail,
type DurationData,
type HeatmapItem,
type SolvedList,
type ProblemSet,
type ProblemSetClassView,
type ProblemSetList,
type ProblemSetLock,
type ProblemSetProblem,
type UserBadge,
type FlowchartStatistics,
type SubmissionStatistics,
type SubmissionLessons,
type LessonLanguage,
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,
Profile,
Message,
SubmissionListItem,
Exercise,
ProblemDetail,
ReactionKey,
ReactionState,
Submission,
SubmissionListPayload,
SubmitCodePayload,
Tutorial,
TutorialProgress,
} from "utils/types"
/**
* 题目详情 / 提交详情的 schema 按需加载,和请求并行发出。静态 import 会把 zod
* 运行时带进首页 —— 题目列表的 getProblemList 也在这个文件里。
* 闸门本身仍是同步的(等 schema 到了再返回),submissionDetailSchema 有 `.catch()`,
* 放行原文和解析结果不等价,所以不能像 /me 那样改成 contractDeferred。
*/
const loadDetailSchemas = () => import("./detailSchemas")
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,
}
}
/** 题目列表顶上那行和左栏的进度;没登录是 null */
export function getProblemProgress() {
return api.get<ProblemProgress | null>("problems/progress")
}
/**
* 「随便来一道」:当前知识点和类型里,先挑能用 language 做的、自己没做对的简单 / 中等题,
* 返回题号。空串的参数拦截器会去掉
*/
export function getRandomProblem(params: { tag: string; type: string; language: string }) {
return api.get<string>("problems/random", { params })
}
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
}
/** 题目页「统计」页签。problemID 是内部题号(problem.id),比赛题也能查 */
export function getProblemStats(problemID: number) {
return api.get<ProblemStats>(`problems/${problemID}/stats`)
}
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; problemId: number }) {
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(),
problemId: data.problemId,
})
}
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 },
})
}
/** 统计「一行一节课」的总览(没选班、没填学生和题号时)。limit 是列几节课,「再往前看」加它 */
export function getSubmissionLessons(duration: { start?: string; end: string }, limit?: number) {
return api.get<SubmissionLessons>("submissions/statistics/lessons", {
params: { ...duration, limit },
})
}
/** 统计页的方块串:范围内每个学生的每一次提交(口径见契约 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 },
})
}
/**
* 排名页的榜:本班 / 本年级 / 全服 × 这周 / 这学期 / 全部。学生看自己的班和年级,
* 老师用 `className` 选班(不给就是最近上课的班)。`full` 把本年级 / 全服中间折起来的那段也要回来
*/
export function getRankBoard(
scope: RankScope,
period: RankPeriod,
options: { className?: string; full?: boolean } = {},
) {
return api.get<RankBoard>("rankings/board", {
params: {
scope,
period,
className: options.className || undefined,
full: options.full ? 1 : undefined,
},
})
}
/** 班级对抗:全服各班这学期人均做对,外加这周人均涨了多少 */
export function getClassBattle() {
return api.get<ClassBattleItem[]>("rankings/classes")
}
/** 班级详情(抽屉):谁都能看,「要多关心的同学」只有老师拿得到 */
export function getClassDetail(className: string) {
return api.get<ClassDetail>("rankings/class-detail", { params: { className } })
}
/** 本班最近几周的每周冠军 */
export function getWeeklyChampions(className?: string) {
return api.get<WeeklyChampion[]>("rankings/champions", {
params: { className: className || undefined },
})
}
/** 老师把学生设成不计入排名 / 恢复 */
export function setRankHidden(userId: number, hidden: boolean) {
return api.put<null>(`rankings/hidden/${userId}`, { hidden })
}
/**
* 本周进步榜。`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[],
language: LessonLanguage | null,
) {
return api.put<null>("classroom/lesson", { className, problemDisplayIds, language })
}
/** 我的知识点地图(只有自己的,不能查别人) */
export function getKnowledgeMap() {
return api.get<KnowledgeMap>("me/knowledge")
}
export function getClassActivity() {
return api.get<ClassActivity>("me/class-activity")
}
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
export function getLastVisit() {
return api.get<LastVisit>("me/last-visit")
}
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
/** 只看我交过题的 */
joined?: boolean
}) {
return api.get<ContestList>("contests", {
params: { ...query, joined: query.joined ? "1" : undefined },
})
}
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 getContestScoreboard(contestID: string) {
return api.get<ContestScoreboard>(`contests/${encodeURIComponent(contestID)}/scoreboard`)
}
export function getContestClassView(contestID: string) {
return api.get<ContestClassView>(`contests/${encodeURIComponent(contestID)}/class-view`)
}
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))
}
// ==================== 流程图相关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 = "") {
return api.get<ProblemSetList>("problem-sets", { params: { offset, limit, keyword } })
}
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 getProblemSetClassView(problemSetId: number, className = "") {
return api.get<ProblemSetClassView>(`problem-sets/${problemSetId}/class-view`, {
params: className ? { className } : {},
})
}
/** 这道题以前的代码为什么藏着:落在哪些正在布置的题单里。传主键或题号都行 */
export function getProblemSetLocks(problem: { id: number } | { displayId: string }) {
return api.get<ProblemSetLock[]>("problem-sets/locks", {
params: "id" in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId },
})
}
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)
}