Files
OJ2/apps/web/src/oj/class/pk.vue
yuetsh b5ba56ccd0 refactor(契约): 判题状态码收进契约唯一一份,并收紧 18 处 as any
- 状态码常量与 judgeStatusSchema 移到 packages/contract/src/judge-status.ts,
  后端 judge/status.ts 只再导出;前端 SubmissionStatus 枚举加编译期断言对齐契约
  (实测改坏一个码会当场类型检查失败)
- 类型逃逸 22 处降到 4 处:collab/handler、pagination、configUpdate、
  ExerciseManager、ProblemSubmission、pk.vue tooltip;剩下的是词云插件无类型、
  生成的 .d.ts、skulpt 和 TextEditor

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 04:40:30 -06:00

1223 lines
35 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 { ClassComparison } from "utils/types"
import { LONG_DURATION_OPTIONS } from "utils/constants"
import { durationFromValue } from "utils/functions"
import { h } from "vue"
import { formatISO, sub, type Duration } from "date-fns"
import { getClassPK } from "oj/api"
import { useConfigStore } from "shared/store/config"
import { useUserStore } from "shared/store/user"
import { Icon } from "@iconify/vue"
import { Bar, Radar } from "vue-chartjs"
import { useBreakpoints } from "shared/composables/breakpoints"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
RadialLinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Colors,
Filler,
type TooltipItem,
} from "chart.js"
// 注册Chart.js组件
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
RadialLinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Colors,
Filler,
)
const configStore = useConfigStore()
const { isTeacherOrAbove } = useUserStore()
const message = useMessage()
const { isDesktop } = useBreakpoints()
const selectedClasses = ref<string[]>([])
const comparisons = ref<ClassComparison[]>([])
const duration = ref<string>("")
const loading = ref(false)
const hasTimeRange = ref(false)
const aiLoading = ref(false)
const aiContent = ref("")
const showAIModal = ref(false)
let aiController: AbortController | null = null
// 长时段和榜单页同一份LONG_DURATION_OPTIONS外加一个「全部时间」
const timeRangeOptions: SelectOption[] = [
{ label: "全部时间", value: "" },
...LONG_DURATION_OPTIONS,
]
// 「全部时间」的 value 是空串,解不出来就是 null —— 正是不带时间条件的意思
const subOptions = computed<Duration | null>(() =>
durationFromValue(duration.value),
)
// 根据时间段选项计算开始和结束时间
function getTimeRange(): {
startTime?: string
endTime?: string
} {
if (!duration.value || duration.value === "" || !subOptions.value) {
return {}
}
const current = Date.now()
const startTime = formatISO(sub(current, subOptions.value))
const endTime = formatISO(current)
return {
startTime,
endTime,
}
}
const classOptions = computed(() => {
return (
configStore.config?.classList.map((item) => ({
label: `${item.slice(0, 2)}计算机${item.slice(2)}`,
value: item,
})) ?? []
)
})
async function compare() {
if (selectedClasses.value.length < 2) {
message.warning("请至少选择2个班级")
return
}
loading.value = true
try {
const { startTime, endTime } = getTimeRange()
const res = await getClassPK(selectedClasses.value, startTime, endTime)
comparisons.value = res.comparisons
hasTimeRange.value = res.hasTimeRange || false
} catch (error) {
message.error("获取数据失败")
} finally {
loading.value = false
}
}
async function analyzeWithAI() {
if (aiController) {
aiController.abort()
}
const controller = new AbortController()
aiController = controller
const timeRangeLabel =
timeRangeOptions.find((o) => o.value === duration.value)?.label ??
"全部时间"
showAIModal.value = true
aiContent.value = ""
aiLoading.value = true
try {
const response = await fetch("/api/ai/class-pk-analysis", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
comparisons: comparisons.value,
timeRangeLabel,
}),
signal: controller.signal,
})
if (!response.ok) throw await aiStreamError(response)
let hasStarted = false
await consumeJSONEventStream(response, {
signal: controller.signal,
onEvent(event) {
if (event === "end" && !hasStarted) aiLoading.value = false
},
onMessage(payload) {
const parsed = payload as {
type?: string
content?: string
message?: string
}
if (parsed.type === "delta" && parsed.content) {
if (!hasStarted) {
hasStarted = true
aiLoading.value = false
}
aiContent.value += parsed.content
} else if (parsed.type === "error") {
throw new Error(parsed.message || "AI 服务异常")
} else if (parsed.type === "done" && !hasStarted) {
aiLoading.value = false
}
},
})
} catch (error: any) {
if (controller.signal.aborted) return
message.error(error?.message || "AI 分析失败,请稍后再试")
aiLoading.value = false
} finally {
if (aiController === controller) aiController = null
}
}
// 计算排名颜色
function getRankColor(index: number) {
if (index === 0) return { type: "success" as const, text: "1" }
if (index === 1) return { type: "info" as const, text: "2" }
if (index === 2) return { type: "warning" as const, text: "3" }
return { type: "default" as const, text: `${index + 1}` }
}
// 获取班级颜色
function getClassColor(index: number) {
const colors = [
{ bg: "rgba(24, 160, 88, 0.2)", border: "rgba(24, 160, 88, 0.8)" }, // success
{ bg: "rgba(32, 128, 240, 0.2)", border: "rgba(32, 128, 240, 0.8)" }, // info
{ bg: "rgba(240, 160, 32, 0.2)", border: "rgba(240, 160, 32, 0.8)" }, // warning
{ bg: "rgba(208, 48, 80, 0.2)", border: "rgba(208, 48, 80, 0.8)" }, // error
{ bg: "rgba(128, 90, 213, 0.2)", border: "rgba(128, 90, 213, 0.8)" }, // purple
{ bg: "rgba(0, 184, 148, 0.2)", border: "rgba(0, 184, 148, 0.8)" }, // teal
{ bg: "rgba(63, 81, 181, 0.2)", border: "rgba(63, 81, 181, 0.8)" }, // indigo
{ bg: "rgba(0, 172, 193, 0.2)", border: "rgba(0, 172, 193, 0.8)" }, // cyan
{ bg: "rgba(124, 179, 66, 0.2)", border: "rgba(124, 179, 66, 0.8)" }, // lime
{ bg: "rgba(233, 30, 99, 0.2)", border: "rgba(233, 30, 99, 0.8)" }, // pink
]
return colors[index % colors.length]
}
// 综合分对比图
const compositeScoreChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "综合分",
data: comparisons.value.map((c) => c.compositeScore),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 总AC数对比图 - 每个班级用不同颜色
const totalAcChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "总AC数",
data: comparisons.value.map((c) => c.totalAc),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 平均AC数对比图
const avgAcChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "平均AC数",
data: comparisons.value.map((c) => c.avgAc),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 中位数AC数对比图
const medianAcChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "中位数AC数",
data: comparisons.value.map((c) => c.medianAc),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 优秀率对比图
const excellentRateChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "优秀率",
data: comparisons.value.map((c) => c.excellentRate),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 及格率对比图
const passRateChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "及格率",
data: comparisons.value.map((c) => c.passRate),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 参与度对比图
const activeRateChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "参与度",
data: comparisons.value.map((c) => c.activeRate),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 前10%平均对比图
const top10AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "前10%平均",
data: comparisons.value.map((c) => c.top10Avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 后10%平均对比图
const bottom10AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "后10%平均",
data: comparisons.value.map((c) => c.bottom10Avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 中间80%均值对比图
const middle80AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "中间80%均值",
data: comparisons.value.map((c) => c.middle80Avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 雷达图数据 - 多维度综合对比
const radarChartData = computed(() => {
if (comparisons.value.length === 0) return null
// 归一化数据到0-100范围
const normalize = (value: number, max: number, min: number) => {
if (max === min) return 50
return ((value - min) / (max - min)) * 100
}
const metrics = [
"总AC数",
"平均AC数",
"中位数AC数",
"优秀率",
"及格率",
"参与度",
]
// 计算每个指标的最大最小值
const maxValues = [
Math.max(...comparisons.value.map((c) => c.totalAc)),
Math.max(...comparisons.value.map((c) => c.avgAc)),
Math.max(...comparisons.value.map((c) => c.medianAc)),
100, // 优秀率最大值
100, // 及格率最大值
100, // 参与度最大值
]
const minValues = [
Math.min(...comparisons.value.map((c) => c.totalAc)),
Math.min(...comparisons.value.map((c) => c.avgAc)),
Math.min(...comparisons.value.map((c) => c.medianAc)),
0,
0,
0,
]
const datasets = comparisons.value.map((c, index) => {
const color = getClassColor(index)
const rawData = [
c.totalAc,
c.avgAc,
c.medianAc,
c.excellentRate,
c.passRate,
c.activeRate,
]
return {
label: c.className,
data: [
normalize(c.totalAc, maxValues[0], minValues[0]),
normalize(c.avgAc, maxValues[1], minValues[1]),
normalize(c.medianAc, maxValues[2], minValues[2]),
c.excellentRate,
c.passRate,
c.activeRate,
],
rawData,
backgroundColor: color.bg,
borderColor: color.border,
borderWidth: 2,
pointBackgroundColor: color.border,
pointBorderColor: "#fff",
pointHoverBackgroundColor: "#fff",
pointHoverBorderColor: color.border,
}
})
return {
labels: metrics,
datasets,
}
})
// 图表配置 - 优化对比效果
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom" as const,
display: true,
labels: {
boxWidth: 0,
padding: 10,
},
},
tooltip: {
mode: "index" as const,
intersect: false,
callbacks: {
label: function (context: any) {
let label = context.dataset.label || ""
if (label) {
label += ": "
}
if (context.parsed.y !== null) {
label += context.parsed.y.toFixed(2)
}
return label
},
},
},
datalabels: {
display: false,
},
},
scales: {
y: {
beginAtZero: true,
grid: {
display: true,
color: "rgba(0, 0, 0, 0.05)",
},
},
x: {
grid: {
display: false,
},
},
},
}
const compositeScoreChartOptions = {
...chartOptions,
scales: {
...chartOptions.scales,
y: {
...chartOptions.scales.y,
max: 100,
},
},
}
const tableColumns: DataTableColumn<ClassComparison>[] = [
{
title: "排名",
key: "rank",
render: (_, index) => getRankColor(index).text,
width: 80,
},
{
title: "综合分",
key: "composite_score",
width: 90,
render: (row) =>
h(
"span",
{
style: {
color: "#722ed1",
fontWeight: "700",
fontSize: "15px",
},
},
row.compositeScore.toFixed(1),
),
},
{
title: "班级",
key: "class_name",
render: (row) =>
`${row.className.slice(0, 2)}计算机${row.className.slice(2)}`,
width: 160,
},
{
title: "人数",
key: "user_count",
width: 80,
render: (row) =>
h(
"span",
{ style: { color: "#1890ff", fontWeight: "600" } },
row.userCount,
),
},
{
title: "总AC数",
key: "total_ac",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#ff4d4f", fontWeight: "600" } },
row.totalAc,
),
},
{
title: "平均AC",
key: "avg_ac",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#52c41a", fontWeight: "600" } },
row.avgAc.toFixed(2),
),
},
{
title: "中位数AC",
key: "median_ac",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#fa8c16", fontWeight: "600" } },
row.medianAc.toFixed(2),
),
},
{
title: "前10%均值",
key: "top10_avg",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#cf1322", fontWeight: "600" } },
row.top10Avg.toFixed(2),
),
},
{
title: "中间80%均值",
key: "middle80_avg",
width: 110,
render: (row) =>
h(
"span",
{ style: { color: "#389e0d", fontWeight: "600" } },
row.middle80Avg.toFixed(2),
),
},
{
title: "后10%均值",
key: "bottom10_avg",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#096dd9", fontWeight: "500" } },
row.bottom10Avg.toFixed(2),
),
},
{
title: "优秀率",
key: "excellent_rate",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#faad14", fontWeight: "600" } },
row.excellentRate.toFixed(1) + "%",
),
},
{
title: "及格率",
key: "pass_rate",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#52c41a", fontWeight: "600" } },
row.passRate.toFixed(1) + "%",
),
},
{
title: "参与度",
key: "active_rate",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#1890ff", fontWeight: "600" } },
row.activeRate.toFixed(1) + "%",
),
},
]
const radarChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom" as const,
},
tooltip: {
callbacks: {
label: function (context: TooltipItem<"radar">) {
// rawData 是我们自己塞进 dataset 的扩展字段chart.js 的类型里没有
const dataset = context.dataset as typeof context.dataset & {
rawData?: (number | null)[]
}
const rawValue = dataset.rawData?.[context.dataIndex]
const metric = context.label || ""
const isRate = context.dataIndex >= 3
if (rawValue === undefined || rawValue === null) {
return `${dataset.label || ""}: ${context.parsed.r?.toFixed(2) ?? ""}`
}
const formatted = Number.isFinite(rawValue)
? isRate
? rawValue.toFixed(1)
: Number.isInteger(rawValue)
? rawValue.toString()
: rawValue.toFixed(2)
: String(rawValue)
const suffix = isRate ? "%" : ""
return `${dataset.label || ""} - ${metric}: ${formatted}${suffix}`
},
},
},
},
scales: {
r: {
beginAtZero: true,
max: 100,
ticks: {
stepSize: 20,
},
},
},
}
</script>
<template>
<n-card>
<n-flex vertical :size="20">
<n-h2 style="margin-bottom: 0">班级PK</n-h2>
<n-flex :wrap="false" align="flex-start" :size="16">
<n-form-item
label="选择班级至少2个"
style="width: 300px; margin-bottom: 0"
>
<n-select
v-model:value="selectedClasses"
:options="classOptions"
multiple
placeholder="选择要比较的班级"
/>
</n-form-item>
<n-form-item
label="时间段(可选)"
style="width: 200px; margin-bottom: 0"
>
<n-select
v-model:value="duration"
:options="timeRangeOptions"
clearable
placeholder="选择时间段"
style="width: 100%"
/>
</n-form-item>
<n-button
type="primary"
@click="compare"
:loading="loading"
style="margin-top: 26px"
>
开始PK
</n-button>
<n-button
v-if="isTeacherOrAbove"
type="info"
@click="analyzeWithAI"
:loading="aiLoading"
:disabled="comparisons.length === 0"
style="margin-top: 26px"
>
<template #icon>
<Icon icon="ph:sparkle" />
</template>
AI分析
</n-button>
</n-flex>
<n-modal
v-model:show="showAIModal"
preset="card"
title="AI 分析报告"
:style="{ width: '800px', maxWidth: '95vw' }"
>
<n-spin :show="aiLoading" :delay="50">
<div style="min-height: 200px">
<MdPreview v-if="aiContent" :model-value="aiContent" />
<n-flex
v-else-if="!aiLoading"
align="center"
justify="center"
style="min-height: 200px"
>
<n-empty description="暂无分析内容" />
</n-flex>
</div>
</n-spin>
</n-modal>
<!-- 班级对比卡片 -->
<n-grid v-if="comparisons.length > 0" :cols="2" :x-gap="16" :y-gap="16">
<n-gi
v-for="(classData, index) in comparisons"
:key="classData.className"
:span="isDesktop ? 1 : 2"
>
<n-card
:title="`${classData.className.slice(0, 2)}计算机${classData.className.slice(2)}班`"
:bordered="true"
hoverable
:style="{
borderTop: `4px solid ${getClassColor(index).border}`,
}"
>
<template #header-extra>
<n-tag :type="getRankColor(index).type" size="large">
#{{ getRankColor(index).text }}
<span style="margin-left: 6px; font-size: 12px; opacity: 0.85">
{{ classData.compositeScore }}
</span>
</n-tag>
</template>
<!-- 班级信息布局 -->
<n-flex vertical :size="12">
<!-- AC核心指标 -->
<n-grid :cols="5" :x-gap="8" responsive="screen">
<n-gi>
<n-statistic
label="总AC数"
:value="classData.totalAc"
size="large"
class="stat-total-ac"
>
<template #suffix>
<Icon icon="streamline-emojis:raised-fist-1" width="20" />
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic
label="平均AC数"
:value="classData.avgAc.toFixed(2)"
size="large"
class="stat-avg-ac"
>
<template #suffix>
<Icon
icon="streamline-ultimate-color:analytics-pie-2"
width="20"
/>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic
label="中位数AC数"
:value="classData.medianAc.toFixed(2)"
size="large"
class="stat-median-ac"
>
<template #suffix>
<Icon
icon="streamline-ultimate-color:cursor-target-1"
width="20"
/>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic
label="总提交数"
:value="classData.totalSubmission"
size="large"
class="stat-total-submission"
>
<template #suffix>
<Icon
icon="streamline-ultimate-color:common-file-text"
width="20"
/>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic
label="AC率"
:value="classData.acRate.toFixed(1) + '%'"
size="large"
class="stat-ac-rate"
>
<template #suffix>
<Icon icon="fluent-emoji:check-mark-button" width="20" />
</template>
</n-statistic>
</n-gi>
</n-grid>
<n-divider style="margin: 12px 0" />
<!-- 详细统计 - 紧凑布局统一格式 -->
<n-descriptions
bordered
:column="2"
size="small"
label-placement="left"
>
<!-- 分位数统计 -->
<n-descriptions-item label="第一四分位数(Q1)">
<span style="color: #9254de; font-weight: 500">{{
classData.q1Ac.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="第三四分位数(Q3)">
<span style="color: #f759ab; font-weight: 500">{{
classData.q3Ac.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="四分位距(IQR)">
<span style="color: #13c2c2; font-weight: 500">{{
classData.iqr.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="标准差">
<span style="color: #fa8c16; font-weight: 500">{{
classData.stdDev.toFixed(2)
}}</span>
</n-descriptions-item>
<!-- 分层统计 -->
<n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{
classData.top10Avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{
classData.middle80Avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{
classData.bottom10Avg.toFixed(2)
}}</span>
</n-descriptions-item>
<!-- 人数 -->
<n-descriptions-item label="人数">
<span style="color: #1890ff; font-weight: 600">{{
classData.userCount
}}</span>
</n-descriptions-item>
</n-descriptions>
<!-- 比率统计 - 使用进度条图表 -->
<n-card
size="small"
title="比率统计"
embedded
style="margin-top: 12px"
>
<n-space vertical :size="10">
<n-progress
type="line"
:percentage="classData.excellentRate"
:show-indicator="true"
:border-radius="4"
>
<template #default>
优秀率: {{ classData.excellentRate.toFixed(1) }}%
</template>
</n-progress>
<n-progress
type="line"
:percentage="classData.passRate"
:show-indicator="true"
:border-radius="4"
status="success"
>
<template #default>
及格率: {{ classData.passRate.toFixed(1) }}%
</template>
</n-progress>
<n-progress
type="line"
:percentage="classData.activeRate"
:show-indicator="true"
:border-radius="4"
status="info"
>
<template #default>
参与度: {{ classData.activeRate.toFixed(1) }}%
</template>
</n-progress>
</n-space>
</n-card>
<!-- 时间段统计如果有 -->
<template
v-if="hasTimeRange && classData.recentTotalAc !== undefined"
>
<n-descriptions
bordered
:column="2"
size="small"
label-placement="left"
style="margin-top: 12px"
>
<n-descriptions-item label="时间段总AC">
<span style="color: #ff7875; font-weight: 600">{{
classData.recentTotalAc
}}</span>
</n-descriptions-item>
<n-descriptions-item label="时间段平均AC">
<span style="color: #73d13d; font-weight: 600">{{
classData.recentAvgAc?.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="时间段中位数AC">
<span style="color: #ffc53d; font-weight: 600">{{
classData.recentMedianAc?.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="时间段前10名平均">
<span style="color: #ff4d4f; font-weight: 600">{{
classData.recentTop10Avg?.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="活跃学生数" :span="2">
<span style="color: #1890ff; font-weight: 600">{{
classData.recentActiveCount
}}</span>
</n-descriptions-item>
</n-descriptions>
</template>
</n-flex>
</n-card>
</n-gi>
</n-grid>
<!-- 可视化图表 - 专注于对比 -->
<template v-if="comparisons.length > 0">
<!-- 综合分对比 + 多维度雷达图 同行 -->
<n-grid style="margin-top: 20px" :cols="2" :x-gap="16">
<n-gi>
<n-card title="综合分对比满分100" style="height: 100%">
<div style="height: 380px">
<Bar
v-if="compositeScoreChartData"
:data="compositeScoreChartData"
:options="compositeScoreChartOptions"
/>
</div>
</n-card>
</n-gi>
<n-gi>
<n-card title="多维度综合对比" style="height: 100%">
<div style="height: 380px">
<Radar
v-if="radarChartData"
:data="radarChartData"
:options="radarChartOptions"
/>
</div>
</n-card>
</n-gi>
</n-grid>
<!-- AC核心指标对比 - 三个独立图表并排显示 -->
<n-card title="AC核心指标对比" style="margin-top: 20px">
<n-grid :cols="3" :x-gap="16" :y-gap="16">
<n-gi>
<div style="height: 300px">
<Bar
v-if="totalAcChartData"
:data="totalAcChartData"
:options="chartOptions"
/>
</div>
</n-gi>
<n-gi>
<div style="height: 300px">
<Bar
v-if="avgAcChartData"
:data="avgAcChartData"
:options="chartOptions"
/>
</div>
</n-gi>
<n-gi>
<div style="height: 300px">
<Bar
v-if="medianAcChartData"
:data="medianAcChartData"
:options="chartOptions"
/>
</div>
</n-gi>
</n-grid>
</n-card>
<!-- 比率统计对比 - 三个独立图表并排显示 -->
<n-card title="比率统计对比" style="margin-top: 20px">
<n-grid :cols="3" :x-gap="16" :y-gap="16">
<n-gi>
<div style="height: 300px">
<Bar
v-if="excellentRateChartData"
:data="excellentRateChartData"
:options="chartOptions"
/>
</div>
</n-gi>
<n-gi>
<div style="height: 300px">
<Bar
v-if="passRateChartData"
:data="passRateChartData"
:options="chartOptions"
/>
</div>
</n-gi>
<n-gi>
<div style="height: 300px">
<Bar
v-if="activeRateChartData"
:data="activeRateChartData"
:options="chartOptions"
/>
</div>
</n-gi>
</n-grid>
</n-card>
<!-- 分层统计对比 - 三个独立图表并排显示 -->
<n-card title="分层统计对比" style="margin-top: 20px">
<n-grid :cols="3" :x-gap="16" :y-gap="16">
<n-gi>
<div style="height: 300px">
<Bar
v-if="top10AvgChartData"
:data="top10AvgChartData"
:options="chartOptions"
/>
</div>
</n-gi>
<n-gi>
<div style="height: 300px">
<Bar
v-if="middle80AvgChartData"
:data="middle80AvgChartData"
:options="chartOptions"
/>
</div>
</n-gi>
<n-gi>
<div style="height: 300px">
<Bar
v-if="bottom10AvgChartData"
:data="bottom10AvgChartData"
:options="chartOptions"
/>
</div>
</n-gi>
</n-grid>
</n-card>
</template>
<!-- 对比表格 -->
<n-card
v-if="comparisons.length > 0"
title="对比表格"
style="margin-top: 20px"
>
<n-data-table :data="comparisons" :columns="tableColumns" />
</n-card>
</n-flex>
</n-card>
</template>
<style scoped>
/* ==================== 统计数字颜色设置 ==================== */
/* 覆盖 Naive UI n-statistic 组件的所有可能类名 */
/* 总AC数 - 红色 */
.stat-total-ac :deep(.n-statistic-value),
.stat-total-ac :deep(.n-statistic-value__content),
.stat-total-ac :deep(.n-number-animation),
.stat-total-ac :deep(.n-statistic-value > *),
.stat-total-ac :deep(.n-statistic-value span) {
color: #ff4d4f !important;
font-weight: 600;
}
/* 平均AC数 - 绿色 */
.stat-avg-ac :deep(.n-statistic-value),
.stat-avg-ac :deep(.n-statistic-value__content),
.stat-avg-ac :deep(.n-number-animation),
.stat-avg-ac :deep(.n-statistic-value > *),
.stat-avg-ac :deep(.n-statistic-value span) {
color: #52c41a !important;
font-weight: 600;
}
/* 中位数AC数 - 橙色 */
.stat-median-ac :deep(.n-statistic-value),
.stat-median-ac :deep(.n-statistic-value__content),
.stat-median-ac :deep(.n-number-animation),
.stat-median-ac :deep(.n-statistic-value > *),
.stat-median-ac :deep(.n-statistic-value span) {
color: #fa8c16 !important;
font-weight: 600;
}
/* 总提交数 - 紫色 */
.stat-total-submission :deep(.n-statistic-value),
.stat-total-submission :deep(.n-statistic-value__content),
.stat-total-submission :deep(.n-number-animation),
.stat-total-submission :deep(.n-statistic-value > *),
.stat-total-submission :deep(.n-statistic-value span) {
color: #805ad5 !important;
font-weight: 600;
}
/* AC率 - 青色 */
.stat-ac-rate :deep(.n-statistic-value),
.stat-ac-rate :deep(.n-statistic-value__content),
.stat-ac-rate :deep(.n-number-animation),
.stat-ac-rate :deep(.n-statistic-value > *),
.stat-ac-rate :deep(.n-statistic-value span) {
color: #00b894 !important;
font-weight: 600;
}
</style>