成绩分析

This commit is contained in:
biss committed 2026-08-22 16:24:56 +08:00
1 parent 0a0500dd70
commit 9ba83c3dc1
4 files changed
+754 -33

No files matched your search

+214 -16
View File
@@ -38,10 +38,31 @@ interface TeachingClassItem {
calculatedAt: string
}
interface CourseOverviewItem {
courseId: string
courseCode: string
courseName: string
collegeId: string
collegeName: string
termCount: number
teachingClassCount: number
studentCount: number
passedCount: number
excellentCount: number
highestScore: number
averageScore: number
lowestScore: number
passRate: number
excellentRate: number
calculatedAt: string
}
const terms = ref<any[]>([])
const colleges = ref<any[]>([])
const auth = useAuthStore()
const canManageSchedule = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
const canViewCourseOverview = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ?? false)
const analysisMode = ref<'classes' | 'courses'>('classes')
const classes = ref<TeachingClassItem[]>([])
const selected = ref<TeachingClassItem>()
const report = ref<any>()
@@ -52,6 +73,13 @@ const teacherKeyword = ref('')
const riskOnly = ref(false)
const page = ref(1)
const total = ref(0)
const courses = ref<CourseOverviewItem[]>([])
const selectedCourse = ref<CourseOverviewItem>()
const courseReport = ref<any>()
const courseTotal = ref(0)
const coursePage = ref(1)
const courseReportLoading = ref(false)
const courseExporting = ref(false)
const pageSize = 12
const loading = ref(false)
const reportLoading = ref(false)
@@ -136,13 +164,82 @@ async function loadClasses(reset = false) {
}
}
async function loadCourses(reset = false) {
if (reset) coursePage.value = 1
loading.value = true
try {
const data = (await http.get('/grade-analytics/courses', {
params: {
academicTermId: termId.value,
keyword: keyword.value.trim() || undefined,
collegeId: collegeId.value,
riskOnly: riskOnly.value || undefined,
page: coursePage.value,
pageSize,
},
})).data
courses.value = data.items
courseTotal.value = data.total
selectedCourse.value = courses.value.find(item => item.courseId === selectedCourse.value?.courseId) ?? courses.value[0]
if (selectedCourse.value) await loadCourseReport(selectedCourse.value)
else courseReport.value = undefined
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadCourseReport(item = selectedCourse.value) {
if (!item) return
selectedCourse.value = item
courseReportLoading.value = true
try {
courseReport.value = (await http.get(`/grade-analytics/courses/${item.courseId}`, {
params: { academicTermId: termId.value },
})).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
courseReportLoading.value = false
}
}
async function exportCourseWordReport() {
if (!selectedCourse.value) return
courseExporting.value = true
try {
await downloadApiFile(
`/grade-analytics/courses/${selectedCourse.value.courseId}/report.docx`,
`${selectedCourse.value.courseCode}-科目总体成绩分析报告.docx`,
{ params: { academicTermId: termId.value } },
)
ElMessage.success('科目总体成绩分析 Word 报告已导出。')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
courseExporting.value = false
}
}
async function switchAnalysisMode(mode: 'classes' | 'courses') {
analysisMode.value = mode
if (mode === 'courses') await loadCourses(true)
else await loadClasses(true)
}
function reloadCurrent(reset = true) {
if (analysisMode.value === 'courses') return loadCourses(reset)
return loadClasses(reset)
}
function resetFilters() {
termId.value = defaultAcademicTermId(terms.value)
collegeId.value = undefined
keyword.value = ''
teacherKeyword.value = ''
riskOnly.value = false
loadClasses(true)
reloadCurrent(true)
}
async function loadReport(item = selected.value) {
@@ -389,17 +486,15 @@ onBeforeUnmount(() => {
</div>
<div class="intro-actions">
<el-button v-if="canManageSchedule" :icon="Setting" @click="openScheduleSettings">定时刷新设置</el-button>
<el-button
type="primary"
:icon="Download"
:loading="exporting"
:disabled="!selected || !summary"
@click="exportWordReport"
>导出 Word 报告</el-button>
<el-button :icon="Refresh" @click="refreshStatistics">重新计算当前教学班</el-button>
</div>
</section>
<el-radio-group v-if="canViewCourseOverview" v-model="analysisMode" class="analysis-mode" @change="switchAnalysisMode(analysisMode)">
<el-radio-button value="classes">教学班分析</el-radio-button>
<el-radio-button value="courses">科目总体分析</el-radio-button>
</el-radio-group>
<el-dialog v-model="scheduleDialogVisible" title="成绩统计与学业总览定时刷新" width="520px">
<el-form v-loading="scheduleLoading" label-width="130px">
<el-form-item label="启用定时刷新">
@@ -427,20 +522,20 @@ onBeforeUnmount(() => {
</el-dialog>
<section class="filter-bar">
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)">
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="reloadCurrent(true)">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" />
</el-select>
<el-select v-model="collegeId" clearable placeholder="全部开课学院" style="width: 190px" @change="loadClasses(true)">
<el-select v-model="collegeId" clearable placeholder="全部开课学院" style="width: 190px" @change="reloadCurrent(true)">
<el-option v-for="college in colleges" :key="college.id" :label="college.name" :value="college.id" />
</el-select>
<el-input v-model="keyword" clearable placeholder="课程、教学班名称或编号" :prefix-icon="Search" @keyup.enter="loadClasses(true)" />
<el-input v-model="teacherKeyword" clearable placeholder="任课教师姓名或工号" :prefix-icon="Search" @keyup.enter="loadClasses(true)" @clear="loadClasses(true)" />
<el-checkbox v-model="riskOnly" @change="loadClasses(true)">仅看预警班</el-checkbox>
<el-button type="primary" @click="loadClasses(true)">查询</el-button>
<el-input v-model="keyword" clearable :placeholder="analysisMode === 'courses' ? '课程名称或编号' : '课程、教学班名称或编号'" :prefix-icon="Search" @keyup.enter="reloadCurrent(true)" />
<el-input v-if="analysisMode === 'classes'" v-model="teacherKeyword" clearable placeholder="任课教师姓名或工号" :prefix-icon="Search" @keyup.enter="loadClasses(true)" @clear="loadClasses(true)" />
<el-checkbox v-model="riskOnly" @change="reloadCurrent(true)">仅看预警{{ analysisMode === 'courses' ? '科目' : '班' }}</el-checkbox>
<el-button type="primary" @click="reloadCurrent(true)">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</section>
<section class="analytics-shell">
<section v-if="analysisMode === 'classes'" class="analytics-shell">
<aside class="class-rail" v-loading="loading">
<header><strong>可查看教学班</strong><small>{{ total }} 个已生成统计</small></header>
<button
@@ -473,7 +568,10 @@ onBeforeUnmount(() => {
<h3>{{ report.courseName }}</h3>
<p>{{ report.taskName }} · 数据更新时间 {{ new Date(summary.calculatedAt).toLocaleString() }}</p>
</div>
<div class="sample-stamp"><b>{{ summary.studentCount }}</b><span>有效成绩</span></div>
<div class="report-actions">
<el-button type="primary" plain :icon="Download" :loading="exporting" @click="exportWordReport">导出 Word 报告</el-button>
<div class="sample-stamp"><b>{{ summary.studentCount }}</b><span>有效成绩</span></div>
</div>
</header>
<section class="kpi-strip">
@@ -525,12 +623,104 @@ onBeforeUnmount(() => {
</template>
</main>
</section>
<section v-else class="analytics-shell course-overview-shell">
<aside class="class-rail" v-loading="loading">
<header><strong>可查看科目</strong><small>{{ courseTotal }} 个已生成统计</small></header>
<button
v-for="item in courses"
:key="item.courseId"
type="button"
:class="['class-option', { active: selectedCourse?.courseId === item.courseId }]"
@click="loadCourseReport(item)"
>
<span class="course-code">{{ item.courseCode }} · {{ item.collegeName }}</span>
<strong>{{ item.courseName }}</strong>
<small>{{ item.teachingClassCount }} 个教学班 · {{ item.studentCount }} 人</small>
<span class="option-metrics"><b>{{ number(item.averageScore) }}</b> 平均分 · <b>{{ number(item.passRate) }}%</b> 合格</span>
</button>
<el-empty v-if="!loading && !courses.length" :image-size="72" description="当前范围暂无已发布成绩统计" />
<el-pagination
v-if="courseTotal > pageSize"
small layout="prev, pager, next" :total="courseTotal" :page-size="pageSize" v-model:current-page="coursePage"
@current-change="loadCourses()"
/>
</aside>
<main class="analysis-canvas" v-loading="courseReportLoading">
<el-empty v-if="!selectedCourse" description="请选择一个科目查看总体分析" />
<template v-else-if="courseReport?.summary">
<header class="report-heading">
<div>
<span>{{ courseReport.summary.courseCode }} · {{ courseReport.summary.collegeName }}</span>
<h3>{{ courseReport.summary.courseName }} · 科目总体分析</h3>
<p>覆盖 {{ courseReport.summary.termCount }} 个学期、{{ courseReport.summary.teachingClassCount }} 个教学班;仅统计已正式发布成绩。</p>
</div>
<div class="report-actions">
<el-button type="primary" plain :icon="Download" :loading="courseExporting" @click="exportCourseWordReport">导出 Word 报告</el-button>
<div class="sample-stamp"><b>{{ courseReport.summary.studentCount }}</b><span>有效成绩</span></div>
</div>
</header>
<section class="kpi-strip">
<article><span>平均分</span><strong>{{ number(courseReport.summary.averageScore) }}</strong><small>按学生数加权</small></article>
<article><span>合格率</span><strong>{{ number(courseReport.summary.passRate) }}%</strong><small>{{ courseReport.summary.passedCount }} / {{ courseReport.summary.studentCount }} 人</small></article>
<article><span>优秀率</span><strong>{{ number(courseReport.summary.excellentRate) }}%</strong><small>{{ courseReport.summary.excellentCount }} 人达到 90 分</small></article>
<article><span>最高 / 最低</span><strong>{{ number(courseReport.summary.highestScore) }} / {{ number(courseReport.summary.lowestScore) }}</strong><small>成绩跨度 {{ number(courseReport.summary.highestScore - courseReport.summary.lowestScore) }}</small></article>
<article><span>教学班数</span><strong>{{ courseReport.summary.teachingClassCount }}</strong><small>开课教学班</small></article>
<article><span>数据更新时间</span><strong class="date-metric">{{ new Date(courseReport.summary.calculatedAt).toLocaleDateString('zh-CN') }}</strong><small>以最后刷新统计为准</small></article>
</section>
<section class="detail-grid">
<article class="detail-card">
<h4>分数段分布</h4>
<el-table :data="courseReport.distribution" size="small" stripe>
<el-table-column prop="label" label="分数段" />
<el-table-column prop="studentCount" label="人数" align="right" />
<el-table-column label="占比" align="right"><template #default="scope">{{ number(scope.row.studentCount / courseReport.summary.studentCount * 100, 1) }}%</template></el-table-column>
</el-table>
</article>
<article class="detail-card">
<h4>各学期趋势</h4>
<el-table :data="courseReport.terms" size="small" stripe>
<el-table-column prop="termName" label="学期" min-width="110" />
<el-table-column prop="studentCount" label="人数" align="right" />
<el-table-column label="平均分" align="right"><template #default="scope">{{ number(scope.row.averageScore) }}</template></el-table-column>
<el-table-column label="合格率" align="right"><template #default="scope">{{ number(scope.row.passRate) }}%</template></el-table-column>
</el-table>
</article>
<article class="detail-card">
<h4>学生来源学院</h4>
<el-table :data="courseReport.studentOrigins" size="small" stripe>
<el-table-column prop="collegeName" label="学院" min-width="110" />
<el-table-column prop="studentCount" label="人数" align="right" />
<el-table-column label="平均分" align="right"><template #default="scope">{{ number(scope.row.averageScore) }}</template></el-table-column>
<el-table-column label="合格率" align="right"><template #default="scope">{{ number(scope.row.passRate) }}%</template></el-table-column>
</el-table>
</article>
</section>
<section class="detail-card teaching-class-detail">
<h4>教学班横向比较</h4>
<el-table :data="courseReport.teachingClasses" size="small" stripe max-height="360">
<el-table-column prop="termName" label="学期" width="110" />
<el-table-column prop="taskNumber" label="教学班" width="115" />
<el-table-column prop="teacherNames" label="任课教师" min-width="110" />
<el-table-column prop="studentCount" label="人数" align="right" width="70" />
<el-table-column label="平均分" align="right" width="85"><template #default="scope">{{ number(scope.row.averageScore) }}</template></el-table-column>
<el-table-column label="中位数" align="right" width="85"><template #default="scope">{{ number(scope.row.medianScore) }}</template></el-table-column>
<el-table-column label="标准差" align="right" width="85"><template #default="scope">{{ number(scope.row.standardDeviation, 2) }}</template></el-table-column>
<el-table-column label="合格率" align="right" width="85"><template #default="scope">{{ number(scope.row.passRate) }}%</template></el-table-column>
<el-table-column label="优秀率" align="right" width="85"><template #default="scope">{{ number(scope.row.excellentRate) }}%</template></el-table-column>
</el-table>
</section>
</template>
</main>
</section>
</div>
</template>
<style scoped>
.grade-analytics { --ink: #18324f; --blue: #204f87; --line: #dce3ec; }
.analytics-intro { align-items: flex-end; }
.analysis-mode { margin-top: -6px; }
.filter-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; padding: 16px 18px; background: #f7f9fc; border: 1px solid var(--line); }
.filter-bar > :deep(.el-input) { width: min(100%, 280px); }
.filter-bar > :deep(.el-checkbox) { margin-right: 4px; }
@@ -555,6 +745,7 @@ onBeforeUnmount(() => {
.report-heading h3 { margin: 7px 0 5px; font-size: 24px; }
.report-heading p { margin: 0; color: #cbd5e1; font-size: 12px; }
.sample-stamp { display: flex; flex-direction: column; min-width: 94px; justify-content: center; padding-left: 22px; border-left: 1px solid rgb(255 255 255 / 20%); }
.report-actions { display: flex; align-items: center; gap: 18px; }
.sample-stamp b { font: 700 34px/1 Consolas, monospace; }
.sample-stamp span { margin-top: 5px; color: #b9c9da; font-size: 11px; }
.kpi-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); background: #fff; border: 1px solid var(--line); border-top: 0; }
@@ -581,10 +772,16 @@ onBeforeUnmount(() => {
.schedule-status { display: grid; gap: 3px; color: #667085; font-size: 12px; }
.chart { height: 310px; margin-top: 10px; }
.chart-large { height: 360px; }
.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; margin-top: 18px; }
.detail-card { min-width: 0; padding: 18px; background: #fff; border: 1px solid var(--line); box-shadow: 0 10px 28px rgb(24 50 79 / 5%); }
.detail-card h4 { margin: 0 0 14px; color: var(--ink); font-size: 15px; }
.teaching-class-detail { margin-top: 18px; }
.date-metric { font-size: 17px !important; }
@media (max-width: 1180px) {
.analytics-shell { grid-template-columns: 240px minmax(0, 1fr); }
.chart-grid { grid-template-columns: 1fr; }
.chart-card-wide { grid-column: auto; }
.detail-grid { grid-template-columns: 1fr; }
}
@media (max-width: 820px) {
.filter-bar { display: grid; grid-template-columns: 1fr; }
@@ -599,6 +796,7 @@ onBeforeUnmount(() => {
}
@media (max-width: 520px) {
.report-heading { flex-direction: column; }
.report-actions { width: 100%; align-items: stretch; flex-direction: column-reverse; }
.sample-stamp { padding: 14px 0 0; border: 0; border-top: 1px solid rgb(255 255 255 / 20%); }
.kpi-strip { grid-template-columns: 1fr; }
.kpi-strip article { border-right: 0 !important; }