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>
321 lines
8.3 KiB
Vue
321 lines
8.3 KiB
Vue
<script setup lang="ts">
|
||
import type { AcmHelperItem, SubmissionInfo } from "utils/types"
|
||
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
|
||
import { parseTime } from "utils/functions"
|
||
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
|
||
import { getSubmission, getSubmissions } from "oj/api"
|
||
import SubmissionDetail from "oj/submission/detail.vue"
|
||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||
|
||
interface Props {
|
||
contestID: string
|
||
}
|
||
|
||
/**
|
||
* ACM 助手行。`acInfo` 的**内容**是 acm_contest_rank.submission_info 的 JSONB 原文,
|
||
* 键名保持 snake_case —— 回滚时旧后端还要读。
|
||
*/
|
||
type HelperItem = Omit<AcmHelperItem, "acInfo"> & {
|
||
acInfo: SubmissionInfo
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
const message = useMessage()
|
||
|
||
const { isDesktop } = useBreakpoints()
|
||
|
||
const submissions = ref<HelperItem[]>([])
|
||
const contestStartTime = ref<Date | null>(null)
|
||
const query = reactive({
|
||
username: "",
|
||
problemId: "",
|
||
checked: "all",
|
||
})
|
||
|
||
// 检查状态选项
|
||
const checkedOptions = [
|
||
{ label: "全部", value: "all" },
|
||
{ label: "已检查", value: "checked" },
|
||
{ label: "未检查", value: "unchecked" },
|
||
]
|
||
|
||
// 代码查看模态框
|
||
const [codePanel, toggleCodePanel] = useToggle(false)
|
||
const currentSubmission = ref<any>(null)
|
||
|
||
// 格式化 AC 时间(ac_time 是相对于比赛开始的秒数)
|
||
function formatACTime(relativeSeconds: number) {
|
||
if (!contestStartTime.value) return "-"
|
||
const acTime = new Date(
|
||
contestStartTime.value.getTime() + relativeSeconds * 1000,
|
||
)
|
||
return parseTime(acTime, "YYYY-MM-DD HH:mm:ss")
|
||
}
|
||
|
||
// 切换检查状态
|
||
async function toggleChecked(item: HelperItem) {
|
||
const newChecked = !item.checked
|
||
try {
|
||
await updateACMHelperChecked(
|
||
Number(props.contestID),
|
||
item.id,
|
||
item.problemId,
|
||
newChecked,
|
||
)
|
||
// 更新本地状态
|
||
item.checked = newChecked
|
||
item.acInfo.checked = newChecked
|
||
|
||
// 强制触发响应式更新
|
||
submissions.value = [...submissions.value]
|
||
|
||
message.success(newChecked ? "已标记为已检查" : "已取消标记")
|
||
} catch (err: any) {
|
||
message.error(err.data || "操作失败")
|
||
}
|
||
}
|
||
|
||
// 批量标记为已检查
|
||
async function markAllAsChecked() {
|
||
const unchecked = filteredSubmissions.value.filter((item) => !item.checked)
|
||
if (unchecked.length === 0) {
|
||
message.info("没有需要标记的提交")
|
||
return
|
||
}
|
||
|
||
const loadingMsg = message.loading("正在标记...", { duration: 0 })
|
||
try {
|
||
for (const item of unchecked) {
|
||
await updateACMHelperChecked(
|
||
Number(props.contestID),
|
||
item.id,
|
||
item.problemId,
|
||
true,
|
||
)
|
||
item.checked = true
|
||
item.acInfo.checked = true
|
||
}
|
||
|
||
// 强制触发响应式更新
|
||
submissions.value = [...submissions.value]
|
||
|
||
loadingMsg.destroy()
|
||
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
|
||
} catch (err: any) {
|
||
loadingMsg.destroy()
|
||
message.error(err.data || "批量操作失败")
|
||
}
|
||
}
|
||
|
||
// 过滤后的提交列表
|
||
const filteredSubmissions = computed(() => {
|
||
return submissions.value.filter((item) => {
|
||
if (query.username && !item.username.includes(query.username)) return false
|
||
if (query.problemId && !item.problemDisplayId.includes(query.problemId))
|
||
return false
|
||
if (query.checked === "checked" && !item.checked) return false
|
||
if (query.checked === "unchecked" && item.checked) return false
|
||
return true
|
||
})
|
||
})
|
||
|
||
// 统计信息
|
||
const stats = computed(() => {
|
||
const total = submissions.value.length
|
||
const checked = submissions.value.filter((item) => item.checked).length
|
||
const unchecked = total - checked
|
||
return { total, checked, unchecked }
|
||
})
|
||
|
||
// 查看代码 - 获取该用户在该题目的 AC 提交
|
||
async function viewSubmission(item: HelperItem) {
|
||
try {
|
||
// 查询该用户在该竞赛该题目的 AC 提交
|
||
const res = await getSubmissions({
|
||
username: item.username,
|
||
problemId: item.problemDisplayId,
|
||
contestId: props.contestID,
|
||
result: "0", // ACCEPTED
|
||
language: "",
|
||
page: 1,
|
||
offset: 0,
|
||
limit: 1,
|
||
})
|
||
|
||
if (res.results.length === 0) {
|
||
message.warning("未找到该用户的 AC 提交")
|
||
return
|
||
}
|
||
|
||
// 获取提交详情
|
||
const submissionListItem = res.results[0]
|
||
const detailRes = await getSubmission(submissionListItem.id)
|
||
|
||
// 手动添加 contest 字段(ACM模式下后端不返回此字段)
|
||
currentSubmission.value = {
|
||
...detailRes,
|
||
contest: Number(props.contestID),
|
||
problem_display_id: item.problemDisplayId,
|
||
}
|
||
|
||
toggleCodePanel(true)
|
||
} catch (err: any) {
|
||
message.error(err.data || "加载提交失败")
|
||
}
|
||
}
|
||
|
||
// 加载数据
|
||
async function loadData() {
|
||
try {
|
||
// 先获取比赛信息,获取开始时间
|
||
const contestRes = await getContest(props.contestID)
|
||
contestStartTime.value = new Date(contestRes.startTime)
|
||
|
||
// 再获取 AC 提交列表
|
||
const data = await getACMHelperList(Number(props.contestID))
|
||
submissions.value = data
|
||
} catch (err: any) {
|
||
message.error(err.data || "加载失败")
|
||
}
|
||
}
|
||
|
||
const columns: DataTableColumn<HelperItem>[] = [
|
||
{
|
||
title: "用户名",
|
||
key: "username",
|
||
width: 150,
|
||
},
|
||
{
|
||
title: "题目",
|
||
key: "problem_display_id",
|
||
width: 100,
|
||
render: (row) => h(NTag, { type: "info" }, () => row.problemDisplayId),
|
||
},
|
||
{
|
||
title: "AC时间",
|
||
key: "ac_time",
|
||
width: 180,
|
||
render: (row) => formatACTime(row.acInfo.ac_time),
|
||
},
|
||
{
|
||
title: "错误次数",
|
||
key: "error_number",
|
||
width: 100,
|
||
render: (row) =>
|
||
h(
|
||
NTag,
|
||
{
|
||
type: row.acInfo.error_number > 0 ? "warning" : "success",
|
||
size: "small",
|
||
},
|
||
() => row.acInfo.error_number,
|
||
),
|
||
},
|
||
{
|
||
title: "已检查",
|
||
key: "checked",
|
||
width: 100,
|
||
render: (row) =>
|
||
h(NCheckbox, {
|
||
checked: row.checked,
|
||
onUpdateChecked: () => toggleChecked(row),
|
||
}),
|
||
},
|
||
{
|
||
title: "操作",
|
||
key: "actions",
|
||
width: 100,
|
||
render: (row) =>
|
||
h(
|
||
NButton,
|
||
{
|
||
size: "small",
|
||
type: "primary",
|
||
secondary: true,
|
||
onClick: () => viewSubmission(row),
|
||
},
|
||
() => "查看代码",
|
||
),
|
||
},
|
||
]
|
||
|
||
onMounted(loadData)
|
||
</script>
|
||
|
||
<template>
|
||
<n-flex vertical>
|
||
<n-flex justify="space-between" align="center">
|
||
<n-flex align="center">
|
||
<h2 style="margin: 0">比赛辅助检查</h2>
|
||
<n-tag type="info" size="large"> 总计: {{ stats.total }} </n-tag>
|
||
<n-tag type="success" size="large"> 已检查: {{ stats.checked }} </n-tag>
|
||
<n-tag type="warning" size="large">
|
||
未检查: {{ stats.unchecked }}
|
||
</n-tag>
|
||
</n-flex>
|
||
<n-button
|
||
type="primary"
|
||
:disabled="stats.unchecked === 0"
|
||
@click="markAllAsChecked"
|
||
>
|
||
标记全部为已检查
|
||
</n-button>
|
||
</n-flex>
|
||
|
||
<n-alert type="info" style="margin-bottom: 16px">
|
||
<template #header>使用说明</template>
|
||
此工具用于赛后人工审核代码,检查是否存在抄袭、作弊等行为。请逐个查看通过(AC)的提交代码,检查完成后勾选"已检查"。
|
||
</n-alert>
|
||
|
||
<n-flex align="center" style="margin-bottom: 16px">
|
||
<n-input
|
||
v-model:value="query.username"
|
||
placeholder="筛选用户名"
|
||
style="width: 150px"
|
||
clearable
|
||
/>
|
||
<n-input
|
||
v-model:value="query.problemId"
|
||
placeholder="筛选题目"
|
||
style="width: 150px"
|
||
clearable
|
||
/>
|
||
<n-select
|
||
v-model:value="query.checked"
|
||
:options="checkedOptions"
|
||
style="width: 120px"
|
||
/>
|
||
</n-flex>
|
||
|
||
<n-data-table
|
||
:columns="columns"
|
||
:data="filteredSubmissions"
|
||
:pagination="{ pageSize: 20 }"
|
||
:bordered="false"
|
||
/>
|
||
|
||
<n-modal
|
||
v-model:show="codePanel"
|
||
preset="card"
|
||
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
||
:content-style="{ overflow: 'auto' }"
|
||
title="代码详情"
|
||
>
|
||
<SubmissionDetail
|
||
v-if="currentSubmission"
|
||
:submission="currentSubmission"
|
||
:problemID="currentSubmission.problemDisplayId"
|
||
:submissionID="currentSubmission.id"
|
||
hideList
|
||
@copied="toggleCodePanel(false)"
|
||
/>
|
||
</n-modal>
|
||
</n-flex>
|
||
</template>
|
||
|
||
<style scoped>
|
||
:deep(.n-data-table) {
|
||
margin-top: 16px;
|
||
}
|
||
</style>
|