总学业统计
This commit is contained in:
1 parent
f08cd94924
commit
0ec45c196e
18 files changed
+8124
-117
No files matched your search
@@ -186,7 +186,11 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
...whenVisible(isTeacher.value, { path: '/teacher-roster', label: '选课名单' }),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor']),
|
||||
{ path: '/grades', label: isStudent.value ? '学业成绩' : isTeacher.value ? '成绩录入' : '成绩管理' },
|
||||
{ path: '/grades', label: isStudent.value ? '本学期成绩' : isTeacher.value ? '成绩录入' : '成绩管理' },
|
||||
),
|
||||
...whenVisible(
|
||||
isStudent.value,
|
||||
{ path: '/academic-overview', label: '学业成绩总览' },
|
||||
),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']),
|
||||
|
||||
@@ -265,6 +265,12 @@ const router = createRouter({
|
||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'academic-overview',
|
||||
name: 'academic-overview',
|
||||
component: () => import('../views/AcademicOverviewView.vue'),
|
||||
meta: { roles: ['Student'] },
|
||||
},
|
||||
{
|
||||
path: 'grades/:sheetId/statistics',
|
||||
name: 'course-grade-statistics',
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { DataAnalysis, Refresh } from '@element-plus/icons-vue'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { AriaComponent, GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
|
||||
echarts.use([AriaComponent, CanvasRenderer, GridComponent, LegendComponent, LineChart, TooltipComponent])
|
||||
|
||||
const loading = ref(false)
|
||||
const overview = ref<any | null>(null)
|
||||
const trendElement = ref<HTMLElement>()
|
||||
let trendChart: echarts.ECharts | undefined
|
||||
|
||||
const metricRows = () => {
|
||||
const metrics = overview.value?.metrics
|
||||
if (!metrics) return []
|
||||
return [
|
||||
{ key: 'gpa', label: 'GPA', hint: '绩点按课程学分加权', metric: metrics.gpa },
|
||||
{ key: 'weighted', label: '加权平均成绩', hint: '课程总评按课程学分加权', metric: metrics.weightedAverageScore },
|
||||
{ key: 'average', label: '平均成绩', hint: '已发布课程总评的算术平均', metric: metrics.averageScore },
|
||||
]
|
||||
}
|
||||
|
||||
function value(metric: any) {
|
||||
return metric?.value == null ? '—' : Number(metric.value).toFixed(2)
|
||||
}
|
||||
|
||||
function rank(rank: any) {
|
||||
if (!rank?.peerCount) return '—'
|
||||
return rank.rank == null ? `暂未形成排名(${rank.peerCount} 人)` : `${rank.rank} / ${rank.peerCount}`
|
||||
}
|
||||
|
||||
function drawTrend() {
|
||||
if (!trendElement.value) return
|
||||
trendChart?.dispose()
|
||||
const trend = overview.value?.termTrend ?? []
|
||||
if (!trend.length) return
|
||||
trendChart = echarts.init(trendElement.value)
|
||||
trendChart.setOption({
|
||||
animationDuration: 520,
|
||||
aria: { enabled: true },
|
||||
color: ['#315a9b', '#25816e', '#d07a28'],
|
||||
grid: { top: 56, right: 54, bottom: 58, left: 54, containLabel: true },
|
||||
legend: { top: 12, data: ['GPA', '加权平均成绩', '平均成绩'] },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
valueFormatter: (value: number | string) => value == null ? '—' : Number(value).toFixed(2),
|
||||
axisPointer: { type: 'line' },
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: trend.map((item: any) => item.termName),
|
||||
axisLabel: { interval: 0, rotate: trend.length > 5 ? 28 : 0 },
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: 'GPA', min: 0, max: 4, interval: 1 },
|
||||
{ type: 'value', name: '成绩', min: 0, max: 100, interval: 20 },
|
||||
],
|
||||
series: [
|
||||
{ name: 'GPA', type: 'line', yAxisIndex: 0, smooth: true, connectNulls: false, data: trend.map((item: any) => item.gpa), symbolSize: 8 },
|
||||
{ name: '加权平均成绩', type: 'line', yAxisIndex: 1, smooth: true, connectNulls: false, data: trend.map((item: any) => item.weightedAverageScore), symbolSize: 8 },
|
||||
{ name: '平均成绩', type: 'line', yAxisIndex: 1, smooth: true, connectNulls: false, data: trend.map((item: any) => item.averageScore), symbolSize: 8 },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
overview.value = (await http.get('/grades/student/academic-overview')).data
|
||||
await nextTick()
|
||||
drawTrend()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => window.addEventListener('resize', resizeTrend))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', resizeTrend)
|
||||
trendChart?.dispose()
|
||||
})
|
||||
|
||||
function resizeTrend() {
|
||||
trendChart?.resize()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack academic-overview-page" v-loading="loading">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="section-kicker">ACADEMIC PERFORMANCE</span>
|
||||
<h2>学业成绩总览</h2>
|
||||
<p>汇总所有已正式发布课程的 GPA、加权平均成绩与平均成绩,并显示当前行政班、专业和学院的排名。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<section class="overview-note">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span v-if="overview?.isRefreshing">成绩已发布,学业统计将在管理员设置的定时刷新后更新;完成后点击“刷新”即可查看。</span>
|
||||
<span v-else>本页面按全部学期统计;“本学期成绩”请在学业成绩中查询。排名仅比较至少具备该项有效成绩的学生,并列学生使用相同名次。</span>
|
||||
</section>
|
||||
|
||||
<section class="overview-card">
|
||||
<header>
|
||||
<div>
|
||||
<span>ALL PUBLISHED COURSES</span>
|
||||
<h3>已纳入统计 {{ overview?.courseCount ?? 0 }} 门课程</h3>
|
||||
</div>
|
||||
<small>仅纳入已发布成绩单;未完成或未发布成绩不会影响统计。</small>
|
||||
</header>
|
||||
<el-table :data="metricRows()" class="overview-table" empty-text="暂无可统计的已发布成绩">
|
||||
<el-table-column label="统计指标" min-width="210">
|
||||
<template #default="{ row }"><strong>{{ row.label }}</strong><small>{{ row.hint }}</small></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="我的成绩" min-width="140" align="center">
|
||||
<template #default="{ row }"><b class="metric-value">{{ value(row.metric) }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="班级排名" min-width="145" align="center">
|
||||
<template #default="{ row }">{{ rank(row.metric.classRank) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="专业排名" min-width="145" align="center">
|
||||
<template #default="{ row }">{{ rank(row.metric.majorRank) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="学院排名" min-width="145" align="center">
|
||||
<template #default="{ row }">{{ rank(row.metric.collegeRank) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="trend-card">
|
||||
<header>
|
||||
<div>
|
||||
<span>TERM-BY-TERM TREND</span>
|
||||
<h3>各学期学业成绩变化</h3>
|
||||
</div>
|
||||
<small>GPA 使用左轴(0–4),两项成绩使用右轴(0–100)。</small>
|
||||
</header>
|
||||
<div v-if="overview?.termTrend?.length" ref="trendElement" class="term-trend-chart" />
|
||||
<el-empty v-else description="暂无可绘制趋势的已发布成绩" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.academic-overview-page { max-width: 1180px; }
|
||||
.overview-note { display: flex; align-items: flex-start; gap: 10px; padding: 13px 16px; color: #35506f; background: #edf5fc; border: 1px solid #cbddec; border-radius: 8px; font-size: 13px; line-height: 1.6; }
|
||||
.overview-note .el-icon { flex: 0 0 auto; margin-top: 3px; color: #315a9b; font-size: 17px; }
|
||||
.overview-card { overflow: hidden; background: #fff; border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 5px 18px rgb(31 66 112 / 6%); }
|
||||
.overview-card header { display: flex; align-items: end; justify-content: space-between; gap: 16px; padding: 20px 22px; border-bottom: 1px solid var(--line); background: linear-gradient(110deg, #f7faff, #fff); }
|
||||
.overview-card header span { color: #527199; font: 700 10px/1.2 system-ui, sans-serif; letter-spacing: .12em; }
|
||||
.overview-card h3 { margin: 5px 0 0; color: var(--ink); font-size: 18px; }
|
||||
.overview-card header small { max-width: 420px; color: var(--muted); text-align: right; line-height: 1.5; }
|
||||
.overview-table :deep(.cell) { white-space: normal; }
|
||||
.overview-table strong { display: block; color: var(--ink); font-size: 14px; }
|
||||
.overview-table small { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
.metric-value { color: #1e518e; font: 700 22px/1 Consolas, monospace; }
|
||||
.trend-card { overflow: hidden; background: #fff; border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 5px 18px rgb(31 66 112 / 6%); }
|
||||
.trend-card header { display: flex; align-items: end; justify-content: space-between; gap: 16px; padding: 20px 22px 14px; }
|
||||
.trend-card header span { color: #527199; font: 700 10px/1.2 system-ui, sans-serif; letter-spacing: .12em; }
|
||||
.trend-card h3 { margin: 5px 0 0; color: var(--ink); font-size: 18px; }
|
||||
.trend-card header small { color: var(--muted); line-height: 1.5; text-align: right; }
|
||||
.term-trend-chart { width: 100%; height: 360px; }
|
||||
@media (max-width: 700px) { .overview-card header { align-items: flex-start; flex-direction: column; } .overview-card header small { text-align: left; } }
|
||||
</style>
|
||||
Loaded 3 of 18 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user