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>
462 lines
12 KiB
Vue
462 lines
12 KiB
Vue
<template>
|
||
<n-flex align="center">
|
||
<n-input
|
||
placeholder="用户(可选)"
|
||
v-model:value="query.username"
|
||
style="width: 150px"
|
||
clearable
|
||
/>
|
||
<n-input
|
||
placeholder="题号(可选)"
|
||
v-model:value="query.problem"
|
||
style="width: 120px"
|
||
clearable
|
||
/>
|
||
<n-select
|
||
style="width: 120px"
|
||
v-model:value="query.duration"
|
||
:options="options"
|
||
/>
|
||
<n-button type="primary" @click="handleStatistics">统计</n-button>
|
||
<n-button v-if="route.name !== 'submissions'" @click="goSubmissions">
|
||
前往提交列表
|
||
</n-button>
|
||
</n-flex>
|
||
|
||
<n-empty
|
||
v-if="count.total === 0"
|
||
description="暂无数据"
|
||
style="margin: 40px 0"
|
||
/>
|
||
|
||
<template v-if="count.total > 0">
|
||
<n-divider style="margin: 16px 0" />
|
||
<n-flex justify="space-around">
|
||
<div class="stat-item">
|
||
<n-text>总提交</n-text>
|
||
<n-gradient-text type="info" font-size="28">{{
|
||
count.total
|
||
}}</n-gradient-text>
|
||
</div>
|
||
<div class="stat-item">
|
||
<n-text>正确提交</n-text>
|
||
<n-gradient-text type="primary" font-size="28">{{
|
||
count.accepted
|
||
}}</n-gradient-text>
|
||
</div>
|
||
<div class="stat-item">
|
||
<n-text>正确率</n-text>
|
||
<n-gradient-text type="warning" font-size="28"
|
||
>{{ count.rate }}%</n-gradient-text
|
||
>
|
||
</div>
|
||
<template v-if="person.count > 0">
|
||
<div class="stat-item">
|
||
<n-text>完成人数</n-text>
|
||
<n-gradient-text type="error" font-size="28">{{
|
||
list.length
|
||
}}</n-gradient-text>
|
||
</div>
|
||
<div class="stat-item">
|
||
<n-text>班级人数</n-text>
|
||
<n-gradient-text type="warning" font-size="28">{{
|
||
adjustedPersonCount
|
||
}}</n-gradient-text>
|
||
</div>
|
||
<div class="stat-item">
|
||
<n-text>完成度</n-text>
|
||
<n-gradient-text type="success" font-size="28">{{
|
||
adjustedPersonRate
|
||
}}</n-gradient-text>
|
||
</div>
|
||
</template>
|
||
</n-flex>
|
||
<n-divider style="margin: 16px 0" />
|
||
|
||
<n-tabs animated type="line">
|
||
<n-tab-pane name="charts" tab="数据图表">
|
||
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
|
||
<n-gi>
|
||
<n-card title="提交正确率">
|
||
<Doughnut :data="pieChartData" :options="pieChartOptions" />
|
||
</n-card>
|
||
</n-gi>
|
||
<n-gi v-if="person.count > 0">
|
||
<n-card title="班级完成度">
|
||
<Doughnut
|
||
:data="completionChartData"
|
||
:options="completionChartOptions"
|
||
/>
|
||
</n-card>
|
||
</n-gi>
|
||
</n-grid>
|
||
</n-tab-pane>
|
||
|
||
<n-tab-pane name="submissions" tab="提交记录">
|
||
<n-data-table
|
||
v-if="list.length"
|
||
striped
|
||
:columns="columns"
|
||
:data="list"
|
||
:row-key="rowKey"
|
||
:expanded-row-keys="expandedRowKeys"
|
||
@update:expanded-row-keys="updateExpandedRowKeys"
|
||
:row-props="rowProps"
|
||
style="margin-top: 12px"
|
||
/>
|
||
</n-tab-pane>
|
||
|
||
<n-tab-pane
|
||
name="unaccepted"
|
||
:tab="`未完成(${visibleUnaccepted.length})`"
|
||
>
|
||
<n-flex align="center" style="margin: 12px 0">
|
||
<n-switch v-model:value="hideMode" size="large">
|
||
<template #checked>请假隐藏中</template>
|
||
<template #unchecked>请假隐藏</template>
|
||
</n-switch>
|
||
<n-button
|
||
v-if="hiddenCount > 0"
|
||
size="small"
|
||
type="info"
|
||
@click="showAll"
|
||
>
|
||
恢复 {{ hiddenCount }} 位
|
||
</n-button>
|
||
</n-flex>
|
||
<n-flex size="large" align="center">
|
||
<n-gradient-text
|
||
v-if="visibleUnaccepted.length === 0"
|
||
font-size="24"
|
||
type="success"
|
||
>
|
||
全都完成了
|
||
</n-gradient-text>
|
||
<template v-for="item in visibleUnaccepted" :key="item.username">
|
||
<n-tag
|
||
v-if="hideMode"
|
||
closable
|
||
size="large"
|
||
style="font-size: 20px"
|
||
@close="hideStudent(item.username)"
|
||
>
|
||
{{ item.realName }}
|
||
</n-tag>
|
||
<span v-else style="font-size: 24px">{{ item.realName }}</span>
|
||
</template>
|
||
</n-flex>
|
||
</n-tab-pane>
|
||
</n-tabs>
|
||
</template>
|
||
</template>
|
||
<script setup lang="ts">
|
||
import { h } from "vue"
|
||
import { formatISO, sub, type Duration } from "date-fns"
|
||
import { getSubmissionStatistics } from "oj/api"
|
||
import { DURATION_OPTIONS } from "utils/constants"
|
||
import { Doughnut } from "vue-chartjs"
|
||
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
||
import { NButton, NFlex, NText, type DataTableRowKey } from "naive-ui"
|
||
import { JUDGE_STATUS } from "utils/constants"
|
||
import type { SubmissionStatisticsUser, UnacceptedStudent } from "@oj2/contract"
|
||
|
||
// 注册 Chart.js 组件
|
||
ChartJS.register(ArcElement, Title, Tooltip, Legend)
|
||
|
||
interface Props {
|
||
problem: string
|
||
username: string
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
|
||
const options: SelectOption[] = [
|
||
{ label: "10分钟内", value: "minutes:10" },
|
||
{ label: "20分钟内", value: "minutes:20" },
|
||
{ label: "30分钟内", value: "minutes:30" },
|
||
...DURATION_OPTIONS,
|
||
{ label: "全部时段", value: "all" },
|
||
]
|
||
|
||
function openSubmission(id: string) {
|
||
window.open(`/submission/${id}`, "_blank", "noopener")
|
||
}
|
||
|
||
const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
|
||
{
|
||
type: "expand",
|
||
renderExpand: (row) => {
|
||
return h(NFlex, { size: "small", wrap: true }, () =>
|
||
row.submissionItems.map((item) =>
|
||
h(
|
||
NButton,
|
||
{
|
||
size: "small",
|
||
tertiary: true,
|
||
type: JUDGE_STATUS[item.result]?.type ?? "default",
|
||
style: "width: 120px",
|
||
onClick: (event: MouseEvent) => {
|
||
event.stopPropagation()
|
||
openSubmission(item.id)
|
||
},
|
||
},
|
||
() => item.id.toString().slice(0, 12),
|
||
),
|
||
),
|
||
)
|
||
},
|
||
},
|
||
{ title: "用户", key: "username" },
|
||
{ title: "提交数", key: "submissionCount" },
|
||
{ title: "已解决", key: "acceptedCount" },
|
||
// 新后端返回的是数值,百分号在这里补 —— 旧后端直接返回 "85.5%" 字符串
|
||
{
|
||
title: "正确率",
|
||
key: "correctRate",
|
||
render: (row) => `${row.correctRate}%`,
|
||
},
|
||
]
|
||
|
||
const query = reactive({
|
||
username: props.username,
|
||
problem: props.problem,
|
||
duration: options[0].value,
|
||
})
|
||
|
||
const count = reactive({
|
||
total: 0,
|
||
accepted: 0,
|
||
rate: 0,
|
||
})
|
||
const person = reactive({
|
||
count: 0,
|
||
rate: 0,
|
||
})
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
|
||
const list = ref<SubmissionStatisticsUser[]>([])
|
||
const listUnaccepted = ref<UnacceptedStudent[]>([])
|
||
const expandedRowKeys = ref<DataTableRowKey[]>([])
|
||
|
||
const HIDE_DURATION = 2 * 60 * 60 * 1000
|
||
const STORAGE_KEY = "oj_hidden_students"
|
||
|
||
function loadHidden(): Record<string, number> {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
const hiddenStudents = ref<Record<string, number>>(loadHidden())
|
||
const hideMode = ref(false)
|
||
|
||
function saveHidden(data: Record<string, number>) {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
||
}
|
||
|
||
function hideStudent(username: string) {
|
||
hiddenStudents.value = {
|
||
...hiddenStudents.value,
|
||
[username]: Date.now() + HIDE_DURATION,
|
||
}
|
||
saveHidden(hiddenStudents.value)
|
||
}
|
||
|
||
function showAll() {
|
||
hiddenStudents.value = {}
|
||
saveHidden({})
|
||
}
|
||
|
||
const visibleUnaccepted = computed(() => {
|
||
const now = Date.now()
|
||
return listUnaccepted.value.filter((item) => {
|
||
const exp = hiddenStudents.value[item.username]
|
||
return !exp || exp <= now
|
||
})
|
||
})
|
||
|
||
const hiddenCount = computed(() => {
|
||
const now = Date.now()
|
||
return listUnaccepted.value.filter((item) => {
|
||
const exp = hiddenStudents.value[item.username]
|
||
return !!exp && exp > now
|
||
}).length
|
||
})
|
||
|
||
const adjustedPersonCount = computed(() => person.count - hiddenCount.value)
|
||
|
||
const adjustedPersonRate = computed(() => {
|
||
if (adjustedPersonCount.value <= 0) return "0%"
|
||
const rate = Math.min(
|
||
100,
|
||
(list.value.length / adjustedPersonCount.value) * 100,
|
||
)
|
||
return `${Math.round(rate * 100) / 100}%`
|
||
})
|
||
|
||
onMounted(() => {
|
||
const now = Date.now()
|
||
const cleaned = Object.fromEntries(
|
||
Object.entries(hiddenStudents.value).filter(([, exp]) => exp > now),
|
||
)
|
||
hiddenStudents.value = cleaned
|
||
saveHidden(cleaned)
|
||
})
|
||
|
||
// 饼图数据 - 提交正确率分布
|
||
const pieChartData = computed(() => {
|
||
const wrongCount = count.total - count.accepted
|
||
return {
|
||
labels: ["正确提交", "错误提交"],
|
||
datasets: [
|
||
{
|
||
label: "提交数",
|
||
data: [count.accepted, wrongCount],
|
||
backgroundColor: ["rgba(75, 192, 192, 0.6)", "rgba(255, 99, 132, 0.6)"],
|
||
borderColor: ["rgba(75, 192, 192, 1)", "rgba(255, 99, 132, 1)"],
|
||
borderWidth: 2,
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const pieChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: "bottom" as const,
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function (context: any) {
|
||
const label = context.label || ""
|
||
const value = context.parsed || 0
|
||
const total = context.dataset.data.reduce(
|
||
(a: number, b: number) => a + b,
|
||
0,
|
||
)
|
||
const percentage = ((value / total) * 100).toFixed(1)
|
||
return `${label}: ${value} (${percentage}%)`
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
// 环形图数据 - 班级完成度
|
||
const completionChartData = computed(() => {
|
||
const completedCount = list.value.length
|
||
const uncompletedCount = Math.max(
|
||
0,
|
||
adjustedPersonCount.value - completedCount,
|
||
)
|
||
return {
|
||
labels: ["已完成", "未完成"],
|
||
datasets: [
|
||
{
|
||
label: "人数",
|
||
data: [completedCount, uncompletedCount],
|
||
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
|
||
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
|
||
borderWidth: 2,
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const completionChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: "bottom" as const,
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function (context: any) {
|
||
const label = context.label || ""
|
||
const value = context.parsed || 0
|
||
const total = context.dataset.data.reduce(
|
||
(a: number, b: number) => a + b,
|
||
0,
|
||
)
|
||
const percentage = ((value / total) * 100).toFixed(1)
|
||
return `${label}: ${value} (${percentage}%)`
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
const subOptions = computed<Duration>(() => {
|
||
let dur = options.find((it) => it.value === query.duration) ?? options[0]
|
||
const x = dur.value!.toString().split(":")
|
||
const unit = x[0]
|
||
const n = x[1]
|
||
return { [unit]: parseInt(n) }
|
||
})
|
||
|
||
function goSubmissions() {
|
||
router.push({
|
||
name: "submissions",
|
||
query: {
|
||
username: query.username,
|
||
problem: query.problem,
|
||
},
|
||
})
|
||
}
|
||
async function handleStatistics() {
|
||
const current = Date.now()
|
||
const end = formatISO(current)
|
||
const duration =
|
||
query.duration === "all"
|
||
? { end }
|
||
: { start: formatISO(sub(current, subOptions.value)), end }
|
||
const res = await getSubmissionStatistics(
|
||
duration,
|
||
query.problem,
|
||
query.username,
|
||
)
|
||
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 {
|
||
return row.username
|
||
}
|
||
|
||
function updateExpandedRowKeys(keys: DataTableRowKey[]) {
|
||
expandedRowKeys.value = keys.slice(-1)
|
||
}
|
||
|
||
function rowProps(row: SubmissionStatisticsUser) {
|
||
return {
|
||
style: "cursor: pointer;",
|
||
onClick: () => {
|
||
const key = rowKey(row)
|
||
const isExpanded = expandedRowKeys.value.includes(key)
|
||
expandedRowKeys.value = isExpanded ? [] : [key]
|
||
},
|
||
}
|
||
}
|
||
</script>
|
||
<style scoped>
|
||
.stat-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
</style>
|