diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 90835ed..de96a68 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -1,54 +1,55 @@ 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 CreateFlowchartResponse, - type FlowchartCurrent, - type FlowchartDetail, - type FlowchartList, type FlowchartSubmission, type AiDetail, type DurationData, type HeatmapItem, type LoginSummary, type SolvedList, - type ProblemSet, - type ProblemSetBadge, - type ProblemSetList, - type ProblemSetProblem, - type ProblemSetProgressList, - type UserBadge, problemDetailSchema, problemListSchema, + problemListItemSchema, submissionDetailSchema, submissionListSchema, + submissionStatisticsSchema, + submissionStatisticsItemsSchema, onlineCountSchema, websiteConfigSchema, - type FlowchartStatistics, - type SubmissionStatistics, - type SubmissionStatisticsItems, + contestListSchema, + contestSchema, + contestAccessSchema, + contestRankSchema, + announcementListSchema, + announcementSchema, + problemSetListSchema, + problemSetSchema, + problemSetBadgeSchema, + userBadgeSchema, + tutorialSchema, + metricsSchema, + activityRankItemSchema, + problemRankSchema, + userRankSchema, + flowchartListSchema, + flowchartDetailSchema, + flowchartCurrentSchema, + flowchartStatisticsSchema, + problemSetProgressListSchema, + problemSetProblemSchema, } from "@oj2/contract" import api from "utils/api" import { contract } from "utils/contract" import { filterResult } from "oj/transforms" import type { - Announcement, - AnnouncementListItem, - ContestRank, Profile, Message, Exercise, @@ -57,7 +58,6 @@ import type { ReactionState, SubmissionListPayload, SubmitCodePayload, - Tutorial, TutorialProgress, } from "utils/types" @@ -183,8 +183,13 @@ async function getSubmissionPage( ) } -export function getRankOfProblem(problemId: string) { - return api.get(`problems/${encodeURIComponent(problemId)}/rank`) +export async function getRankOfProblem(problemId: string) { + const endpoint = `problems/${encodeURIComponent(problemId)}/rank` + return contract( + "GET /problems/:id/rank", + problemRankSchema, + await api.get(endpoint), + ) } export function getTodaySubmissionCount(language?: string) { @@ -201,38 +206,56 @@ export function adminRejudge(id: string) { * 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人, * 和上面那个按班级模糊匹配的不是一回事。 */ -export function getSubmissionStatisticsItems( +export async function getSubmissionStatisticsItems( duration: { start?: string; end: string }, username: string, problemID?: string, ) { - return api.get("submissions/statistics/items", { - params: { ...duration, problemId: problemID, username }, - }) + const endpoint = "submissions/statistics/items" + return contract( + "GET /submissions/statistics/items", + submissionStatisticsItemsSchema, + await api.get(endpoint, { + params: { ...duration, problemId: problemID, username }, + }), + ) } -export function getSubmissionStatistics( +export async function getSubmissionStatistics( duration: { start?: string; end: string }, problemID?: string, username?: string, ) { - return api.get("submissions/statistics", { - params: { ...duration, problemId: problemID, username }, - }) + const endpoint = "submissions/statistics" + return contract( + "GET /submissions/statistics", + submissionStatisticsSchema, + await api.get(endpoint, { + params: { ...duration, problemId: problemID, username }, + }), + ) } /** * 全服榜单。上限(100 名)由服务端定,调用方只管翻页 —— * 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。 */ -export function getRank(offset: number, limit: number) { - return api.get("rankings/users", { params: { offset, limit } }) +export async function getRank(offset: number, limit: number) { + const endpoint = "rankings/users" + return contract( + "GET /rankings/users", + userRankSchema, + await api.get(endpoint, { params: { offset, limit } }), + ) } -export function getActivityRank(start: string) { - return api.get("rankings/activity", { - params: { start }, - }) +export async function getActivityRank(start: string) { + const endpoint = "rankings/activity" + return contract( + "GET /rankings/activity", + activityRankItemSchema.array(), + await api.get(endpoint, { params: { start } }), + ) } export function getClassRank(grade?: number | null) { @@ -261,22 +284,37 @@ export function getClassPK( }) } -export function getContestList(query: { +export async function getContestList(query: { offset: number limit: number keyword: string status: string tag: string }) { - return api.get("contests", { params: query }) + const endpoint = "contests" + return contract( + "GET /contests", + contestListSchema, + await api.get(endpoint, { params: query }), + ) } -export function getContest(id: string) { - return api.get(`contests/${encodeURIComponent(id)}`) +export async function getContest(id: string) { + const endpoint = `contests/${encodeURIComponent(id)}` + return contract( + "GET /contests/:id", + contestSchema, + await api.get(endpoint), + ) } -export function getContestAccess(id: string) { - return api.get(`contests/${encodeURIComponent(id)}/access`) +export async function getContestAccess(id: string) { + const endpoint = `contests/${encodeURIComponent(id)}/access` + return contract( + "GET /contests/:id/access", + contestAccessSchema, + await api.get(endpoint), + ) } // 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch @@ -290,21 +328,29 @@ export function checkContestPassword(contestID: string, password: string) { } export async function getContestProblems(contestID: string) { - const res = await api.get( - `contests/${encodeURIComponent(contestID)}/problems`, + const endpoint = `contests/${encodeURIComponent(contestID)}/problems` + // 用 problemListItemSchema.array(),不是契约的 contestProblemsSchema —— + // 后者是 `array(union([列表项, 详情]))`,联合类型会让 filterResult 的类型收窄 + // 落到详情分支上,而且学生侧这条接口只下发列表项。 + const res = contract( + "GET /contests/:id/problems", + problemListItemSchema.array(), + await api.get(endpoint), ) return res.map(filterResult) } -export function getContestRank( +export async function getContestRank( contestID: string, query: { limit: number; offset: number }, ) { // submissionInfo 在契约里是 Record(JSONB 原文), // 前端在这里收窄成 SubmissionInfo,见 utils/types 的 ContestRank - return api.get<{ results: ContestRank[]; total: number }>( - `contests/${encodeURIComponent(contestID)}/rank`, - { params: query }, + const endpoint = `contests/${encodeURIComponent(contestID)}/rank` + return contract( + "GET /contests/:id/rank", + contestRankSchema, + await api.get(endpoint, { params: query }), ) } @@ -320,14 +366,22 @@ export function updateProfile(data: { realName: string; mood: string }) { return api.put("me/profile", data) } -export function getAnnouncementList(offset = 0, limit = 10) { - return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", { - params: { limit, offset }, - }) +export async function getAnnouncementList(offset = 0, limit = 10) { + const endpoint = "announcements" + return contract( + "GET /announcements", + announcementListSchema, + await api.get(endpoint, { params: { limit, offset } }), + ) } -export function getAnnouncement(id: number) { - return api.get(`announcements/${id}`) +export async function getAnnouncement(id: number) { + const endpoint = `announcements/${id}` + return contract( + "GET /announcements/:id", + announcementSchema, + await api.get(endpoint), + ) } export function getMessageList(offset = 0, limit = 10) { @@ -345,12 +399,22 @@ export function setReaction(problemID: number, type: ReactionKey) { return api.post(`problems/${problemID}/reaction`, { type }) } -export function getMetrics(userid: number) { - return api.get(`users/${userid}/metrics`) +export async function getMetrics(userid: number) { + const endpoint = `users/${userid}/metrics` + return contract( + "GET /users/:id/metrics", + metricsSchema, + await api.get(endpoint), + ) } -export function getTutorial(id: number) { - return api.get(`tutorials/${id}`) +export async function getTutorial(id: number) { + const endpoint = `tutorials/${id}` + return contract( + "GET /tutorials/:id", + tutorialSchema, + await api.get(endpoint), + ) } export function getTutorials(type: "python" | "c") { @@ -399,10 +463,14 @@ export function getAIPinnedReport() { // ==================== 相似题目推荐 ==================== -export function getSimilarProblems(problemId: string) { - return api - .get(`problems/${encodeURIComponent(problemId)}/similar`) - .then((response) => response.map(filterResult)) +export async function getSimilarProblems(problemId: string) { + const endpoint = `problems/${encodeURIComponent(problemId)}/similar` + const res = contract( + "GET /problems/:id/similar", + problemListItemSchema.array(), + await api.get(endpoint), + ) + return res.map(filterResult) } export type { YearlyAc as YearlyACData } from "@oj2/contract" @@ -427,7 +495,7 @@ export function getFlowchartSubmission(id: string) { return api.get(`flowcharts/${encodeURIComponent(id)}`) } -export function getFlowchartSubmissions(params: { +export async function getFlowchartSubmissions(params: { username?: string problemId?: string myself?: string @@ -436,17 +504,27 @@ export function getFlowchartSubmissions(params: { today?: string grade?: string }) { - return api.get("flowcharts", { params }) + const endpoint = "flowcharts" + return contract( + "GET /flowcharts", + flowchartListSchema, + await api.get(endpoint, { params }), + ) } -export function getFlowchartStatistics( +export async function getFlowchartStatistics( duration: { start?: string; end: string }, problemID?: string, username?: string, ) { - return api.get("flowcharts/statistics", { - params: { ...duration, problemId: problemID, username }, - }) + const endpoint = "flowcharts/statistics" + return contract( + "GET /flowcharts/statistics", + flowchartStatisticsSchema, + await api.get(endpoint, { + params: { ...duration, problemId: problemID, username }, + }), + ) } export function retryFlowchartSubmission(submissionId: string) { @@ -455,36 +533,59 @@ export function retryFlowchartSubmission(submissionId: string) { ) } -export function getCurrentProblemFlowchartSubmission(problemId: number) { - return api.get(`problems/${problemId}/flowchart/current`) +export async function getCurrentProblemFlowchartSubmission(problemId: number) { + const endpoint = `problems/${problemId}/flowchart/current` + return contract( + "GET /problems/:id/flowchart/current", + flowchartCurrentSchema, + await api.get(endpoint), + ) } -export function getFlowchartSubmissionDetail(problemId: number, page = 0) { - return api.get(`problems/${problemId}/flowchart/history`, { - params: { page }, - }) +export async function getFlowchartSubmissionDetail(problemId: number, page = 0) { + const endpoint = `problems/${problemId}/flowchart/history` + return contract( + "GET /problems/:id/flowchart/history", + flowchartDetailSchema, + await api.get(endpoint, { params: { page } }), + ) } // ==================== 题单相关API ==================== -export function getProblemSetList( +export async function getProblemSetList( offset = 0, limit = 10, keyword = "", difficulty = "", status = "", ) { - return api.get("problem-sets", { - params: { offset, limit, keyword, difficulty, status }, - }) + const endpoint = "problem-sets" + return contract( + "GET /problem-sets", + problemSetListSchema, + await api.get(endpoint, { + params: { offset, limit, keyword, difficulty, status }, + }), + ) } -export function getProblemSetDetail(id: number) { - return api.get(`problem-sets/${id}`) +export async function getProblemSetDetail(id: number) { + const endpoint = `problem-sets/${id}` + return contract( + "GET /problem-sets/:id", + problemSetSchema, + await api.get(endpoint), + ) } -export function getProblemSetProblems(problemSetId: number) { - return api.get(`problem-sets/${problemSetId}/problems`) +export async function getProblemSetProblems(problemSetId: number) { + const endpoint = `problem-sets/${problemSetId}/problems` + return contract( + "GET /problem-sets/:id/problems", + problemSetProblemSchema.array(), + await api.get(endpoint), + ) } export function joinProblemSet(problemSetId: number) { @@ -503,17 +604,25 @@ export function updateProblemSetProgress( }) } -export function getUserBadges(username?: string) { - return api.get( - `users/${encodeURIComponent(username ?? "me")}/badges`, +export async function getUserBadges(username?: string) { + const endpoint = `users/${encodeURIComponent(username ?? "me")}/badges` + return contract( + "GET /users/:username/badges", + userBadgeSchema.array(), + await api.get(endpoint), ) } -export function getProblemSetBadges(problemSetId: number) { - return api.get(`problem-sets/${problemSetId}/badges`) +export async function getProblemSetBadges(problemSetId: number) { + const endpoint = `problem-sets/${problemSetId}/badges` + return contract( + "GET /problem-sets/:id/badges", + problemSetBadgeSchema.array(), + await api.get(endpoint), + ) } -export function getProblemSetUserProgress( +export async function getProblemSetUserProgress( problemSetId: number, params?: { limit?: number @@ -522,9 +631,11 @@ export function getProblemSetUserProgress( completionStatus?: "" | "completed" | "in_progress" | "not_started" }, ) { - return api.get( - `problem-sets/${problemSetId}/user-progress`, - { params }, + const endpoint = `problem-sets/${problemSetId}/user-progress` + return contract( + "GET /problem-sets/:id/user-progress", + problemSetProgressListSchema, + await api.get(endpoint, { params }), ) } diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index a847adf..b9a73f4 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -113,17 +113,18 @@ export type { UploadTestCaseResponse } from "@oj2/contract" export type { ProblemTestCaseScore as Testcase } from "@oj2/contract" /** - * 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化: - * - `languages` / `template` 的键窄化成 LANGUAGE,组件按语言查模板要靠它 + * 题目详情。**直接取契约** —— `languages` / `template` 的收窄已经搬进 + * `problemDetailSchema`(`z.array(problemLanguageSchema)` 与 + * `z.partialRecord(problemLanguageSchema, …)`),这里原来那份 + * `Omit & {...}` 与契约**双向可赋值**, + * 即完全等价,是一层没有内容的重复(已用类型探针验证)。 + * + * 原来它还多挂三个可选字段(`hasAstRules` / `visible` / `answers`)。它们在 + * 契约里**本来就有**(`hasAstRules` 在题目列表项上、`ProblemDetail` 带的是 + * `astRequirements`),少数管理端调用点需要补充时应该就地声明自己的类型, + * 不该让一个全局别名对所有调用方声称这些字段存在。 */ -export type Problem = Omit & { - languages: LANGUAGE[] - template: { [key in LANGUAGE]?: string } - // oj 侧不下发 astRules 原文,只有 astRequirements(契约里就有) - hasAstRules?: boolean - visible?: boolean - answers?: { language: LANGUAGE; code: string }[] -} +export type Problem = ProblemDetail /** * AST 代码要求。原来这里手抄的那份少了 label / exact / outer / inner —— @@ -327,27 +328,16 @@ export type BlankContest = Omit< > /** - * acm_contest_rank.submission_info 的 JSONB 内容。**键名保持 snake_case** —— - * 判题按这套键名写进去,历史比赛的榜单行也是这个形状,不能跟着响应字段一起改名。 + * `acm_contest_rank.submission_info` 的 JSONB 内容。**形状已搬进契约** + * (`contestSubmissionInfoSchema`),这里保留别名给既有调用点。 */ -export interface SubmissionInfo { - is_ac: boolean - ac_time: number - is_first_ac: boolean - error_number: number - checked?: boolean -} +export type { ContestSubmissionInfo as SubmissionInfo } from "@oj2/contract" /** - * 榜单行。`submissionInfo` 的**内容**仍是 snake_case —— 它是 acm_contest_rank - * 表的 JSONB 原文,见 SubmissionInfo。 + * 榜单行。`submissionInfo` 的收窄(JSONB 原文的 snake_case 形状)已经搬进 + * `contestRankItemSchema`,这里不再需要 Omit + 覆盖。 */ -export type ContestRank = Omit< - import("@oj2/contract").ContestRankItem, - "submissionInfo" -> & { - submissionInfo: { [key: string]: SubmissionInfo } -} +export type ContestRank = import("@oj2/contract").ContestRankItem export type { WebsiteConfig, OnlineCount } from "@oj2/contract" @@ -399,12 +389,12 @@ export type { export type AnnouncementEdit = CreateAnnouncementRequest & { id: number } /** - * 站内信。取契约的形状,只把 `submission` 换成前端窄化过的那个 - * (statisticInfo / language 在契约里是 unknown,见 EmbeddedSubmission)。 + * 站内信。**直接取契约** —— 原来这里把 `submission` 换成前端窄化过的 + * `EmbeddedSubmission`,但那个窄化已经搬进 `embeddedSubmissionSchema` + * (statisticInfo / language 在契约里不再是 unknown),两者双向可赋值、 + * 完全等价,这层 Omit 已经没有内容。 */ -export type Message = Omit & { - submission: EmbeddedSubmission -} +export type Message = ContractMessage /** * 题目表情。三个类型都直接取自契约 —— 语义 key 必须与后端 reaction/models.py diff --git a/packages/contract/src/contest.ts b/packages/contract/src/contest.ts index fecd661..b73034b 100644 --- a/packages/contract/src/contest.ts +++ b/packages/contract/src/contest.ts @@ -29,13 +29,32 @@ export const contestPasswordRequestSchema = z.object({ export const contestAccessSchema = z.object({ access: z.boolean() }) export const contestProblemsSchema = z.array(z.union([problemListItemSchema, problemDetailSchema])) +/** + * `acm_contest_rank.submission_info` 的 JSONB 原文。 + * + * 键名是**判题链路写进去的 snake_case**(历史比赛的榜单行也是这个形状), + * 不要跟着响应字段一起改成 camelCase。字段全部可选:只有真正提交过的题目键 + * 才会出现,`checked` 更是前端在本地标「已看」时补的。 + * + * 原来契约这里是 `z.record(z.string(), z.unknown())`,于是前端不得不 + * 自己再声明一份 `SubmissionInfo` 去覆盖它(utils/types 的 ContestRank)。 + * 形状搬进来之后那个覆盖就没有内容了。 + */ +export const contestSubmissionInfoSchema = z.object({ + is_ac: z.boolean(), + ac_time: z.number(), + is_first_ac: z.boolean(), + error_number: z.number().int(), + checked: z.boolean().optional(), +}) + export const contestRankItemSchema = z.object({ id: z.number().int(), user: sampleUserSchema, submissionNumber: z.number().int(), acceptedNumber: z.number().int(), totalTime: z.number().int(), - submissionInfo: z.record(z.string(), z.unknown()), + submissionInfo: z.record(z.string(), contestSubmissionInfoSchema), contestId: z.number().int(), }) @@ -46,6 +65,7 @@ export type ContestList = z.infer export type ContestRankItem = z.infer export type ContestRank = z.infer export type ContestAccess = z.infer +export type ContestSubmissionInfo = z.infer export type ContestStatus = z.infer export type ContestPasswordRequest = z.infer