refactor(前端): 去掉 { error, data } 信封,api2 改名 api
Some checks failed
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
parent b35118f32e
commit 0b7b08f2cc
64 changed files with 391 additions and 427 deletions

View File

@@ -1,10 +1,9 @@
import { userProfileSchema, type Quote } from "@oj2/contract"
import api2 from "utils/api2"
import type { ApiResponse } from "utils/api2"
import api from "utils/api"
import type { Profile, Tag } from "utils/types"
export function login(data: { username: string; password: string }) {
return api2.post("auth/login", data)
return api.post("auth/login", data)
}
export function signup(data: {
@@ -12,37 +11,34 @@ export function signup(data: {
email: string
password: string
}) {
return api2.post("users", data)
return api.post("users", data)
}
export function logout() {
return api2.delete("auth/session")
return api.delete("auth/session")
}
export async function getProfile(
username: string = "",
): Promise<ApiResponse<Profile | null>> {
const response = await api2.get<unknown>(
): Promise<Profile | null> {
const response = await api.get<unknown>(
username ? `profiles/${encodeURIComponent(username)}` : "me",
)
if (response.data === null) return { error: null, data: null }
if (response === null) return null
// 形状与契约一致不再逐字段搬运zod 解析仍保留,形状对不上要当场炸
return {
error: null,
data: userProfileSchema.parse(response.data) as Profile,
}
return userProfileSchema.parse(response) as Profile
}
export function getProblemTagList() {
return api2.get<Tag[]>("problem-tags")
return api.get<Tag[]>("problem-tags")
}
export function getHitokoto() {
return api2.get<Quote>("quotes/random")
return api.get<Quote>("quotes/random")
}
export function getClassUsernames(classroom: string) {
return api2.get<string[]>(
return api.get<string[]>(
`classes/${encodeURIComponent(classroom)}/usernames`,
)
}

View File

@@ -24,7 +24,7 @@ const authorOptions = ref([{ label: "全部", value: "" }])
async function getAuthorOptions() {
authorOptions.value = [{ label: "全部", value: "" }]
const res = await getAuthors(all)
const remotes = res.data.map((item) => ({
const remotes = res.map((item) => ({
label: `${item.username} (${item.problemCount})`,
value: item.username,
}))

View File

@@ -533,7 +533,7 @@ async function handleStatistics() {
query.problem,
query.username,
)
Object.assign(data, res.data)
Object.assign(data, res)
await nextTick()
renderWordCloud()
}

View File

@@ -10,7 +10,7 @@ async function receive() {
try {
const res = await getHitokoto()
// 契约是 string | Record —— 一言数据集不在仓库里,形状留了余地
const quote = res.data
const quote = res
if (typeof quote === "string") {
hitokoto.sentence = quote
hitokoto.from = ""

View File

@@ -80,7 +80,7 @@ async function loadClassUsernames(selectedClass: string) {
classUserLoading.value = true
try {
const res = await getClassUsernames(selectedClass)
classUserOptions.value = res.data.map((name: string) => ({
classUserOptions.value = res.map((name: string) => ({
label: name,
value: name,
}))

View File

@@ -421,13 +421,15 @@ async function handleStatistics() {
query.problem,
query.username,
)
count.total = res.data.submissionCount
count.accepted = res.data.acceptedCount
count.rate = res.data.correctRate
list.value = res.data.data
listUnaccepted.value = res.data.dataUnaccepted
person.count = res.data.personCount
person.rate = res.data.personRate
count.total = res.submissionCount
count.accepted = res.acceptedCount
count.rate = res.correctRate
// 这里的 res.data 是载荷**自己**的 data 字段(每个学生一行),
// 不是原来那层信封 —— 契约 submissionStatisticsSchema 就是这么定的
list.value = res.data
listUnaccepted.value = res.dataUnaccepted
person.count = res.personCount
person.rate = res.personRate
}
function rowKey(row: SubmissionStatisticsUser): DataTableRowKey {

View File

@@ -36,9 +36,8 @@ export const useAchievementStore = defineStore("achievement", () => {
async function fetchPending() {
try {
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
const res = await getPendingAchievements()
enqueue(res.data ?? [])
enqueue(res ?? [])
} catch {
// 拉取失败静默处理,下次路由切换会再拉
}

View File

@@ -14,8 +14,8 @@ export const useConfigStore = defineStore("config", () => {
})
async function getConfig() {
const res = await getWebsiteConfig()
config.value = res.data
document.title = res.data.websiteName
config.value = res
document.title = res.websiteName
}
return {
config,

View File

@@ -31,9 +31,9 @@ export const useLoginSummaryStore = defineStore("loginSummary", () => {
analysisError.value = ""
try {
const res = await getAILoginSummary()
summary.value = res.data.summary
analysis.value = res.data.analysis || ""
analysisError.value = res.data.analysisError || ""
summary.value = res.summary
analysis.value = res.analysis || ""
analysisError.value = res.analysisError || ""
} catch (err) {
analysisError.value = "获取登录统计失败,请稍后再试"
} finally {

View File

@@ -63,7 +63,7 @@ export const useUserStore = defineStore("user", () => {
async function getMyProfile() {
isFinished.value = false
const res = await getProfile()
profile.value = res.data
profile.value = res
isFinished.value = true
storage.set(STORAGE_KEY.AUTHED, !!user.value?.email)
}