Files
OJ2/apps/web/src/admin/contest/helper.vue
yuetsh 0b7b08f2cc
Some checks failed
Deploy / deploy (push) Has been cancelled
refactor(前端): 去掉 { error, data } 信封,api2 改名 api
信封是 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>
2026-08-25 22:42:20 -06:00

321 lines
8.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>