学业预警

This commit is contained in:
2026-07-25 17:14:20 +08:00 Unverified
parent 5e4c76a9e4
commit db3a275601
8 changed files with 536 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Check, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin'))
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)))
const terms = ref<any[]>([])
const termId = ref('')
const records = ref<any[]>([])
const myWarnings = ref<any[]>([])
const loading = ref(false)
const detecting = ref(false)
const filterType = ref('')
const typeLabels: Record<number, string> = { 1: '不及格学分', 2: '低绩点', 3: '缺勤', 4: '延毕风险' }
const statusLabels: Record<number, string> = { 1: '生效中', 2: '已确认', 3: '已处理', 4: '已忽略' }
function tagType(type: number): 'danger' | 'warning' | 'info' {
return type === 1 ? 'danger' : type === 2 ? 'warning' : 'info'
}
function thresholdUnit(type: number): string {
if (type === 1) return '门 不及格'
if (type === 2) return 'GPA 下限'
if (type === 3) return '次 缺勤/迟到'
return '门 累计不及格'
}
// Reactive rule objects — one per type, bound directly to v-model
const ruleRows = reactive([
{ type: 1 as number, name: '不及格学分', threshold: 2, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '' },
{ type: 2, name: '低绩点', threshold: 2, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '' },
{ type: 3, name: '缺勤', threshold: 3, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '' },
{ type: 4, name: '延毕风险', threshold: 5, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '' },
])
async function load() {
loading.value = true
try {
if (isSuperAdmin.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)
if (sr) {
row.threshold = sr.threshold
row.isEnabled = sr.isEnabled
row.notifyStudent = sr.notifyStudent
row.notifyCounselor = sr.notifyCounselor
row.description = sr.description || ''
}
}
}
if (isSuperAdmin.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 }
}
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 }))
await http.put('/warnings/rules', payload, { params: { academicTermId: termId.value } })
ElMessage.success('预警规则已保存')
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function detectWarnings() {
try {
await ElMessageBox.confirm('系统将扫描当前学期全部在籍学生,产生的预警将自动通知学生及辅导员。', '执行学业预警检测', { type: 'warning', confirmButtonText: '开始检测' })
detecting.value = true
const res = await http.post('/warnings/detect', null, { params: { academicTermId: termId.value } })
ElMessage.success(`检测完成,生成 ${res.data.generated} 条预警`)
await load()
} catch (e: any) { if (e !== 'cancel' && e !== 'close') ElMessage.error(apiErrorMessage(e)) } finally { detecting.value = false }
}
async function acknowledge(w: any) {
try {
const { value } = await ElMessageBox.prompt('确认已知晓此预警?(可选填备注)', '确认预警', { confirmButtonText: '确认', inputType: 'textarea' })
await http.post(`/warnings/${w.id}/acknowledge`, { comment: value || null }); ElMessage.success('已确认'); await load()
} catch (e: any) { if (e !== 'cancel' && e !== 'close') ElMessage.error(apiErrorMessage(e)) }
}
onMounted(async () => {
try { terms.value = (await http.get('/base-data/terms')).data; termId.value = terms.value.find(t => t.isCurrent)?.id; await load() }
catch (e) { ElMessage.error(apiErrorMessage(e)) }
})
</script>
<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 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="t.name" :value="t.id" /></el-select>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
</section>
<!-- Admin: Rule config -->
<section v-if="isSuperAdmin && termId" class="warn-rules">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0">预警规则配置</h3>
<div>
<el-button type="primary" @click="saveRules">保存规则</el-button>
<el-button :icon="Search" :loading="detecting" type="warning" @click="detectWarnings">执行检测</el-button>
</div>
</div>
<el-table :data="ruleRows" size="small">
<el-table-column label="类型" width="100"><template #default="{row}"><b>{{ row.name }}</b></template></el-table-column>
<el-table-column label="阈值" width="160">
<template #default="{row}">
<el-input-number v-model="row.threshold" :min="1" :max="row.type===4?20:row.type===3?50:10" :precision="row.type===2?1:0" size="small" />
<span style="margin-left:4px;font-size:12px;color:var(--muted)">{{ thresholdUnit(row.type) }}</span>
</template>
</el-table-column>
<el-table-column label="开关" width="200">
<template #default="{row}">
<el-switch v-model="row.isEnabled" size="small" active-text="启用" style="margin-right:12px" />
</template>
</el-table-column>
<el-table-column label="通知" width="200">
<template #default="{row}">
<el-checkbox v-model="row.notifyStudent" :disabled="!row.isEnabled">学生</el-checkbox>
<el-checkbox v-model="row.notifyCounselor" :disabled="!row.isEnabled" style="margin-left:12px">辅导员</el-checkbox>
</template>
</el-table-column>
<el-table-column label="说明"><template #default="{row}"><el-input v-model="row.description" size="small" placeholder="可选" maxlength="300" /></template></el-table-column>
</el-table>
</section>
<!-- Records -->
<section v-if="isSuperAdmin || 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>
</div>
<el-table :data="records" size="small" v-if="records.length">
<el-table-column label="学生" min-width="150"><template #default="{row}"><b>{{ row.studentName }}</b><span style="font-size:11px;color:var(--muted);margin-left:6px">{{ row.studentNumber }}</span></template></el-table-column>
<el-table-column label="班级" prop="className" width="120" />
<el-table-column label="类型" width="100"><template #default="{row}"><el-tag size="small" :type="tagType(row.type)">{{ typeLabels[row.type] }}</el-tag></template></el-table-column>
<el-table-column label="阈值/详情" min-width="200"><template #default="{row}"><span>{{ row.triggerValue }}</span><p style="margin:0;font-size:11px;color:var(--muted)">{{ row.detail }}</p></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{row}"><el-tag size="small" :type="row.status===1?'danger':row.status===2?'success':'info'">{{ statusLabels[row.status] }}</el-tag></template></el-table-column>
</el-table>
<el-empty v-if="!records.length" description="暂无预警记录,请先执行检测。" />
</section>
<!-- Student -->
<section v-if="isStudent" v-loading="loading" class="warn-mine">
<article v-for="w in myWarnings" :key="w.id" class="warn-card" :class="{ active: w.status===1 }">
<div><el-tag size="small" :type="(w.type===1?'danger':w.type===2?'warning':'info') as 'danger'|'warning'|'info'">{{ typeLabels[w.type] }}</el-tag></div>
<div class="warn-body"><b>{{ w.detail }}</b><small>{{ new Date(w.createdAt).toLocaleString('zh-CN') }}</small></div>
<div class="warn-status">
<el-tag size="small" :type="w.status===1?'danger':w.status===2?'success':'info'">{{ statusLabels[w.status] }}</el-tag>
<el-button v-if="w.status===1" size="small" type="primary" :icon="Check" @click="acknowledge(w)">确认</el-button>
</div>
</article>
<el-empty v-if="!myWarnings.length" description="暂无学业预警" />
</section>
</div>
</template>
<style scoped>
.warn-rules { background:#fff; border:1px solid #e4e7ed; border-radius:8px; padding:16px 20px; margin-bottom:20px; }
.warn-records { background:#fff; border:1px solid #e4e7ed; border-radius:8px; padding:16px 20px; }
.warn-mine { display:grid; gap:10px; }
.warn-card { display:flex; align-items:center; gap:14px; padding:14px 18px; background:#fff; border:1px solid #e4e7ed; border-radius:8px; }
.warn-card.active { border-left:4px solid #f56c6c; }
.warn-body { flex:1; }
.warn-body b { font-size:14px; display:block; margin-bottom:4px; }
.warn-body small { font-size:11px; color:var(--muted); }
.warn-status { display:flex; align-items:center; gap:8px; }
</style>