refactor(前端): 去掉 { error, data } 信封,api2 改名 api
Deploy / deploy (push) Has been cancelled

信封是 Django 时代的形状:拦截器手工造一个**恒为 null** 的 error 字段,再把
真正的载荷塞进 data。后端 http.ts 的 success 其实只返回 { data },那个 error
从头到尾没人用 —— 全站成功路径读 res.error 的只有 admin/api.ts 的
resetPassword 一处,而它自己就是个把信封拆开再重新包一遍的 shim。

代价是每个调用点都要 .data 一次:47 个组件、3 个 api 层文件、200 多处。
现在拦截器直接返回 response.data.data,ApiResponse<T> 退化成 T,文件末尾那句
`as unknown as Api2Client` 的类型谎言也少了一层。失败路径不动,仍然 reject
`{ error: 错误码, data: 文案 }` —— 和成功路径不对称是故意的,成功没有错误码
可言,接口注释里写清楚了。

顺带把 api2 改回 api:utils/ 下早就没有 api.ts 了,"2" 是迁移期用来和旧
client 区分的,现在只剩下让人多想一秒的作用。

## 怎么改的

**没有全局 sed。** 先把客户端的返回类型从 Promise<ApiResponse<T>> 改成
Promise<T>,让 vue-tsc 把每一处报出来(210 条),再按它给的 file:line:col
精确删 `.data`(192 处),剩下的手工处理:

- 6 处 `const { data } = await ...` 解构 → `const data = await ...`
- 3 个 api 层函数(getProfile / getProblem / getSubmission)自己手工造信封,
  改成直接返回值;getProfile 的返回类型跟着从 ApiResponse<Profile|null>
  变成 Profile|null

**类型检查抓不到的,人工把剩下的每一处 `.data` 过了一遍** —— 载荷本身带
data 字段、或者载荷是索引签名时,`res.data` 照样过类型。这一遍捞出三条真 bug:

- `getTutorialList` 的载荷是 `{ [key: string]: TutorialListItem[] }`(按
  python / c 分组)。索引签名让 `res.data` 编译通过、运行时是 undefined ——
  改完信封之后教程列表会**两个 tab 全空且不报错**。实跑确认过修好了。
- `createExercise` / `updateExercise` 返回 `res.data`,而 Exercise 自己有
  data 字段(练习内容)。两个调用方都不看返回值,所以类型和运行时都不响。
- `getSimilarProblems` 的 `.then(r => ({ ...r, data: r.data.map(...) }))`
  删掉 .data 之后变成往对象里摊一个数组,能跑但形状是错的。

另外两处是**对的**,加了注释免得下次被"顺手清理"掉:
StatisticsPanel 的 `res.data` 是契约 submissionStatisticsSchema 自己的 data
字段(每个学生一行);download.ts 是独立 axios 实例,`res.data` 是 axios 的
响应体(zip 二进制,不走信封)。

## 验证

tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、vite build
通过。**因为这改动碰的是每一个请求,静态检查不够,起了全套服务用浏览器实跑:**

- oj 侧 12 个页面 + 后台 13 个页面逐个打开,断言没有重定向、console 无报错。
- 关键页面进一步断言渲染出了真数据(后台用户列表 3 行、题目列表 10 行、
  站点配置表单三个输入框有值、教程列表分组正确)。
- 三条写路径实打:重置密码(库里 student123 → 531554,表格当场刷新)、
  公告可见性开关(走 getAnnouncement + editAnnouncement,就是手改解构那处,
  库里 visible t → f)、提交代码(POST → 判题机真跑出 -2 → 提交列表和详情页
  都正确渲染状态、语言、代码)。
- /rank 有一条 `{error: "class-missing"}` 的未捕获 reject,stash 掉本次改动
  复现同样报错,**是既有问题**,不在本次范围内。

本地 dev 库为了打通后台测试改了三处,都只影响本机:devadmin 补了 email 和
user_profile 行(原来缺这两样,getProfile 报 profile-not-found,AUTHED 存不
进去,所有 /admin 路由被守卫弹回首页)、密码重置成 devpass123。冒烟用的教程/
公告/提交三条测试数据已删干净,题目和用户的提交计数也回滚了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 22:42:20 -06:00
co-authored by Claude Opus 5
parent b35118f32e
commit 0b7b08f2cc
64 changed files with 391 additions and 427 deletions
+65 -73
View File
@@ -37,7 +37,7 @@ import {
type FlowchartStatistics,
type SubmissionStatistics,
} from "@oj2/contract"
import api2 from "utils/api2"
import api from "utils/api"
import { filterResult } from "oj/transforms"
import type {
Announcement,
@@ -65,7 +65,7 @@ function detailProblem(value: unknown): Problem {
}
export function getWebsiteConfig() {
return api2.get<WebsiteConfig>("site")
return api.get<WebsiteConfig>("site")
}
export async function getProblemList(
@@ -73,17 +73,17 @@ export async function getProblemList(
limit = 10,
searchParams: Record<string, unknown> = {},
) {
const res = await api2.get<ProblemList>("problems", {
const res = await api.get<ProblemList>("problems", {
params: { paging: true, offset, limit, ...searchParams },
})
return {
results: res.data.results.map(filterResult),
total: res.data.total,
results: res.results.map(filterResult),
total: res.total,
}
}
export function getAuthors(all = false) {
return api2.get<ProblemAuthor[]>("problem-authors", {
return api.get<ProblemAuthor[]>("problem-authors", {
params: { all: all ? "1" : "0" },
})
}
@@ -92,27 +92,23 @@ export async function getProblem(problemID: string, contestID: string) {
const endpoint = contestID
? `contests/${encodeURIComponent(contestID)}/problems/${encodeURIComponent(problemID)}`
: `problems/${encodeURIComponent(problemID)}`
const response = await api2.get<unknown>(endpoint)
return { error: null, data: detailProblem(response.data) }
return detailProblem(await api.get<unknown>(endpoint))
}
// 未登录返回 "0",登录后返回百分比字符串
export function getProblemBeatRate(problemID: number) {
return api2.get<string>(`problems/${problemID}/beat-count`)
return api.get<string>(`problems/${problemID}/beat-count`)
}
export async function getSubmission(id: string) {
const response = await api2.get<unknown>(
const response = await api.get<unknown>(
`submissions/${encodeURIComponent(id)}`,
)
return {
error: null,
data: submissionDetailSchema.parse(response.data) as Submission,
}
return submissionDetailSchema.parse(response) as Submission
}
export function submitCode(data: SubmitCodePayload) {
return api2.post<CreateSubmissionResponse>("submissions", data)
return api.post<CreateSubmissionResponse>("submissions", data)
}
export function formatCode(data: { code: string; language: string }) {
@@ -122,7 +118,7 @@ export function formatCode(data: { code: string; language: string }) {
"C++": "cpp",
SQL: "sql",
}
return api2.post<FormatCodeResponse>("code/format", {
return api.post<FormatCodeResponse>("code/format", {
code: data.code,
language: languages[data.language] ?? data.language.toLowerCase(),
})
@@ -134,22 +130,22 @@ export function getSubmissions(params: Partial<SubmissionListPayload>) {
: "submissions"
// 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让
// 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE
return api2.get<{ results: SubmissionListItem[]; total: number }>(endpoint, {
return api.get<{ results: SubmissionListItem[]; total: number }>(endpoint, {
// contestId 走的是路径,page 只有前端分页器用
params: { ...params, contestId: undefined, page: undefined },
})
}
export function getRankOfProblem(problemId: string) {
return api2.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
return api.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
}
export function getTodaySubmissionCount(language?: string) {
return api2.get<number>("submissions/today-count", { params: { language } })
return api.get<number>("submissions/today-count", { params: { language } })
}
export function adminRejudge(id: string) {
return api2.post<{ ok: boolean }>(
return api.post<{ ok: boolean }>(
`submissions/${encodeURIComponent(id)}/rejudge`,
)
}
@@ -159,7 +155,7 @@ export function getSubmissionStatistics(
problemID?: string,
username?: string,
) {
return api2.get<SubmissionStatistics>("submissions/statistics", {
return api.get<SubmissionStatistics>("submissions/statistics", {
params: { ...duration, problemId: problemID, username },
})
}
@@ -169,17 +165,17 @@ export function getSubmissionStatistics(
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
*/
export function getRank(offset: number, limit: number) {
return api2.get<UserRank>("rankings/users", { params: { offset, limit } })
return api.get<UserRank>("rankings/users", { params: { offset, limit } })
}
export function getActivityRank(start: string) {
return api2.get<ActivityRankItem[]>("rankings/activity", {
return api.get<ActivityRankItem[]>("rankings/activity", {
params: { start },
})
}
export function getClassRank(grade?: number | null) {
return api2.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
return api.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
}
export function getUserClassRank(
@@ -187,7 +183,7 @@ export function getUserClassRank(
offset?: number,
limit?: number,
) {
return api2.get<ClassUserRank>("me/class-rank", {
return api.get<ClassUserRank>("me/class-rank", {
params: { scope, offset, limit },
})
}
@@ -197,7 +193,7 @@ export function getClassPK(
startTime?: string,
endTime?: string,
) {
return api2.post<ClassComparisonResponse>("classes/comparison", {
return api.post<ClassComparisonResponse>("classes/comparison", {
classNames,
...(startTime ? { startTime } : {}),
...(endTime ? { endTime } : {}),
@@ -211,20 +207,20 @@ export function getContestList(query: {
status: string
tag: string
}) {
return api2.get<ContestList>("contests", { params: query })
return api.get<ContestList>("contests", { params: query })
}
export function getContest(id: string) {
return api2.get<OjContest>(`contests/${encodeURIComponent(id)}`)
return api.get<OjContest>(`contests/${encodeURIComponent(id)}`)
}
export function getContestAccess(id: string) {
return api2.get<ContestAccess>(`contests/${encodeURIComponent(id)}/access`)
return api.get<ContestAccess>(`contests/${encodeURIComponent(id)}/access`)
}
// 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch
export function checkContestPassword(contestID: string, password: string) {
return api2.post<boolean>(
return api.post<boolean>(
`contests/${encodeURIComponent(contestID)}/access`,
{
password,
@@ -233,10 +229,10 @@ export function checkContestPassword(contestID: string, password: string) {
}
export async function getContestProblems(contestID: string) {
const res = await api2.get<ProblemListItem[]>(
const res = await api.get<ProblemListItem[]>(
`contests/${encodeURIComponent(contestID)}/problems`,
)
return res.data.map(filterResult)
return res.map(filterResult)
}
export function getContestRank(
@@ -245,7 +241,7 @@ export function getContestRank(
) {
// submissionInfo 在契约里是 Record<string, unknown>JSONB 原文),
// 前端在这里收窄成 SubmissionInfo,见 utils/types 的 ContestRank
return api2.get<{ results: ContestRank[]; total: number }>(
return api.get<{ results: ContestRank[]; total: number }>(
`contests/${encodeURIComponent(contestID)}/rank`,
{ params: query },
)
@@ -254,23 +250,23 @@ export function getContestRank(
export function uploadAvatar(file: File) {
const form = new window.FormData()
form.append("image", file)
return api2.post("me/avatar", form, {
return api.post("me/avatar", form, {
headers: { "content-type": "multipart/form-data" },
})
}
export function updateProfile(data: { realName: string; mood: string }) {
return api2.put<Profile>("me/profile", data)
return api.put<Profile>("me/profile", data)
}
export function getAnnouncementList(offset = 0, limit = 10) {
return api2.get<{ results: Announcement[]; total: number }>("announcements", {
return api.get<{ results: Announcement[]; total: number }>("announcements", {
params: { limit, offset },
})
}
export function getAnnouncement(id: number) {
return api2.get<Announcement>(`announcements/${id}`)
return api.get<Announcement>(`announcements/${id}`)
}
export function createMessage(data: {
@@ -278,7 +274,7 @@ export function createMessage(data: {
message: string
submission: string
}) {
return api2.post("messages", {
return api.post("messages", {
recipientId: data.recipient,
message: data.message,
submissionId: data.submission,
@@ -287,33 +283,33 @@ export function createMessage(data: {
export function getMessageList(offset = 0, limit = 10) {
// language 的收窄同 getSubmissions,见那里的说明
return api2.get<{ results: Message[]; total: number }>("messages", {
return api.get<{ results: Message[]; total: number }>("messages", {
params: { limit, offset },
})
}
export function getReaction(problemID: number) {
return api2.get<ReactionState>(`problems/${problemID}/reaction`)
return api.get<ReactionState>(`problems/${problemID}/reaction`)
}
export function setReaction(problemID: number, type: ReactionKey) {
return api2.post<ReactionState>(`problems/${problemID}/reaction`, { type })
return api.post<ReactionState>(`problems/${problemID}/reaction`, { type })
}
export function getMetrics(userid: number) {
return api2.get<Metrics>(`users/${userid}/metrics`)
return api.get<Metrics>(`users/${userid}/metrics`)
}
export function getTutorial(id: number) {
return api2.get<Tutorial>(`tutorials/${id}`)
return api.get<Tutorial>(`tutorials/${id}`)
}
export function getTutorials(type: "python" | "c") {
return api2.get<TutorialSummary[]>("tutorials", { params: { type } })
return api.get<TutorialSummary[]>("tutorials", { params: { type } })
}
export function getAIDetailData(start: string, end: string, username?: string) {
return api2.get<AiDetail>("ai/detail", { params: { start, end, username } })
return api.get<AiDetail>("ai/detail", { params: { start, end, username } })
}
export function getAIDurationData(
@@ -321,40 +317,37 @@ export function getAIDurationData(
duration: string,
username?: string,
) {
return api2.get<DurationData[]>("ai/duration", {
return api.get<DurationData[]>("ai/duration", {
params: { end, duration, username },
})
}
export function getAIHeatmapData(username?: string) {
return api2.get<HeatmapItem[]>("ai/heatmap", {
return api.get<HeatmapItem[]>("ai/heatmap", {
params: username ? { username } : {},
})
}
export function getAILoginSummary() {
return api2.get<LoginSummary>("ai/login-summary")
return api.get<LoginSummary>("ai/login-summary")
}
export function getAIPinnedReport() {
return api2.get<AiAnalysisRecord | null>("ai/pinned")
return api.get<AiAnalysisRecord | null>("ai/pinned")
}
// ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) {
return api2
return api
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => ({
...response,
data: response.data.map(filterResult),
}))
.then((response) => response.map(filterResult))
}
export type { YearlyAc as YearlyACData } from "@oj2/contract"
export function getProblemYearlyAC(problemId: string) {
return api2.get<YearlyAc[]>(
return api.get<YearlyAc[]>(
`problems/${encodeURIComponent(problemId)}/yearly-ac`,
)
}
@@ -366,11 +359,11 @@ export function submitFlowchart(data: {
mermaidCode: string
flowchartData: Record<string, unknown> // 压缩之后的,元数据太长了
}) {
return api2.post<CreateFlowchartResponse>("flowcharts", data)
return api.post<CreateFlowchartResponse>("flowcharts", data)
}
export function getFlowchartSubmission(id: string) {
return api2.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
return api.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
}
export function getFlowchartSubmissions(params: {
@@ -382,7 +375,7 @@ export function getFlowchartSubmissions(params: {
today?: string
grade?: string
}) {
return api2.get<FlowchartList>("flowcharts", { params })
return api.get<FlowchartList>("flowcharts", { params })
}
export function getFlowchartStatistics(
@@ -390,23 +383,23 @@ export function getFlowchartStatistics(
problemID?: string,
username?: string,
) {
return api2.get<FlowchartStatistics>("flowcharts/statistics", {
return api.get<FlowchartStatistics>("flowcharts/statistics", {
params: { ...duration, problemId: problemID, username },
})
}
export function retryFlowchartSubmission(submissionId: string) {
return api2.post<{ status: string }>(
return api.post<{ status: string }>(
`flowcharts/${encodeURIComponent(submissionId)}/retry`,
)
}
export function getCurrentProblemFlowchartSubmission(problemId: number) {
return api2.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
return api.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
}
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
return api2.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
return api.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
params: { page },
})
}
@@ -420,21 +413,21 @@ export function getProblemSetList(
difficulty = "",
status = "",
) {
return api2.get<ProblemSetList>("problem-sets", {
return api.get<ProblemSetList>("problem-sets", {
params: { offset, limit, keyword, difficulty, status },
})
}
export function getProblemSetDetail(id: number) {
return api2.get<ProblemSet>(`problem-sets/${id}`)
return api.get<ProblemSet>(`problem-sets/${id}`)
}
export function getProblemSetProblems(problemSetId: number) {
return api2.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
return api.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
}
export function joinProblemSet(problemSetId: number) {
return api2.post("problem-set-progress", { problemSetId })
return api.post("problem-set-progress", { problemSetId })
}
export function updateProblemSetProgress(
@@ -442,7 +435,7 @@ export function updateProblemSetProgress(
problemId: number,
submissionId: string,
) {
return api2.put("problem-set-progress", {
return api.put("problem-set-progress", {
problemSetId,
problemId,
submissionId,
@@ -450,13 +443,13 @@ export function updateProblemSetProgress(
}
export function getUserBadges(username?: string) {
return api2.get<UserBadge[]>(
return api.get<UserBadge[]>(
`users/${encodeURIComponent(username ?? "me")}/badges`,
)
}
export function getProblemSetBadges(problemSetId: number) {
return api2.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
return api.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
}
export function getProblemSetUserProgress(
@@ -468,13 +461,12 @@ export function getProblemSetUserProgress(
completionStatus?: "" | "completed" | "in_progress" | "not_started"
},
) {
return api2.get<ProblemSetProgressList>(
return api.get<ProblemSetProgressList>(
`problem-sets/${problemSetId}/user-progress`,
{ params },
)
}
export async function getExercises(tutorialId: number): Promise<Exercise[]> {
const res = await api2.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
return res.data
export function getExercises(tutorialId: number): Promise<Exercise[]> {
return api.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
}