成绩分析
This commit is contained in:
1 parent
0a0500dd70
commit
9ba83c3dc1
4 files changed
+754
-33
No files matched your search
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user