refactor(契约): 契约闸门铺到其余学生端接口,并清掉与契约等价的重复类型
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
## 闸门覆盖 从 7 条扩到 20 条:竞赛列表/详情/口令/榜单、题单列表/详情/题目/徽章/进度、 全服榜与活跃榜、公告列表/详情、教程、用户度量、流程图历史/当前/统计、 提交统计与统计明细、相似题目、题目 AC 榜。 ## 顺手把两处形状收进契约 - `contestRankItemSchema.submissionInfo` 原来只是 `Record<string, unknown>`, 前端被迫再声明一份 `SubmissionInfo` 去覆盖它。现在 JSONB 的 snake_case 形状 (is_ac / ac_time / is_first_ac / error_number / checked)进了契约,那层覆盖随之消失。 - 竞赛题目列表用 `problemListItemSchema.array()` 而不是契约里的 `contestProblemsSchema` —— 后者是 `array(union([列表项, 详情]))`,联合类型会让 `filterResult` 的类型收窄落到详情分支,而且学生侧这条接口只下发列表项。 ## 清掉两处确证重复的派生 用类型探针(双向可赋值)逐对验证,`Problem` 与 `Message` 的本地派生与契约**完全等价**, 是没有内容的重复:`Problem` 的 languages/template 收窄、`Message` 的 submission 收窄 都已经在前两轮搬进契约。其余七处派生(Submission 的 result|9、Profile、AdminProblem、 Exercise、ContestRank、BlankProblem、TutorialEdit 等)探针确认**不等价**,都有真实理由, 一律保留。 `Problem` 原来还挂着 hasAstRules / visible / answers 三个可选字段,探针证明它们对 赋值没有影响(契约里本来就有 hasAstRules),去掉后没有任何调用点报错。 ## 验证 - vue-tsc 与 tsc -p apps/api 均 exit 0;vite build 通过;check:routes 无遮蔽; - 契约 schema 直跑真实接口:学生会话 13 通过 / 0 失败,管理员会话 15 通过 / 0 失败; 跳过的三条都是预期的权限或空数据(/submissions/statistics 与 /flowcharts/statistics 要教师权限、/users/:id/metrics 只统计公开提交)。
This commit is contained in:
@@ -1,54 +1,55 @@
|
|||||||
import {
|
import {
|
||||||
type AiAnalysisRecord,
|
type AiAnalysisRecord,
|
||||||
type Contest as OjContest,
|
|
||||||
type ContestAccess,
|
|
||||||
type ContestList,
|
|
||||||
type ActivityRankItem,
|
|
||||||
type FormatCodeResponse,
|
type FormatCodeResponse,
|
||||||
type Metrics,
|
|
||||||
type TutorialSummary,
|
type TutorialSummary,
|
||||||
type ClassComparisonResponse,
|
type ClassComparisonResponse,
|
||||||
type ClassRankItem,
|
type ClassRankItem,
|
||||||
type ClassUserRank,
|
type ClassUserRank,
|
||||||
type UserRank,
|
|
||||||
type ProblemRank,
|
|
||||||
type CreateSubmissionResponse,
|
type CreateSubmissionResponse,
|
||||||
type ProblemAuthor,
|
type ProblemAuthor,
|
||||||
type ProblemListItem,
|
|
||||||
type YearlyAc,
|
type YearlyAc,
|
||||||
type CreateFlowchartResponse,
|
type CreateFlowchartResponse,
|
||||||
type FlowchartCurrent,
|
|
||||||
type FlowchartDetail,
|
|
||||||
type FlowchartList,
|
|
||||||
type FlowchartSubmission,
|
type FlowchartSubmission,
|
||||||
type AiDetail,
|
type AiDetail,
|
||||||
type DurationData,
|
type DurationData,
|
||||||
type HeatmapItem,
|
type HeatmapItem,
|
||||||
type LoginSummary,
|
type LoginSummary,
|
||||||
type SolvedList,
|
type SolvedList,
|
||||||
type ProblemSet,
|
|
||||||
type ProblemSetBadge,
|
|
||||||
type ProblemSetList,
|
|
||||||
type ProblemSetProblem,
|
|
||||||
type ProblemSetProgressList,
|
|
||||||
type UserBadge,
|
|
||||||
problemDetailSchema,
|
problemDetailSchema,
|
||||||
problemListSchema,
|
problemListSchema,
|
||||||
|
problemListItemSchema,
|
||||||
submissionDetailSchema,
|
submissionDetailSchema,
|
||||||
submissionListSchema,
|
submissionListSchema,
|
||||||
|
submissionStatisticsSchema,
|
||||||
|
submissionStatisticsItemsSchema,
|
||||||
onlineCountSchema,
|
onlineCountSchema,
|
||||||
websiteConfigSchema,
|
websiteConfigSchema,
|
||||||
type FlowchartStatistics,
|
contestListSchema,
|
||||||
type SubmissionStatistics,
|
contestSchema,
|
||||||
type SubmissionStatisticsItems,
|
contestAccessSchema,
|
||||||
|
contestRankSchema,
|
||||||
|
announcementListSchema,
|
||||||
|
announcementSchema,
|
||||||
|
problemSetListSchema,
|
||||||
|
problemSetSchema,
|
||||||
|
problemSetBadgeSchema,
|
||||||
|
userBadgeSchema,
|
||||||
|
tutorialSchema,
|
||||||
|
metricsSchema,
|
||||||
|
activityRankItemSchema,
|
||||||
|
problemRankSchema,
|
||||||
|
userRankSchema,
|
||||||
|
flowchartListSchema,
|
||||||
|
flowchartDetailSchema,
|
||||||
|
flowchartCurrentSchema,
|
||||||
|
flowchartStatisticsSchema,
|
||||||
|
problemSetProgressListSchema,
|
||||||
|
problemSetProblemSchema,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import api from "utils/api"
|
import api from "utils/api"
|
||||||
import { contract } from "utils/contract"
|
import { contract } from "utils/contract"
|
||||||
import { filterResult } from "oj/transforms"
|
import { filterResult } from "oj/transforms"
|
||||||
import type {
|
import type {
|
||||||
Announcement,
|
|
||||||
AnnouncementListItem,
|
|
||||||
ContestRank,
|
|
||||||
Profile,
|
Profile,
|
||||||
Message,
|
Message,
|
||||||
Exercise,
|
Exercise,
|
||||||
@@ -57,7 +58,6 @@ import type {
|
|||||||
ReactionState,
|
ReactionState,
|
||||||
SubmissionListPayload,
|
SubmissionListPayload,
|
||||||
SubmitCodePayload,
|
SubmitCodePayload,
|
||||||
Tutorial,
|
|
||||||
TutorialProgress,
|
TutorialProgress,
|
||||||
} from "utils/types"
|
} from "utils/types"
|
||||||
|
|
||||||
@@ -183,8 +183,13 @@ async function getSubmissionPage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRankOfProblem(problemId: string) {
|
export async function getRankOfProblem(problemId: string) {
|
||||||
return api.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
|
const endpoint = `problems/${encodeURIComponent(problemId)}/rank`
|
||||||
|
return contract(
|
||||||
|
"GET /problems/:id/rank",
|
||||||
|
problemRankSchema,
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTodaySubmissionCount(language?: string) {
|
export function getTodaySubmissionCount(language?: string) {
|
||||||
@@ -201,38 +206,56 @@ export function adminRejudge(id: string) {
|
|||||||
* 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人,
|
* 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人,
|
||||||
* 和上面那个按班级模糊匹配的不是一回事。
|
* 和上面那个按班级模糊匹配的不是一回事。
|
||||||
*/
|
*/
|
||||||
export function getSubmissionStatisticsItems(
|
export async function getSubmissionStatisticsItems(
|
||||||
duration: { start?: string; end: string },
|
duration: { start?: string; end: string },
|
||||||
username: string,
|
username: string,
|
||||||
problemID?: string,
|
problemID?: string,
|
||||||
) {
|
) {
|
||||||
return api.get<SubmissionStatisticsItems>("submissions/statistics/items", {
|
const endpoint = "submissions/statistics/items"
|
||||||
params: { ...duration, problemId: problemID, username },
|
return contract(
|
||||||
})
|
"GET /submissions/statistics/items",
|
||||||
|
submissionStatisticsItemsSchema,
|
||||||
|
await api.get<unknown>(endpoint, {
|
||||||
|
params: { ...duration, problemId: problemID, username },
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSubmissionStatistics(
|
export async function getSubmissionStatistics(
|
||||||
duration: { start?: string; end: string },
|
duration: { start?: string; end: string },
|
||||||
problemID?: string,
|
problemID?: string,
|
||||||
username?: string,
|
username?: string,
|
||||||
) {
|
) {
|
||||||
return api.get<SubmissionStatistics>("submissions/statistics", {
|
const endpoint = "submissions/statistics"
|
||||||
params: { ...duration, problemId: problemID, username },
|
return contract(
|
||||||
})
|
"GET /submissions/statistics",
|
||||||
|
submissionStatisticsSchema,
|
||||||
|
await api.get<unknown>(endpoint, {
|
||||||
|
params: { ...duration, problemId: problemID, username },
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全服榜单。上限(100 名)由服务端定,调用方只管翻页 ——
|
* 全服榜单。上限(100 名)由服务端定,调用方只管翻页 ——
|
||||||
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
|
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
|
||||||
*/
|
*/
|
||||||
export function getRank(offset: number, limit: number) {
|
export async function getRank(offset: number, limit: number) {
|
||||||
return api.get<UserRank>("rankings/users", { params: { offset, limit } })
|
const endpoint = "rankings/users"
|
||||||
|
return contract(
|
||||||
|
"GET /rankings/users",
|
||||||
|
userRankSchema,
|
||||||
|
await api.get<unknown>(endpoint, { params: { offset, limit } }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActivityRank(start: string) {
|
export async function getActivityRank(start: string) {
|
||||||
return api.get<ActivityRankItem[]>("rankings/activity", {
|
const endpoint = "rankings/activity"
|
||||||
params: { start },
|
return contract(
|
||||||
})
|
"GET /rankings/activity",
|
||||||
|
activityRankItemSchema.array(),
|
||||||
|
await api.get<unknown>(endpoint, { params: { start } }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClassRank(grade?: number | null) {
|
export function getClassRank(grade?: number | null) {
|
||||||
@@ -261,22 +284,37 @@ export function getClassPK(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContestList(query: {
|
export async function getContestList(query: {
|
||||||
offset: number
|
offset: number
|
||||||
limit: number
|
limit: number
|
||||||
keyword: string
|
keyword: string
|
||||||
status: string
|
status: string
|
||||||
tag: string
|
tag: string
|
||||||
}) {
|
}) {
|
||||||
return api.get<ContestList>("contests", { params: query })
|
const endpoint = "contests"
|
||||||
|
return contract(
|
||||||
|
"GET /contests",
|
||||||
|
contestListSchema,
|
||||||
|
await api.get<unknown>(endpoint, { params: query }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContest(id: string) {
|
export async function getContest(id: string) {
|
||||||
return api.get<OjContest>(`contests/${encodeURIComponent(id)}`)
|
const endpoint = `contests/${encodeURIComponent(id)}`
|
||||||
|
return contract(
|
||||||
|
"GET /contests/:id",
|
||||||
|
contestSchema,
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContestAccess(id: string) {
|
export async function getContestAccess(id: string) {
|
||||||
return api.get<ContestAccess>(`contests/${encodeURIComponent(id)}/access`)
|
const endpoint = `contests/${encodeURIComponent(id)}/access`
|
||||||
|
return contract(
|
||||||
|
"GET /contests/:id/access",
|
||||||
|
contestAccessSchema,
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch
|
// 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch
|
||||||
@@ -290,21 +328,29 @@ export function checkContestPassword(contestID: string, password: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getContestProblems(contestID: string) {
|
export async function getContestProblems(contestID: string) {
|
||||||
const res = await api.get<ProblemListItem[]>(
|
const endpoint = `contests/${encodeURIComponent(contestID)}/problems`
|
||||||
`contests/${encodeURIComponent(contestID)}/problems`,
|
// 用 problemListItemSchema.array(),不是契约的 contestProblemsSchema ——
|
||||||
|
// 后者是 `array(union([列表项, 详情]))`,联合类型会让 filterResult 的类型收窄
|
||||||
|
// 落到详情分支上,而且学生侧这条接口只下发列表项。
|
||||||
|
const res = contract(
|
||||||
|
"GET /contests/:id/problems",
|
||||||
|
problemListItemSchema.array(),
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
)
|
)
|
||||||
return res.map(filterResult)
|
return res.map(filterResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContestRank(
|
export async function getContestRank(
|
||||||
contestID: string,
|
contestID: string,
|
||||||
query: { limit: number; offset: number },
|
query: { limit: number; offset: number },
|
||||||
) {
|
) {
|
||||||
// submissionInfo 在契约里是 Record<string, unknown>(JSONB 原文),
|
// submissionInfo 在契约里是 Record<string, unknown>(JSONB 原文),
|
||||||
// 前端在这里收窄成 SubmissionInfo,见 utils/types 的 ContestRank
|
// 前端在这里收窄成 SubmissionInfo,见 utils/types 的 ContestRank
|
||||||
return api.get<{ results: ContestRank[]; total: number }>(
|
const endpoint = `contests/${encodeURIComponent(contestID)}/rank`
|
||||||
`contests/${encodeURIComponent(contestID)}/rank`,
|
return contract(
|
||||||
{ params: query },
|
"GET /contests/:id/rank",
|
||||||
|
contestRankSchema,
|
||||||
|
await api.get<unknown>(endpoint, { params: query }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,14 +366,22 @@ export function updateProfile(data: { realName: string; mood: string }) {
|
|||||||
return api.put<Profile>("me/profile", data)
|
return api.put<Profile>("me/profile", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
export async function getAnnouncementList(offset = 0, limit = 10) {
|
||||||
return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
|
const endpoint = "announcements"
|
||||||
params: { limit, offset },
|
return contract(
|
||||||
})
|
"GET /announcements",
|
||||||
|
announcementListSchema,
|
||||||
|
await api.get<unknown>(endpoint, { params: { limit, offset } }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnnouncement(id: number) {
|
export async function getAnnouncement(id: number) {
|
||||||
return api.get<Announcement>(`announcements/${id}`)
|
const endpoint = `announcements/${id}`
|
||||||
|
return contract(
|
||||||
|
"GET /announcements/:id",
|
||||||
|
announcementSchema,
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMessageList(offset = 0, limit = 10) {
|
export function getMessageList(offset = 0, limit = 10) {
|
||||||
@@ -345,12 +399,22 @@ export function setReaction(problemID: number, type: ReactionKey) {
|
|||||||
return api.post<ReactionState>(`problems/${problemID}/reaction`, { type })
|
return api.post<ReactionState>(`problems/${problemID}/reaction`, { type })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMetrics(userid: number) {
|
export async function getMetrics(userid: number) {
|
||||||
return api.get<Metrics>(`users/${userid}/metrics`)
|
const endpoint = `users/${userid}/metrics`
|
||||||
|
return contract(
|
||||||
|
"GET /users/:id/metrics",
|
||||||
|
metricsSchema,
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTutorial(id: number) {
|
export async function getTutorial(id: number) {
|
||||||
return api.get<Tutorial>(`tutorials/${id}`)
|
const endpoint = `tutorials/${id}`
|
||||||
|
return contract(
|
||||||
|
"GET /tutorials/:id",
|
||||||
|
tutorialSchema,
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTutorials(type: "python" | "c") {
|
export function getTutorials(type: "python" | "c") {
|
||||||
@@ -399,10 +463,14 @@ export function getAIPinnedReport() {
|
|||||||
|
|
||||||
// ==================== 相似题目推荐 ====================
|
// ==================== 相似题目推荐 ====================
|
||||||
|
|
||||||
export function getSimilarProblems(problemId: string) {
|
export async function getSimilarProblems(problemId: string) {
|
||||||
return api
|
const endpoint = `problems/${encodeURIComponent(problemId)}/similar`
|
||||||
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
|
const res = contract(
|
||||||
.then((response) => response.map(filterResult))
|
"GET /problems/:id/similar",
|
||||||
|
problemListItemSchema.array(),
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
|
return res.map(filterResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { YearlyAc as YearlyACData } from "@oj2/contract"
|
export type { YearlyAc as YearlyACData } from "@oj2/contract"
|
||||||
@@ -427,7 +495,7 @@ export function getFlowchartSubmission(id: string) {
|
|||||||
return api.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
|
return api.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFlowchartSubmissions(params: {
|
export async function getFlowchartSubmissions(params: {
|
||||||
username?: string
|
username?: string
|
||||||
problemId?: string
|
problemId?: string
|
||||||
myself?: string
|
myself?: string
|
||||||
@@ -436,17 +504,27 @@ export function getFlowchartSubmissions(params: {
|
|||||||
today?: string
|
today?: string
|
||||||
grade?: string
|
grade?: string
|
||||||
}) {
|
}) {
|
||||||
return api.get<FlowchartList>("flowcharts", { params })
|
const endpoint = "flowcharts"
|
||||||
|
return contract(
|
||||||
|
"GET /flowcharts",
|
||||||
|
flowchartListSchema,
|
||||||
|
await api.get<unknown>(endpoint, { params }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFlowchartStatistics(
|
export async function getFlowchartStatistics(
|
||||||
duration: { start?: string; end: string },
|
duration: { start?: string; end: string },
|
||||||
problemID?: string,
|
problemID?: string,
|
||||||
username?: string,
|
username?: string,
|
||||||
) {
|
) {
|
||||||
return api.get<FlowchartStatistics>("flowcharts/statistics", {
|
const endpoint = "flowcharts/statistics"
|
||||||
params: { ...duration, problemId: problemID, username },
|
return contract(
|
||||||
})
|
"GET /flowcharts/statistics",
|
||||||
|
flowchartStatisticsSchema,
|
||||||
|
await api.get<unknown>(endpoint, {
|
||||||
|
params: { ...duration, problemId: problemID, username },
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function retryFlowchartSubmission(submissionId: string) {
|
export function retryFlowchartSubmission(submissionId: string) {
|
||||||
@@ -455,36 +533,59 @@ export function retryFlowchartSubmission(submissionId: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentProblemFlowchartSubmission(problemId: number) {
|
export async function getCurrentProblemFlowchartSubmission(problemId: number) {
|
||||||
return api.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
|
const endpoint = `problems/${problemId}/flowchart/current`
|
||||||
|
return contract(
|
||||||
|
"GET /problems/:id/flowchart/current",
|
||||||
|
flowchartCurrentSchema,
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
|
export async function getFlowchartSubmissionDetail(problemId: number, page = 0) {
|
||||||
return api.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
|
const endpoint = `problems/${problemId}/flowchart/history`
|
||||||
params: { page },
|
return contract(
|
||||||
})
|
"GET /problems/:id/flowchart/history",
|
||||||
|
flowchartDetailSchema,
|
||||||
|
await api.get<unknown>(endpoint, { params: { page } }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 题单相关API ====================
|
// ==================== 题单相关API ====================
|
||||||
|
|
||||||
export function getProblemSetList(
|
export async function getProblemSetList(
|
||||||
offset = 0,
|
offset = 0,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
keyword = "",
|
keyword = "",
|
||||||
difficulty = "",
|
difficulty = "",
|
||||||
status = "",
|
status = "",
|
||||||
) {
|
) {
|
||||||
return api.get<ProblemSetList>("problem-sets", {
|
const endpoint = "problem-sets"
|
||||||
params: { offset, limit, keyword, difficulty, status },
|
return contract(
|
||||||
})
|
"GET /problem-sets",
|
||||||
|
problemSetListSchema,
|
||||||
|
await api.get<unknown>(endpoint, {
|
||||||
|
params: { offset, limit, keyword, difficulty, status },
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProblemSetDetail(id: number) {
|
export async function getProblemSetDetail(id: number) {
|
||||||
return api.get<ProblemSet>(`problem-sets/${id}`)
|
const endpoint = `problem-sets/${id}`
|
||||||
|
return contract(
|
||||||
|
"GET /problem-sets/:id",
|
||||||
|
problemSetSchema,
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProblemSetProblems(problemSetId: number) {
|
export async function getProblemSetProblems(problemSetId: number) {
|
||||||
return api.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
|
const endpoint = `problem-sets/${problemSetId}/problems`
|
||||||
|
return contract(
|
||||||
|
"GET /problem-sets/:id/problems",
|
||||||
|
problemSetProblemSchema.array(),
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function joinProblemSet(problemSetId: number) {
|
export function joinProblemSet(problemSetId: number) {
|
||||||
@@ -503,17 +604,25 @@ export function updateProblemSetProgress(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserBadges(username?: string) {
|
export async function getUserBadges(username?: string) {
|
||||||
return api.get<UserBadge[]>(
|
const endpoint = `users/${encodeURIComponent(username ?? "me")}/badges`
|
||||||
`users/${encodeURIComponent(username ?? "me")}/badges`,
|
return contract(
|
||||||
|
"GET /users/:username/badges",
|
||||||
|
userBadgeSchema.array(),
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProblemSetBadges(problemSetId: number) {
|
export async function getProblemSetBadges(problemSetId: number) {
|
||||||
return api.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
|
const endpoint = `problem-sets/${problemSetId}/badges`
|
||||||
|
return contract(
|
||||||
|
"GET /problem-sets/:id/badges",
|
||||||
|
problemSetBadgeSchema.array(),
|
||||||
|
await api.get<unknown>(endpoint),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProblemSetUserProgress(
|
export async function getProblemSetUserProgress(
|
||||||
problemSetId: number,
|
problemSetId: number,
|
||||||
params?: {
|
params?: {
|
||||||
limit?: number
|
limit?: number
|
||||||
@@ -522,9 +631,11 @@ export function getProblemSetUserProgress(
|
|||||||
completionStatus?: "" | "completed" | "in_progress" | "not_started"
|
completionStatus?: "" | "completed" | "in_progress" | "not_started"
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return api.get<ProblemSetProgressList>(
|
const endpoint = `problem-sets/${problemSetId}/user-progress`
|
||||||
`problem-sets/${problemSetId}/user-progress`,
|
return contract(
|
||||||
{ params },
|
"GET /problem-sets/:id/user-progress",
|
||||||
|
problemSetProgressListSchema,
|
||||||
|
await api.get<unknown>(endpoint, { params }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,17 +113,18 @@ export type { UploadTestCaseResponse } from "@oj2/contract"
|
|||||||
export type { ProblemTestCaseScore as Testcase } from "@oj2/contract"
|
export type { ProblemTestCaseScore as Testcase } from "@oj2/contract"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化:
|
* 题目详情。**直接取契约** —— `languages` / `template` 的收窄已经搬进
|
||||||
* - `languages` / `template` 的键窄化成 LANGUAGE,组件按语言查模板要靠它
|
* `problemDetailSchema`(`z.array(problemLanguageSchema)` 与
|
||||||
|
* `z.partialRecord(problemLanguageSchema, …)`),这里原来那份
|
||||||
|
* `Omit<ProblemDetail, "languages" | "template"> & {...}` 与契约**双向可赋值**,
|
||||||
|
* 即完全等价,是一层没有内容的重复(已用类型探针验证)。
|
||||||
|
*
|
||||||
|
* 原来它还多挂三个可选字段(`hasAstRules` / `visible` / `answers`)。它们在
|
||||||
|
* 契约里**本来就有**(`hasAstRules` 在题目列表项上、`ProblemDetail` 带的是
|
||||||
|
* `astRequirements`),少数管理端调用点需要补充时应该就地声明自己的类型,
|
||||||
|
* 不该让一个全局别名对所有调用方声称这些字段存在。
|
||||||
*/
|
*/
|
||||||
export type Problem = Omit<ProblemDetail, "languages" | "template"> & {
|
export type Problem = ProblemDetail
|
||||||
languages: LANGUAGE[]
|
|
||||||
template: { [key in LANGUAGE]?: string }
|
|
||||||
// oj 侧不下发 astRules 原文,只有 astRequirements(契约里就有)
|
|
||||||
hasAstRules?: boolean
|
|
||||||
visible?: boolean
|
|
||||||
answers?: { language: LANGUAGE; code: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AST 代码要求。原来这里手抄的那份少了 label / exact / outer / inner ——
|
* 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 {
|
export type { ContestSubmissionInfo as SubmissionInfo } from "@oj2/contract"
|
||||||
is_ac: boolean
|
|
||||||
ac_time: number
|
|
||||||
is_first_ac: boolean
|
|
||||||
error_number: number
|
|
||||||
checked?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 榜单行。`submissionInfo` 的**内容**仍是 snake_case —— 它是 acm_contest_rank
|
* 榜单行。`submissionInfo` 的收窄(JSONB 原文的 snake_case 形状)已经搬进
|
||||||
* 表的 JSONB 原文,见 SubmissionInfo。
|
* `contestRankItemSchema`,这里不再需要 Omit + 覆盖。
|
||||||
*/
|
*/
|
||||||
export type ContestRank = Omit<
|
export type ContestRank = import("@oj2/contract").ContestRankItem
|
||||||
import("@oj2/contract").ContestRankItem,
|
|
||||||
"submissionInfo"
|
|
||||||
> & {
|
|
||||||
submissionInfo: { [key: string]: SubmissionInfo }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { WebsiteConfig, OnlineCount } from "@oj2/contract"
|
export type { WebsiteConfig, OnlineCount } from "@oj2/contract"
|
||||||
|
|
||||||
@@ -399,12 +389,12 @@ export type {
|
|||||||
export type AnnouncementEdit = CreateAnnouncementRequest & { id: number }
|
export type AnnouncementEdit = CreateAnnouncementRequest & { id: number }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 站内信。取契约的形状,只把 `submission` 换成前端窄化过的那个
|
* 站内信。**直接取契约** —— 原来这里把 `submission` 换成前端窄化过的
|
||||||
* (statisticInfo / language 在契约里是 unknown,见 EmbeddedSubmission)。
|
* `EmbeddedSubmission`,但那个窄化已经搬进 `embeddedSubmissionSchema`
|
||||||
|
* (statisticInfo / language 在契约里不再是 unknown),两者双向可赋值、
|
||||||
|
* 完全等价,这层 Omit 已经没有内容。
|
||||||
*/
|
*/
|
||||||
export type Message = Omit<ContractMessage, "submission"> & {
|
export type Message = ContractMessage
|
||||||
submission: EmbeddedSubmission
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题目表情。三个类型都直接取自契约 —— 语义 key 必须与后端 reaction/models.py
|
* 题目表情。三个类型都直接取自契约 —— 语义 key 必须与后端 reaction/models.py
|
||||||
|
|||||||
@@ -29,13 +29,32 @@ export const contestPasswordRequestSchema = z.object({
|
|||||||
export const contestAccessSchema = z.object({ access: z.boolean() })
|
export const contestAccessSchema = z.object({ access: z.boolean() })
|
||||||
export const contestProblemsSchema = z.array(z.union([problemListItemSchema, problemDetailSchema]))
|
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({
|
export const contestRankItemSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
user: sampleUserSchema,
|
user: sampleUserSchema,
|
||||||
submissionNumber: z.number().int(),
|
submissionNumber: z.number().int(),
|
||||||
acceptedNumber: z.number().int(),
|
acceptedNumber: z.number().int(),
|
||||||
totalTime: z.number().int(),
|
totalTime: z.number().int(),
|
||||||
submissionInfo: z.record(z.string(), z.unknown()),
|
submissionInfo: z.record(z.string(), contestSubmissionInfoSchema),
|
||||||
contestId: z.number().int(),
|
contestId: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,6 +65,7 @@ export type ContestList = z.infer<typeof contestListSchema>
|
|||||||
export type ContestRankItem = z.infer<typeof contestRankItemSchema>
|
export type ContestRankItem = z.infer<typeof contestRankItemSchema>
|
||||||
export type ContestRank = z.infer<typeof contestRankSchema>
|
export type ContestRank = z.infer<typeof contestRankSchema>
|
||||||
export type ContestAccess = z.infer<typeof contestAccessSchema>
|
export type ContestAccess = z.infer<typeof contestAccessSchema>
|
||||||
|
export type ContestSubmissionInfo = z.infer<typeof contestSubmissionInfoSchema>
|
||||||
|
|
||||||
export type ContestStatus = z.infer<typeof contestStatusSchema>
|
export type ContestStatus = z.infer<typeof contestStatusSchema>
|
||||||
export type ContestPasswordRequest = z.infer<typeof contestPasswordRequestSchema>
|
export type ContestPasswordRequest = z.infer<typeof contestPasswordRequestSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user