“教学班成绩分析”页面新增“定时刷新设置”,支持启停、刷新间隔、单次处理上限、查看上次/下次扫描时间。

配置保存在独立数据库表 CourseGradeStatisticsRefreshSettings,不是修改 appsettings.json。
后台每 10 秒读取配置,仅到期扫描;成绩录入、导入、审批时不再立即创建统计任务。
扫描发现过期数据后,仍通过持久任务、Outbox 和 RabbitMQ 执行;Redis统计缓存由处理任务统一刷新。
This commit is contained in:
2026-08-09 20:51:57 +08:00 Unverified
parent e8261714da
commit cd073885b5
17 changed files with 7661 additions and 46 deletions
+78 -2
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Download, Refresh, Search } from '@element-plus/icons-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Download, Refresh, Search, Setting } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel'
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
import { useAuthStore } from '../stores/auth'
interface TeachingClassItem {
gradeSheetId: string
@@ -25,6 +26,8 @@ interface TeachingClassItem {
}
const terms = ref<any[]>([])
const auth = useAuthStore()
const canManageSchedule = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
const classes = ref<TeachingClassItem[]>([])
const selected = ref<TeachingClassItem>()
const report = ref<any>()
@@ -37,6 +40,16 @@ const loading = ref(false)
const reportLoading = ref(false)
const exporting = ref(false)
const historyMetric = ref<'average' | 'passRate' | 'excellentRate'>('average')
const scheduleDialogVisible = ref(false)
const scheduleLoading = ref(false)
const scheduleSaving = ref(false)
const schedule = reactive({
isEnabled: true,
intervalMinutes: 5,
batchSize: 100,
lastRunAt: null as string | null,
nextRunAt: null as string | null,
})
const distributionElement = ref<HTMLElement>()
const peerAverageElement = ref<HTMLElement>()
@@ -134,6 +147,40 @@ async function refreshStatistics() {
}
}
async function openScheduleSettings() {
scheduleDialogVisible.value = true
scheduleLoading.value = true
try {
const data = (await http.get('/grade-analytics/refresh-schedule')).data
schedule.isEnabled = data.isEnabled
schedule.intervalMinutes = Math.max(1, Math.round(data.intervalSeconds / 60))
schedule.batchSize = data.batchSize
schedule.lastRunAt = data.lastRunAt
schedule.nextRunAt = data.nextRunAt
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
scheduleLoading.value = false
}
}
async function saveScheduleSettings() {
scheduleSaving.value = true
try {
await http.put('/grade-analytics/refresh-schedule', {
isEnabled: schedule.isEnabled,
intervalSeconds: schedule.intervalMinutes * 60,
batchSize: schedule.batchSize,
})
ElMessage.success('成绩统计定时刷新设置已保存')
scheduleDialogVisible.value = false
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
scheduleSaving.value = false
}
}
async function exportWordReport() {
if (!selected.value) return
exporting.value = true
@@ -307,6 +354,7 @@ onBeforeUnmount(() => {
<p>对比当前教学班同课程教学班学生来源范围与历年成绩统计仅使用已正式发布成绩</p>
</div>
<div class="intro-actions">
<el-button v-if="canManageSchedule" :icon="Setting" @click="openScheduleSettings">定时刷新设置</el-button>
<el-button
type="primary"
:icon="Download"
@@ -318,6 +366,32 @@ onBeforeUnmount(() => {
</div>
</section>
<el-dialog v-model="scheduleDialogVisible" title="成绩统计定时刷新" width="520px">
<el-form v-loading="scheduleLoading" label-width="130px">
<el-form-item label="启用定时刷新">
<el-switch v-model="schedule.isEnabled" />
</el-form-item>
<el-form-item label="刷新间隔">
<el-input-number v-model="schedule.intervalMinutes" :min="1" :max="1440" :disabled="!schedule.isEnabled" />
<span class="schedule-unit">分钟</span>
</el-form-item>
<el-form-item label="单次处理上限">
<el-input-number v-model="schedule.batchSize" :min="1" :max="5000" :step="50" :disabled="!schedule.isEnabled" />
<span class="schedule-unit">个课程学期</span>
</el-form-item>
<el-form-item label="运行状态">
<div class="schedule-status">
<span>上次扫描{{ schedule.lastRunAt ? new Date(schedule.lastRunAt).toLocaleString('zh-CN') : '尚未运行' }}</span>
<span v-if="schedule.isEnabled">下次扫描{{ schedule.nextRunAt ? new Date(schedule.nextRunAt).toLocaleString('zh-CN') : '启用后将尽快运行' }}</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="scheduleDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="scheduleSaving" @click="saveScheduleSettings">保存设置</el-button>
</template>
</el-dialog>
<section class="filter-bar">
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" />
@@ -461,6 +535,8 @@ onBeforeUnmount(() => {
.chart-card h4 { margin: 5px 0 0; color: var(--ink); font-size: 16px; }
.chart-card header p, .chart-caption { min-width: 0; margin: 0; color: #667085; font-size: 11px; text-align: right; }
.chart-caption { margin-top: 9px; text-align: left; }
.schedule-unit { margin-left: 10px; color: #667085; font-size: 12px; }
.schedule-status { display: grid; gap: 3px; color: #667085; font-size: 12px; }
.chart { height: 310px; margin-top: 10px; }
.chart-large { height: 360px; }
@media (max-width: 1180px) {
+8 -7
View File
@@ -6,7 +6,7 @@ import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore()
const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin'))
const isWarningManager = computed(() => auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin'].includes(r)) ?? false)
const isCounselor = computed(() => auth.user?.roles.includes('Counselor') && !auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)))
const isStudent = computed(() => auth.user?.roles.includes('Student') && !auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(r)))
@@ -43,11 +43,11 @@ const ruleRows = reactive([
async function load() {
loading.value = true
try {
if (isSuperAdmin.value && termId.value) {
if (isWarningManager.value && termId.value) {
const serverRules = (await http.get('/warnings/rules', { params: { academicTermId: termId.value } })).data
// Merge server values into reactive rows
for (const row of ruleRows) {
const sr = serverRules.find((r: any) => r.type === row.type)
const sr = serverRules.find((r: any) => Number(r.type) === row.type)
if (sr) {
row.threshold = sr.threshold
row.isEnabled = sr.isEnabled
@@ -62,7 +62,7 @@ async function load() {
}
}
}
if (isSuperAdmin.value || isCounselor.value)
if (isWarningManager.value || isCounselor.value)
records.value = (await http.get('/warnings/records', { params: { academicTermId: termId.value || undefined, type: filterType.value || undefined } })).data
if (isStudent.value) myWarnings.value = (await http.get('/warnings/my-warnings')).data
} catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false }
@@ -72,6 +72,7 @@ async function saveRules() {
try {
const payload = ruleRows.map(r => ({ type: r.type, name: r.name, threshold: r.threshold, isEnabled: r.isEnabled, notifyStudent: r.notifyStudent, notifyCounselor: r.notifyCounselor, description: r.description, autoCheckEnabled: r.autoCheckEnabled, checkDayOfWeek: r.checkDayOfWeek, checkHour: r.checkHour, checkMinute: r.checkMinute }))
await http.put('/warnings/rules', payload, { params: { academicTermId: termId.value } })
await load()
ElMessage.success('预警规则已保存')
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
@@ -102,7 +103,7 @@ onMounted(async () => {
<template>
<div class="page-stack warn-page">
<section class="page-intro">
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isSuperAdmin ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isWarningManager ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
<div style="display:flex;gap:8px;align-items:center">
<el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /></el-select>
<el-button :icon="Refresh" @click="load">刷新</el-button>
@@ -110,7 +111,7 @@ onMounted(async () => {
</section>
<!-- Admin: Rule config -->
<section v-if="isSuperAdmin && termId" class="warn-rules">
<section v-if="isWarningManager && termId" class="warn-rules">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0">预警规则配置</h3>
<div>
@@ -160,7 +161,7 @@ onMounted(async () => {
</section>
<!-- Records -->
<section v-if="isSuperAdmin || isCounselor" v-loading="loading" class="warn-records">
<section v-if="isWarningManager || isCounselor" v-loading="loading" class="warn-records">
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px">
<h3 style="margin:0">预警记录</h3>
<el-select v-model="filterType" clearable placeholder="全部类型" @change="load" style="width:140px"><el-option v-for="(v,k) in typeLabels" :key="k" :label="v" :value="Number(k)" /></el-select>