Files
Academic-Affairs-System/web/src/views/WarningsView.vue
T
2026-08-23 09:21:04 +08:00

307 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Check, EditPen, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore()
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)))
const terms = ref<any[]>([])
const termId = ref('')
const records = ref<any[]>([])
const myWarnings = ref<any[]>([])
const myRiskCases = ref<any[]>([])
const loading = ref(false)
const detecting = ref(false)
const filterType = ref('')
const recordsPage = ref(1)
const recordsCursors = ref<string[]>([])
const nextRecordsCursor = ref<string>()
const recordsHasMore = ref(false)
const recordsPageSize = 20
const riskCases = ref<any[]>([])
const riskDialogVisible = ref(false)
const selectedRiskCase = ref<any>(null)
const riskEntry = reactive({ type: 1, content: '', occurredAt: '' })
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 '门 累计不及格'
}
const weekOptions = [{ value: 0, label: '每天' }, { value: 1, label: '周一' }, { value: 2, label: '周二' }, { value: 3, label: '周三' }, { value: 4, label: '周四' }, { value: 5, label: '周五' }, { value: 6, label: '周六' }, { value: 7, label: '周日' }]
const ruleRows = reactive([
{ type: 1 as number, name: '不及格学分', threshold: 2, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '', autoCheckEnabled: false, checkDayOfWeek: 0, checkHour: 8, checkMinute: 0, lastCheckAt: null as string | null },
{ type: 2, name: '低绩点', threshold: 2, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '', autoCheckEnabled: false, checkDayOfWeek: 0, checkHour: 8, checkMinute: 0, lastCheckAt: null as string | null },
{ type: 3, name: '缺勤', threshold: 3, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '', autoCheckEnabled: false, checkDayOfWeek: 0, checkHour: 8, checkMinute: 0, lastCheckAt: null as string | null },
{ type: 4, name: '延毕风险', threshold: 5, isEnabled: true, notifyStudent: true, notifyCounselor: true, description: '', autoCheckEnabled: false, checkDayOfWeek: 0, checkHour: 8, checkMinute: 0, lastCheckAt: null as string | null },
])
async function load(resetRecordsPage = false) {
if (resetRecordsPage) {
recordsPage.value = 1
recordsCursors.value = []
nextRecordsCursor.value = undefined
}
loading.value = true
try {
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) => Number(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 || ''
row.autoCheckEnabled = sr.autoCheckEnabled
row.checkDayOfWeek = sr.checkDayOfWeek
row.checkHour = sr.checkHour
row.checkMinute = sr.checkMinute
row.lastCheckAt = sr.lastCheckAt
}
}
}
if (isWarningManager.value || isCounselor.value)
{
const response = await http.get('/warnings/records', {
params: {
academicTermId: termId.value || undefined,
type: filterType.value || undefined,
cursor: recordsCursors.value.at(-1),
pageSize: recordsPageSize,
},
})
records.value = response.data.items
recordsHasMore.value = response.data.hasMore
nextRecordsCursor.value = response.data.nextCursor
riskCases.value = (await http.get('/warnings/risk-cases', { params: { mineOnly: isCounselor.value } })).data
}
if (isStudent.value) {
myWarnings.value = (await http.get('/warnings/my-warnings')).data
myRiskCases.value = (await http.get('/warnings/my-risk-cases')).data
}
} catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false }
}
async function nextRecordsPage() {
if (!nextRecordsCursor.value || loading.value) return
recordsCursors.value.push(nextRecordsCursor.value)
recordsPage.value++
await load()
}
async function previousRecordsPage() {
if (!recordsCursors.value.length || loading.value) return
recordsCursors.value.pop()
recordsPage.value--
await load()
}
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)) }
}
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)) }
}
async function openRiskCase(row: any) {
try { selectedRiskCase.value = (await http.get(`/warnings/risk-cases/${row.id}`)).data; riskEntry.type = 1; riskEntry.content = ''; riskEntry.occurredAt = ''; riskDialogVisible.value = true }
catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function saveRiskCase() {
try {
const c = selectedRiskCase.value
await http.put(`/warnings/risk-cases/${c.id}`, { status: c.status, followUpDueDate: c.followUpDueDate, reviewDate: c.reviewDate || null })
ElMessage.success('跟进任务已更新'); await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function addRiskEntry() {
if (!riskEntry.content.trim()) { ElMessage.warning('请填写记录内容'); return }
try {
await http.post(`/warnings/risk-cases/${selectedRiskCase.value.id}/entries`, { type: riskEntry.type, content: riskEntry.content, occurredAt: riskEntry.occurredAt || null })
await openRiskCase(selectedRiskCase.value); await load(); ElMessage.success('处置记录已保存')
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
onMounted(async () => {
try { terms.value = (await http.get('/base-data/terms')).data; termId.value = defaultAcademicTermId(terms.value) ?? ''; 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>{{ isWarningManager ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
<div style="display:flex;gap:8px;align-items:center">
<el-select v-model="termId" clearable @change="load(true)" 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>
</div>
</section>
<!-- Admin: Rule config -->
<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>
<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="180">
<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:8px">辅导员</el-checkbox>
</template>
</el-table-column>
<el-table-column label="自动检测" min-width="290">
<template #default="{row}">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<el-switch v-model="row.autoCheckEnabled" size="small" :disabled="!row.isEnabled" />
<template v-if="row.autoCheckEnabled">
<span style="font-size:11px">每</span>
<el-select v-model="row.checkDayOfWeek" size="small" style="width:75px">
<el-option v-for="o in weekOptions" :key="String(o.value)" :label="o.label" :value="o.value" />
</el-select>
<el-input-number v-model="row.checkHour" :min="0" :max="23" size="small" :controls="false" style="width:56px" />
<span style="font-size:11px">时</span>
<el-input-number v-model="row.checkMinute" :min="0" :max="59" size="small" :controls="false" style="width:56px" />
<span style="font-size:11px">分</span>
<span v-if="row.lastCheckAt" style="font-size:10px;color:var(--muted)">上次: {{ new Date(row.lastCheckAt).toLocaleString('zh-CN') }}</span>
</template>
</div>
</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="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(true)" 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>
<div v-if="recordsCursors.length || recordsHasMore" class="table-pagination cursor-pager">
<el-button :disabled="!recordsCursors.length || loading" @click="previousRecordsPage">上一批</el-button>
<span>第 {{ recordsPage }} 批</span>
<el-button type="primary" :disabled="!recordsHasMore || loading" @click="nextRecordsPage">下一批</el-button>
</div>
<el-empty v-if="!records.length" description="暂无预警记录,请先执行检测。" />
</section>
<section v-if="isWarningManager || isCounselor" class="risk-tasks">
<div class="risk-head"><div><h3>风险处置跟进</h3><p>谈话和帮扶记录为敏感信息,仅被指派辅导员和学业管理人员可见。</p></div><el-tag type="danger">待办 {{ riskCases.filter(x => x.status !== 4).length }}</el-tag></div>
<el-table :data="riskCases" size="small">
<el-table-column label="学生" min-width="140"><template #default="{row}"><b>{{ row.studentName }}</b><small>{{ row.studentNumber }}</small></template></el-table-column>
<el-table-column label="预警类型" width="110"><template #default="{row}">{{ typeLabels[row.warningType] }}</template></el-table-column>
<el-table-column label="跟进截止" width="120"><template #default="{row}">{{ row.followUpDueDate }}</template></el-table-column>
<el-table-column label="复核日期" width="120"><template #default="{row}">{{ row.reviewDate || '待设置' }}</template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{row}">{{ ['','待跟进','跟进中','待复核','已结案'][row.status] }}</template></el-table-column>
<el-table-column label="操作" width="110"><template #default="{row}"><el-button size="small" type="primary" :icon="EditPen" @click="openRiskCase(row)">处置</el-button></template></el-table-column>
</el-table>
<el-empty v-if="!riskCases.length" description="暂无需要跟进的风险任务" :image-size="70" />
</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 v-if="myRiskCases.length" class="my-risk-progress">
<div><h3>我的帮扶与复核进度</h3><p>这里展示辅导员针对本人的谈话、帮扶和复核记录。</p></div>
<article v-for="riskCase in myRiskCases" :key="riskCase.id" class="my-risk-case">
<div class="risk-case-dates"><span>跟进截止:{{ riskCase.followUpDueDate }}</span><span>复核日期:{{ riskCase.reviewDate || '待安排' }}</span></div>
<el-timeline><el-timeline-item v-for="entry in riskCase.entries" :key="entry.id" :timestamp="new Date(entry.occurredAt).toLocaleString('zh-CN')"><b>{{ ['','谈话记录','帮扶措施','复核记录'][entry.type] }}</b><p>{{ entry.content }}</p><small>记录人:{{ entry.recordedByName }}</small></el-timeline-item></el-timeline>
<el-empty v-if="!riskCase.entries.length" description="辅导员暂未填写处置记录" :image-size="55" />
</article>
</section>
</section>
</div>
<el-dialog v-model="riskDialogVisible" title="学业风险处置(敏感)" width="680px" destroy-on-close>
<template v-if="selectedRiskCase">
<p class="risk-warning"><b>{{ selectedRiskCase.studentName }}</b>({{ selectedRiskCase.studentNumber }}) · {{ selectedRiskCase.warningDetail }}</p>
<el-form label-width="90px" class="risk-form"><el-form-item label="跟进状态"><el-select v-model="selectedRiskCase.status"><el-option :value="1" label="待跟进"/><el-option :value="2" label="跟进中"/><el-option :value="3" label="待复核"/><el-option :value="4" label="已结案"/></el-select></el-form-item><el-form-item label="跟进截止"><el-date-picker v-model="selectedRiskCase.followUpDueDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="复核日期"><el-date-picker v-model="selectedRiskCase.reviewDate" type="date" clearable value-format="YYYY-MM-DD" /></el-form-item><el-form-item><el-button type="primary" @click="saveRiskCase">保存任务</el-button></el-form-item></el-form>
<el-divider>新增处置记录</el-divider>
<el-form label-width="90px"><el-form-item label="记录类别"><el-radio-group v-model="riskEntry.type"><el-radio :value="1">谈话记录</el-radio><el-radio :value="2">帮扶措施</el-radio><el-radio :value="3">复核记录</el-radio></el-radio-group></el-form-item><el-form-item label="发生时间"><el-date-picker v-model="riskEntry.occurredAt" type="datetime" clearable value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="内容"><el-input v-model="riskEntry.content" type="textarea" maxlength="2000" show-word-limit :rows="4" placeholder="记录谈话要点、帮扶措施或复核结论" /></el-form-item><el-form-item><el-button type="primary" @click="addRiskEntry">保存记录</el-button></el-form-item></el-form>
<el-divider>处置历史</el-divider><el-timeline><el-timeline-item v-for="entry in selectedRiskCase.entries" :key="entry.id" :timestamp="new Date(entry.occurredAt).toLocaleString('zh-CN')"><b>{{ ['','谈话记录','帮扶措施','复核记录'][entry.type] }}</b> · {{ entry.recordedByName }}<p>{{ entry.content }}</p></el-timeline-item></el-timeline>
</template>
</el-dialog>
</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; }
.risk-tasks { background:#fff; border:1px solid #e4e7ed; border-radius:8px; padding:16px 20px; margin-top:20px; }
.risk-head { display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:12px; }.risk-head h3 { margin:0 0 4px; }.risk-head p { margin:0; font-size:12px; color:var(--muted); }.risk-tasks small { display:block; color:var(--muted); font-size:11px; }.risk-warning { background:#fff7e6; padding:10px 12px; border-radius:4px; }.risk-form { max-width:420px; }
.my-risk-progress { background:#fff; border:1px solid #e4e7ed; border-radius:8px; padding:16px 20px; }.my-risk-progress h3 { margin:0 0 4px; }.my-risk-progress > div > p { margin:0 0 14px; color:var(--muted); font-size:12px; }.my-risk-case { border-top:1px solid #f0f1f3; padding-top:14px; margin-top:14px; }.my-risk-case:first-of-type { border-top:0; padding-top:0; margin-top:0; }.risk-case-dates { display:flex; flex-wrap:wrap; gap:16px; margin-bottom:12px; font-size:12px; color:var(--muted); }.my-risk-case p { margin:4px 0; white-space:pre-wrap; }.my-risk-case small { color:var(--muted); }
</style>