成绩统计图
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
<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>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
Check,
|
||||
ArrowRight,
|
||||
Delete,
|
||||
DocumentChecked,
|
||||
Download,
|
||||
@@ -15,9 +16,11 @@ import {
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile, importExcel } from '../api/excel'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const isStudent = computed(() => auth.user?.roles.includes('Student') &&
|
||||
!auth.user.roles.some((role) =>
|
||||
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'].includes(role)))
|
||||
@@ -127,6 +130,10 @@ function removeItem(index: number) {
|
||||
createForm.items.splice(index, 1)
|
||||
}
|
||||
|
||||
function openStatistics(record: any) {
|
||||
router.push({ name: 'course-grade-statistics', params: { sheetId: record.gradeSheetId } })
|
||||
}
|
||||
|
||||
async function load(resetPage = false) {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -463,7 +470,17 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<section class="transcript-list" v-loading="loading">
|
||||
<article v-for="record in transcript.records" :key="record.id">
|
||||
<article
|
||||
v-for="record in transcript.records"
|
||||
:key="record.id"
|
||||
class="transcript-course-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`查看${record.courseName}成绩分析`"
|
||||
@click="openStatistics(record)"
|
||||
@keydown.enter="openStatistics(record)"
|
||||
@keydown.space.prevent="openStatistics(record)"
|
||||
>
|
||||
<div class="transcript-course">
|
||||
<span>{{ record.termName }}</span>
|
||||
<h3>{{ record.courseName }}</h3>
|
||||
@@ -482,6 +499,7 @@ onMounted(async () => {
|
||||
<b v-if="record.totalScore != null">{{ record.totalScore }}</b>
|
||||
<b v-else>—</b>
|
||||
<small>绩点 {{ record.gradePoint ?? '—' }}</small>
|
||||
<span class="card-entry">查看成绩分析 <el-icon><ArrowRight /></el-icon></span>
|
||||
</div>
|
||||
</article>
|
||||
<el-empty v-if="!transcript.records.length" description="当前范围内暂无已发布成绩" />
|
||||
@@ -835,6 +853,10 @@ onMounted(async () => {
|
||||
padding: 8px;
|
||||
}
|
||||
.transcript-result.is-failed b { color: #b42318; }
|
||||
.transcript-course-card { cursor: pointer; transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease; }
|
||||
.transcript-course-card:hover { transform: translateY(-2px); border-color: #91a8c8; box-shadow: 0 10px 24px rgb(31 66 112 / 9%); }
|
||||
.transcript-course-card:focus-visible { outline: 2px solid #315a9b; outline-offset: 3px; }
|
||||
.card-entry { display: inline-flex !important; align-items: center; justify-content: flex-end; gap: 4px; margin-top: 8px; color: #315a9b !important; font-size: 10px !important; font-weight: 700; }
|
||||
|
||||
/* Preview total score */
|
||||
.total-score.preview {
|
||||
|
||||
Reference in New Issue
Block a user