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>
181 lines
4.7 KiB
Vue
181 lines
4.7 KiB
Vue
<script setup lang="ts">
|
|
import { useRouteQuery } from "@vueuse/router"
|
|
import { NTag } from "naive-ui"
|
|
import { getContestList } from "oj/api"
|
|
import { duration, parseTime } from "utils/functions"
|
|
import type { OjContest } from "utils/types"
|
|
import ContestTitle from "shared/components/ContestTitle.vue"
|
|
import Pagination from "shared/components/Pagination.vue"
|
|
import { useAuthModalStore } from "shared/store/authModal"
|
|
import { usePagination } from "shared/composables/pagination"
|
|
import { useUserStore } from "shared/store/user"
|
|
import { CONTEST_STATUS, ContestType } from "utils/constants"
|
|
import { renderTableTitle } from "utils/renders"
|
|
|
|
const router = useRouter()
|
|
const userStore = useUserStore()
|
|
const authStore = useAuthModalStore()
|
|
|
|
interface ContestQuery {
|
|
keyword: string
|
|
status: string
|
|
tag: string
|
|
}
|
|
|
|
// 使用分页 composable
|
|
const { query, clearQuery } = usePagination<ContestQuery>({
|
|
keyword: useRouteQuery("keyword", "").value,
|
|
status: useRouteQuery("status", "").value,
|
|
tag: useRouteQuery("tag", "").value,
|
|
})
|
|
|
|
const data = ref<OjContest[]>([])
|
|
const total = ref(0)
|
|
|
|
const options: SelectOption[] = [
|
|
{ label: "全部", value: "" },
|
|
{ label: "未开始", value: "1" },
|
|
{ label: "进行中", value: "0" },
|
|
{ label: "已结束", value: "-1" },
|
|
]
|
|
|
|
const tags: SelectOption[] = [
|
|
{ label: "全部", value: "" },
|
|
{ label: "练习", value: "练习" },
|
|
{ label: "期中", value: "期中" },
|
|
{ label: "期末", value: "期末" },
|
|
]
|
|
|
|
const columns: DataTableColumn<OjContest>[] = [
|
|
{
|
|
title: renderTableTitle("状态", "streamline-emojis:collision"),
|
|
key: "status",
|
|
width: 100,
|
|
render: (row) =>
|
|
h(
|
|
NTag,
|
|
{ type: CONTEST_STATUS[row.status]["type"] },
|
|
() => CONTEST_STATUS[row.status]["name"],
|
|
),
|
|
},
|
|
{
|
|
title: renderTableTitle("比赛", "streamline-emojis:bouquet"),
|
|
key: "title",
|
|
minWidth: 360,
|
|
render: (row) => h(ContestTitle, { contest: row }),
|
|
},
|
|
{
|
|
title: renderTableTitle("标签", "fluent-emoji-flat:keycap-hashtag"),
|
|
key: "tag",
|
|
width: 100,
|
|
render: (row) => h(NTag, () => row.tag),
|
|
},
|
|
{
|
|
title: renderTableTitle("开始时间", "fluent-emoji-flat:eleven-thirty"),
|
|
key: "start_time",
|
|
width: 180,
|
|
render: (row) => parseTime(row.startTime),
|
|
},
|
|
{
|
|
title: renderTableTitle("比赛时长", "streamline-emojis:fishing-pole"),
|
|
key: "duration",
|
|
width: 180,
|
|
render: (row) => duration(row.startTime, row.endTime),
|
|
},
|
|
]
|
|
|
|
async function listContests() {
|
|
const offset = (query.page - 1) * query.limit
|
|
const res = await getContestList({
|
|
offset,
|
|
limit: query.limit,
|
|
keyword: query.keyword,
|
|
status: query.status,
|
|
tag: query.tag,
|
|
})
|
|
data.value = res.results
|
|
total.value = res.total
|
|
}
|
|
|
|
function search(value: string) {
|
|
query.keyword = value
|
|
}
|
|
|
|
function clear() {
|
|
clearQuery()
|
|
}
|
|
|
|
onMounted(listContests)
|
|
|
|
// 监听搜索关键词变化(防抖)
|
|
watchDebounced(() => query.keyword, listContests, {
|
|
debounce: 500,
|
|
maxWait: 1000,
|
|
})
|
|
|
|
// 监听其他查询条件变化
|
|
watch(() => [query.page, query.limit, query.status, query.tag], listContests)
|
|
|
|
function rowProps(row: OjContest) {
|
|
return {
|
|
style: "cursor: pointer",
|
|
onClick() {
|
|
if (!userStore.isAuthed && row.contestType === ContestType.private) {
|
|
authStore.openLoginModal()
|
|
} else {
|
|
router.push("/contest/" + row.id)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
</script>
|
|
<template>
|
|
<n-flex vertical size="large">
|
|
<n-space>
|
|
<n-form :show-feedback="false" label-placement="left" inline>
|
|
<n-form-item label="比赛状态">
|
|
<n-select
|
|
style="width: 120px"
|
|
:options="options"
|
|
v-model:value="query.status"
|
|
/>
|
|
</n-form-item>
|
|
<n-form-item label="标签">
|
|
<n-select
|
|
style="width: 120px"
|
|
:options="tags"
|
|
v-model:value="query.tag"
|
|
/>
|
|
</n-form-item>
|
|
</n-form>
|
|
<n-form :show-feedback="false" label-placement="left" inline>
|
|
<n-form-item>
|
|
<n-input
|
|
style="width: 180px"
|
|
clearable
|
|
v-model:value="query.keyword"
|
|
placeholder="比赛标题"
|
|
/>
|
|
</n-form-item>
|
|
<n-form-item>
|
|
<n-flex :wrap="false">
|
|
<n-button @click="search(query.keyword)">搜索</n-button>
|
|
<n-button @click="clear" quaternary>重置</n-button>
|
|
</n-flex>
|
|
</n-form-item>
|
|
</n-form>
|
|
</n-space>
|
|
<n-data-table
|
|
:bordered="false"
|
|
:columns="columns"
|
|
:data="data"
|
|
:row-props="rowProps"
|
|
/>
|
|
</n-flex>
|
|
<Pagination
|
|
v-model:limit="query.limit"
|
|
v-model:page="query.page"
|
|
:total="total"
|
|
/>
|
|
</template>
|