接上 vue-tsc 时有 143 条既有错误,上一批迁移带走 89 条,这里把剩下的清完。
大部分是噪音(未使用的回调参数、死变量、少数 unknown),但里面躺着两个真问题:
- 个人主页查不存在的用户会抛:getProfile 返回 null 时后面照样取
.acmProblemsStatus,靠 `!` 压着。加了早返回。
- getProblemSetProgress 我上一批标错了类型:后台这个接口返回的是裸数组,
不是分页信封(和 oj 侧的 /user-progress 不一样)。已改正并加注释区分。
其余处理:
- 未使用的回调参数按 TS 约定加 `_` 前缀(v-for 的项、供子类重写的空钩子、
路由守卫的 from);确认无用的局部变量直接删(clickX/clickY 算了点击位置
但飞出方向用的是随机角度,hasToday 下面已经用 lastDateOnly 判过,
prefix 算了周/月但标签里没用上)。
- App.vue 的 highlight.js 注册:Promise.all([...]).then(m => m.map(x => x.default))
会把元组塌成 (HLJSApi | LanguageFn)[],hljs 上就找不到 registerLanguage。
改成逐个取 .default。
- 给一批 api 补上契约类型(getMetrics / getHitokoto / getTutorials /
formatCode / getProblemBeatRate / getClassUsernames 等),契约相应补了
8 个 z.infer 导出。
- ContestRank.submissionInfo 和 AcmHelperItem.acInfo 在 api 边界窄化成
SubmissionInfo —— 契约里是 Record<string, unknown>(JSONB 原文)。
- FlowchartEditor 的 TS2589:vue-flow 的 Node 嵌套太深,ref<Node[]>([]) 的
UnwrapRef 推导撞上实例化层级上限,改成 ref([]) as Ref<Node[]>。
- api2.ts 的响应拦截器**故意**不返回 AxiosResponse(要把 { data } 信封剥掉),
类型上确实说不通,没硬掰,写注释说明为什么用断言。
- 题目列表的「随机」按钮在模板里一直是注释状态,对应的 getRandom 和
getRandomProblemID 一并删除(后端 /problems/random 保留不动)。
验证:vue-tsc 0 条,apps/api tsc、check:routes、web build 全通过;
修过 key 的列都打接口确认过字段真实存在。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
4.7 KiB
Vue
201 lines
4.7 KiB
Vue
<template>
|
||
<n-card :title="title" size="small">
|
||
<template #header-extra>
|
||
<n-text depth="3" style="font-size: 12px"> 全面评估学习情况 </n-text>
|
||
</template>
|
||
<div class="chart">
|
||
<Chart type="bar" :data="data" :options="options" />
|
||
</div>
|
||
</n-card>
|
||
</template>
|
||
<script setup lang="ts">
|
||
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
|
||
import { Chart } from "vue-chartjs"
|
||
import {
|
||
Chart as ChartJS,
|
||
CategoryScale,
|
||
LinearScale,
|
||
BarElement,
|
||
LineElement,
|
||
PointElement,
|
||
Title,
|
||
Tooltip,
|
||
Legend,
|
||
Colors,
|
||
LineController,
|
||
} from "chart.js"
|
||
import { useAIStore } from "oj/store/ai"
|
||
import { parseTime } from "utils/functions"
|
||
|
||
// 注册混合图表(Bar + Line)所需的 Chart.js 组件
|
||
ChartJS.register(
|
||
CategoryScale,
|
||
LinearScale,
|
||
BarElement,
|
||
LineElement,
|
||
PointElement,
|
||
Title,
|
||
Tooltip,
|
||
Legend,
|
||
Colors,
|
||
LineController,
|
||
)
|
||
|
||
const aiStore = useAIStore()
|
||
|
||
const gradeOrder = ["C", "B", "A", "S"] as const
|
||
|
||
const title = computed(() => {
|
||
if (aiStore.duration === "months:2") {
|
||
return "过去两个月的每周综合情况"
|
||
} else if (aiStore.duration === "months:6") {
|
||
return "过去半年的每月综合情况"
|
||
} else if (aiStore.duration === "years:1") {
|
||
return "过去一年的每月综合情况"
|
||
} else {
|
||
return "过去四周的综合情况"
|
||
}
|
||
})
|
||
|
||
const data = computed<ChartData<"bar" | "line">>(() => {
|
||
return {
|
||
labels: aiStore.durationData.map((duration) => {
|
||
return [
|
||
parseTime(duration.start, "M月D日"),
|
||
parseTime(duration.end, "M月D日"),
|
||
].join("~")
|
||
}),
|
||
datasets: [
|
||
{
|
||
type: "bar",
|
||
label: "完成题目数",
|
||
data: aiStore.durationData.map((duration) => duration.problemCount),
|
||
yAxisID: "y",
|
||
order: 2,
|
||
},
|
||
{
|
||
type: "bar",
|
||
label: "总提交次数",
|
||
data: aiStore.durationData.map((duration) => duration.submissionCount),
|
||
yAxisID: "y",
|
||
order: 2,
|
||
},
|
||
{
|
||
type: "line",
|
||
label: "等级",
|
||
data: aiStore.durationData.map((duration) =>
|
||
duration.grade ? gradeOrder.indexOf(duration.grade) : null,
|
||
),
|
||
spanGaps: false,
|
||
tension: 0.4,
|
||
yAxisID: "y1",
|
||
barThickness: 10,
|
||
order: 1,
|
||
borderWidth: 2,
|
||
pointRadius: 4,
|
||
pointHoverRadius: 6,
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const options = computed<ChartOptions<"bar" | "line">>(() => {
|
||
return {
|
||
interaction: {
|
||
intersect: false,
|
||
},
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
x: {
|
||
grid: {
|
||
display: false,
|
||
},
|
||
},
|
||
y: {
|
||
ticks: {
|
||
stepSize: 1,
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: "数量",
|
||
},
|
||
beginAtZero: true,
|
||
},
|
||
y1: {
|
||
type: "linear",
|
||
position: "right",
|
||
min: -0.5,
|
||
max: gradeOrder.length - 0.5,
|
||
ticks: {
|
||
stepSize: 1,
|
||
callback: (v) => {
|
||
const idx = Number(v)
|
||
return gradeOrder[idx] || ""
|
||
},
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: "等级",
|
||
},
|
||
grid: {
|
||
display: false,
|
||
},
|
||
},
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
position: "bottom" as const,
|
||
labels: {
|
||
boxWidth: 12,
|
||
padding: 8,
|
||
font: {
|
||
size: 11,
|
||
},
|
||
},
|
||
},
|
||
title: {
|
||
display: false,
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: (ctx: TooltipItem<"bar" | "line">) => {
|
||
const dsLabel = ctx.dataset.label || ""
|
||
if ((ctx.dataset as any).yAxisID === "y1") {
|
||
const idx = Number(ctx.parsed.y)
|
||
return `${dsLabel}: ${gradeOrder[idx] || ""}`
|
||
}
|
||
return `${dsLabel}: ${ctx.formattedValue}`
|
||
},
|
||
footer: (items: TooltipItem<"bar" | "line">[]) => {
|
||
const barItems = items.filter(
|
||
(item) => (item.dataset as any).yAxisID === "y",
|
||
)
|
||
if (barItems.length >= 2) {
|
||
const problemCount =
|
||
barItems.find((item) => item.dataset.label === "完成题目数")
|
||
?.parsed.y || 0
|
||
const submissionCount =
|
||
barItems.find((item) => item.dataset.label === "总提交次数")
|
||
?.parsed.y || 0
|
||
const efficiency =
|
||
submissionCount > 0
|
||
? ((problemCount / submissionCount) * 100).toFixed(1)
|
||
: "0"
|
||
return `AC率: ${efficiency}%`
|
||
}
|
||
return ""
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
})
|
||
</script>
|
||
<style scoped>
|
||
.chart {
|
||
height: 300px;
|
||
width: 100%;
|
||
}
|
||
</style>
|