Deploy / deploy (push) Canceled after 0s
课上老师报题号、全班去找,是 2025 秋七成非比赛提交的来路,界面上原本没有它的入口。 - 顶栏(桌面端)加题号框,首页「找题」同一套逻辑:题号对上直接进题,否则去列表搜 - 新接口 GET /me/class-activity:同班同一天 ≥5 人做过的题,取最近 7 天里最近的一天, 附带我自己的状态与班里通过人数;首页据此显示「今天班里在做」卡片,没有就不占位 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
529 lines
16 KiB
TypeScript
529 lines
16 KiB
TypeScript
import {
|
||
type AiAnalysisRecord,
|
||
type AiHintFeedbackRequest,
|
||
type Contest,
|
||
type ContestAccess,
|
||
type ContestList,
|
||
type ActivityRankItem,
|
||
type FormatCodeResponse,
|
||
type Metrics,
|
||
type TutorialSummary,
|
||
type ClassActivity,
|
||
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 FlowchartCurrent,
|
||
type FlowchartDetail,
|
||
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 SubmissionStatisticsItems,
|
||
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 }>(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`)
|
||
}
|
||
|
||
/**
|
||
* 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人,
|
||
* 和上面那个按班级模糊匹配的不是一回事。
|
||
*/
|
||
export function getSubmissionStatisticsItems(
|
||
duration: { start?: string; end: string },
|
||
username: string,
|
||
problemDisplayId?: string,
|
||
) {
|
||
return api.get<SubmissionStatisticsItems>("submissions/statistics/items", {
|
||
params: { ...duration, problemDisplayId, username },
|
||
})
|
||
}
|
||
|
||
export function getSubmissionStatistics(
|
||
duration: { start?: string; end: string },
|
||
problemDisplayId?: string,
|
||
username?: string,
|
||
) {
|
||
return api.get<SubmissionStatistics>("submissions/statistics", {
|
||
params: { ...duration, problemDisplayId, username },
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 全服榜单。上限(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 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
|
||
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,
|
||
) {
|
||
return api.get<FlowchartStatistics>("flowcharts/statistics", {
|
||
params: { ...duration, problemDisplayId, username },
|
||
})
|
||
}
|
||
|
||
export function retryFlowchartSubmission(submissionId: string) {
|
||
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
|
||
}
|
||
|
||
export function getCurrentProblemFlowchartSubmission(problemId: number) {
|
||
return api.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
|
||
}
|
||
|
||
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
|
||
return api.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
|
||
params: { page },
|
||
})
|
||
}
|
||
|
||
// ==================== 题单相关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)
|
||
}
|