Files
Academic-Affairs-System/web/src/views/CourseGradeStatisticsView.vue
T
2026-08-09 10:13:12 +08:00

272 lines
12 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 { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import { useRoute, useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const report = ref<any | null>(null)
const activeScope = ref('class')
const chartElement = ref<HTMLElement>()
const comparisonChartElement = ref<HTMLElement>()
let chart: echarts.ECharts | undefined
let comparisonChart: echarts.ECharts | undefined
const rows = computed(() => [
{ key: 'class', label: '本班', caption: '同班同学', value: report.value?.class },
{ key: 'major', label: '本专业', caption: '专业学生', value: report.value?.major },
{ key: 'college', label: '本学院', caption: '学院学生', value: report.value?.college },
{ key: 'university', label: '本校', caption: '全校学生', value: report.value?.university },
].filter(item => item.value))
const selectedRow = computed(() => rows.value.find(item => item.key === activeScope.value) ?? rows.value[0])
const selectedDistribution = computed(() => selectedRow.value?.value.distribution ?? [])
function score(value: unknown) {
return Number(value).toFixed(1)
}
async function load() {
loading.value = true
try {
report.value = (await http.get(`/grades/sheets/${route.params.sheetId}/statistics`)).data
if (!rows.value.some(item => item.key === activeScope.value)) activeScope.value = rows.value[0]?.key ?? 'class'
await nextTick()
drawChart()
drawComparisonChart()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
function drawChart() {
if (!chartElement.value || !selectedRow.value) return
chart?.dispose()
chart = echarts.init(chartElement.value)
const distribution = selectedDistribution.value
const total = selectedRow.value.value.studentCount || 1
const palette = ['#a7b4c6', '#8198b6', '#5f7fa8', '#3d6797', '#173f78']
chart.setOption({
animationDuration: 520,
aria: { enabled: true, decal: { show: true } },
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = distribution[params[0]?.dataIndex ?? 0]
const rate = item ? Number(item.count) / total * 100 : 0
return `<b>${item?.range ?? ''} 分</b><br/>人数 ${item?.count ?? 0} 人<br/>占比 ${rate.toFixed(1)}%`
},
},
grid: { left: 42, right: 22, top: 44, bottom: 38 },
xAxis: {
type: 'category',
name: '分数段',
data: distribution.map((item: any) => item.range),
axisTick: { show: false },
axisLine: { lineStyle: { color: '#cbd4e1' } },
axisLabel: { color: '#475467', fontWeight: 600 },
},
yAxis: {
type: 'value', min: 0, minInterval: 1, name: '人数',
splitLine: { lineStyle: { color: '#edf1f6' } },
},
series: [
{
name: `${selectedRow.value.label}人数`, type: 'bar', barMaxWidth: 62,
data: distribution.map((item: any, index: number) => ({ value: item.count, itemStyle: { color: palette[index] } })),
itemStyle: { borderRadius: [5, 5, 0, 0] },
label: {
show: true, position: 'top', color: '#344054', fontWeight: 700,
formatter: (params: any) => {
const count = Number(params.value)
return `${count}\n${(count / total * 100).toFixed(1)}%`
},
},
},
],
})
}
function drawComparisonChart() {
if (!comparisonChartElement.value || !rows.value.length) return
comparisonChart?.dispose()
comparisonChart = echarts.init(comparisonChartElement.value)
const selectedIndex = rows.value.findIndex(item => item.key === activeScope.value)
comparisonChart.setOption({
animationDuration: 520,
aria: { enabled: true, decal: { show: true } },
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = rows.value[params[0]?.dataIndex ?? 0]
return `<b>${item.label}</b><br/>最高分 ${score(item.value.highestScore)}<br/>平均分 ${score(item.value.averageScore)}<br/>最低分 ${score(item.value.lowestScore)}<br/>合格率 ${score(item.value.passRate)}%`
},
},
legend: { top: 2, data: ['分数区间', '平均分', '合格率'] },
grid: { left: 42, right: 52, top: 56, bottom: 36 },
xAxis: {
type: 'category',
data: rows.value.map(item => item.label),
axisTick: { show: false },
axisLine: { lineStyle: { color: '#cbd4e1' } },
axisLabel: {
color: (value: string) => value === selectedRow.value?.label ? '#173f78' : '#667085',
fontWeight: 600,
},
},
yAxis: [
{ type: 'value', min: 0, max: 100, name: '分数', splitLine: { lineStyle: { color: '#edf1f6' } } },
{ type: 'value', min: 0, max: 100, name: '合格率', axisLabel: { formatter: '{value}%' }, splitLine: { show: false } },
],
series: [
{
name: '区间起点', type: 'bar', stack: 'score-range', silent: true,
itemStyle: { color: 'transparent' }, emphasis: { disabled: true },
data: rows.value.map(item => item.value.lowestScore),
},
{
name: '分数区间', type: 'bar', stack: 'score-range', barWidth: 22,
itemStyle: { borderRadius: 8 },
data: rows.value.map((item, index) => ({
value: item.value.highestScore - item.value.lowestScore,
itemStyle: { color: index === selectedIndex ? '#315a9b' : '#b8c7dc' },
})),
},
{
name: '平均分', type: 'line', smooth: true, symbol: 'circle', symbolSize: 10,
lineStyle: { width: 3, color: '#d58b32' },
itemStyle: { color: '#d58b32', borderColor: '#fff', borderWidth: 2 },
data: rows.value.map(item => item.value.averageScore),
},
{
name: '合格率', type: 'line', yAxisIndex: 1, smooth: true, symbol: 'diamond', symbolSize: 9,
lineStyle: { width: 2, type: 'dashed', color: '#25847a' },
itemStyle: { color: '#25847a' },
data: rows.value.map(item => item.value.passRate),
},
],
})
}
watch(activeScope, async () => {
await nextTick()
drawChart()
drawComparisonChart()
})
onMounted(load)
onBeforeUnmount(() => {
chart?.dispose()
comparisonChart?.dispose()
})
</script>
<template>
<div class="page-stack course-statistics" v-loading="loading">
<section class="page-intro">
<div>
<span class="section-kicker">COURSE INSIGHT</span>
<h2>{{ report?.courseName ?? '课程成绩分析' }}</h2>
<p>{{ report?.courseCode }} · {{ report?.termName }} · 本页数据来自已正式发布成绩</p>
</div>
<div class="actions">
<el-button :icon="ArrowLeft" @click="router.back()">返回</el-button>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
</section>
<el-alert v-if="report?.isRefreshing" type="info" :closable="false"
title="统计数据正在生成,请稍后刷新。" />
<section v-if="rows.length && selectedRow" class="scope-panel">
<nav class="scope-tabs" aria-label="统计范围">
<button
v-for="item in rows"
:key="item.key"
type="button"
:class="{ active: activeScope === item.key }"
@click="activeScope = item.key"
>
<span>{{ item.label }}</span>
<small>{{ item.value.studentCount }} </small>
</button>
</nav>
<article class="scope-summary">
<div class="average-score">
<span>{{ selectedRow.label }}平均分</span>
<strong>{{ score(selectedRow.value.averageScore) }}</strong>
<small>统计对象{{ selectedRow.caption }} · {{ selectedRow.value.studentCount }} </small>
</div>
<dl>
<div><dt>最高分</dt><dd>{{ score(selectedRow.value.highestScore) }}</dd></div>
<div><dt>最低分</dt><dd>{{ score(selectedRow.value.lowestScore) }}</dd></div>
<div><dt>合格人数</dt><dd>{{ selectedRow.value.passedCount }} / {{ selectedRow.value.studentCount }}</dd></div>
<div><dt>合格率</dt><dd>{{ score(selectedRow.value.passRate) }}%</dd></div>
</dl>
</article>
</section>
<el-empty v-else-if="!loading" description="暂无可展示的课程统计" />
<section v-if="rows.length" class="chart-panel">
<div class="chart-heading">
<div><span class="section-kicker">SCORE DISTRIBUTION</span><h3>{{ selectedRow?.label }}分数段分布</h3></div>
<p>按当前标签页单独统计 {{ selectedRow?.value.studentCount }} 柱顶显示人数和占比</p>
</div>
<div ref="chartElement" class="score-chart" />
</section>
<section v-if="rows.length" class="chart-panel comparison-panel">
<div class="chart-heading">
<div><span class="section-kicker">LEVEL COMPARISON</span><h3>各层级整体分析</h3></div>
<p>同时比较四个层级的最低最高分区间平均分和合格率当前标签对应层级高亮</p>
</div>
<div ref="comparisonChartElement" class="score-chart" />
</section>
</div>
</template>
<style scoped>
.actions { display: flex; gap: 10px; }
.scope-panel, .chart-panel { background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); box-shadow: 0 10px 30px rgb(21 47 82 / 5%); }
.scope-tabs { display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid #dfe5ee; background: #f6f8fb; }
.scope-tabs button { min-height: 58px; padding: 10px 18px; border: 0; border-right: 1px solid #e2e7ef; color: #667085; background: transparent; cursor: pointer; text-align: left; transition: color .2s ease, background .2s ease, box-shadow .2s ease; }
.scope-tabs button:last-child { border-right: 0; }
.scope-tabs button span, .scope-tabs button small { display: block; }
.scope-tabs button span { font-weight: 700; font-size: 14px; }
.scope-tabs button small { margin-top: 3px; color: #98a2b3; font: 11px Consolas, monospace; }
.scope-tabs button.active { color: #173f78; background: #fff; box-shadow: inset 0 3px #315a9b; }
.scope-tabs button:focus-visible { outline: 2px solid #315a9b; outline-offset: -3px; }
.scope-summary { display: grid; grid-template-columns: minmax(220px, .8fr) 1.6fr; gap: 36px; padding: 28px 30px; }
.average-score { padding-right: 30px; border-right: 1px solid #e3e8ef; }
.average-score span, .average-score small { display: block; color: #667085; }
.average-score span { font-size: 12px; font-weight: 700; }
.average-score strong { display: block; margin: 9px 0 7px; color: #173f78; font: 700 48px/1 Consolas, monospace; }
.average-score small { font-size: 11px; }
.scope-summary dl { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 0; align-items: stretch; }
.scope-summary dl div { padding: 14px 16px; background: #f7f9fc; border-left: 2px solid #d5deeb; }
.scope-summary dt { color: #667085; font-size: 11px; }
.scope-summary dd { margin: 8px 0 0; color: #243b5a; font: 700 20px/1.1 Consolas, monospace; }
.chart-panel { margin-top: 18px; padding: 22px 26px; }
.chart-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; }
.chart-heading h3 { margin: 4px 0 0; }
.chart-heading p { max-width: 420px; margin: 0; color: #667085; font-size: 12px; text-align: right; }
.score-chart { height: 350px; margin-top: 10px; }
@media (max-width: 760px) {
.page-intro { align-items: flex-start; }
.actions { flex-wrap: wrap; }
.scope-tabs { grid-template-columns: repeat(2, 1fr); }
.scope-tabs button:nth-child(2) { border-right: 0; }
.scope-tabs button:nth-child(-n + 2) { border-bottom: 1px solid #e2e7ef; }
.scope-summary { grid-template-columns: 1fr; gap: 20px; padding: 22px; }
.average-score { padding: 0 0 20px; border: 0; border-bottom: 1px solid #e3e8ef; }
.scope-summary dl { grid-template-columns: repeat(2, 1fr); }
.chart-heading { align-items: flex-start; flex-direction: column; }
.chart-heading p { text-align: left; }
}
</style>