Files
Academic-Affairs-System/web/src/views/GraduationAuditsView.vue
T
biss b0eaa20da6 学籍异动:休学、复学、退学申请及辅导员→学院→教务处分级审批。
毕业审核:批次计算、缺失课程检查、人工复核、结果发布。
学位授予:毕业资格与 GPA 计算、人工调整、发布授予结果。
毕业离校:离校事项配置、责任角色分工、逐项办理及批次关闭。
2026-07-24 17:30:05 +08:00

249 lines
13 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, DocumentChecked, Plus, Refresh, Stamp } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
const isPublisher = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
const batches = ref<any[]>([])
const selected = ref<any | null>(null)
const myResult = ref<any | null>(null)
const loading = ref(false)
const createDialog = ref(false)
const decisionDialog = ref(false)
const decisionTarget = ref<any | null>(null)
const keyword = ref('')
const conclusionFilter = ref('')
const batchForm = reactive({
name: '2030届本科生毕业资格审核',
graduationYear: 2030,
enrollmentYear: 2026,
notes: '',
})
const decisionForm = reactive({ conclusion: 'Ineligible', comment: '' })
const statusLabels: Record<string, string> = { Draft: '审核中', Published: '已发布' }
const conclusionLabels: Record<string, string> = { Eligible: '符合毕业条件', Ineligible: '暂不符合' }
const filteredResults = computed(() => {
const q = keyword.value.trim().toLowerCase()
return (selected.value?.results ?? []).filter((x: any) =>
(!conclusionFilter.value || x.conclusion === conclusionFilter.value) &&
(!q || `${x.studentNumber}${x.name}${x.className}${x.majorName}`.toLowerCase().includes(q)))
})
function percent(earned: number, required: number) {
if (!required) return 0
return Math.min(100, Math.round((earned / required) * 100))
}
function dateText(value?: string) {
if (!value) return '—'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date(value))
}
async function load() {
loading.value = true
try {
if (isStudent.value) {
myResult.value = (await http.get('/graduation-audits/my-result')).data
return
}
batches.value = (await http.get('/graduation-audits/batches')).data
const batch = batches.value.find((x) => x.id === selected.value?.id) ?? batches.value[0]
if (batch) await selectBatch(batch.id)
else selected.value = null
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function selectBatch(id: string) {
selected.value = (await http.get(`/graduation-audits/batches/${id}`)).data
}
async function createBatch() {
if (!batchForm.name.trim()) return
try {
const response = await http.post('/graduation-audits/batches', batchForm)
createDialog.value = false
await http.post(`/graduation-audits/batches/${response.data.id}/calculate`)
ElMessage.success('审核批次已建立,并完成首次资格计算。')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function calculate() {
try {
await ElMessageBox.confirm('重新计算会覆盖当前人工调整结果,是否继续?', '重新计算资格', {
type: 'warning', confirmButtonText: '重新计算',
})
await http.post(`/graduation-audits/batches/${selected.value.id}/calculate`)
ElMessage.success('已按最新培养方案与发布成绩重新计算。')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openDecision(row: any) {
decisionTarget.value = row
decisionForm.conclusion = row.conclusion
decisionForm.comment = row.reviewComment ?? ''
decisionDialog.value = true
}
async function saveDecision() {
if (decisionForm.comment.trim().length < 5) {
ElMessage.warning('人工复核意见至少填写 5 个字。')
return
}
try {
await http.put(`/graduation-audits/results/${decisionTarget.value.id}`, decisionForm)
decisionDialog.value = false
ElMessage.success('人工复核结论已保存。')
await selectBatch(selected.value.id)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function publish() {
try {
await ElMessageBox.confirm(
'发布后结果不可修改;符合条件的在籍学生将同步为“毕业”状态。',
'发布毕业审核结果',
{ type: 'warning', confirmButtonText: '确认发布' },
)
await http.post(`/graduation-audits/batches/${selected.value.id}/publish`)
ElMessage.success('毕业审核结果已正式发布。')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(load)
</script>
<template>
<div class="page-stack graduation-page">
<section class="page-intro">
<div>
<span class="section-kicker">DEGREE CLEARANCE</span>
<h2>{{ isStudent ? '我的毕业资格' : '毕业资格审核' }}</h2>
<p>{{ isStudent ? '查看学校正式发布的毕业资格审核结论和未完成项目。' : '以培养方案和已发布成绩为依据批量计算,支持人工复核并形成不可变发布结果。' }}</p>
</div>
<el-button v-if="isPublisher && !isStudent" type="primary" :icon="Plus" @click="createDialog = true">新建审核批次</el-button>
<el-button v-else :icon="Refresh" @click="load">刷新</el-button>
</section>
<template v-if="isStudent">
<section v-if="myResult" class="graduation-certificate" v-loading="loading">
<header>
<span>OFFICIAL DEGREE CLEARANCE</span>
<i>{{ myResult.graduationYear }}</i>
</header>
<div class="certificate-person">
<div><span>学生姓名</span><b>{{ myResult.name }}</b></div>
<div><span>学号</span><b>{{ myResult.studentNumber }}</b></div>
<div><span>专业</span><b>{{ myResult.majorName }}</b></div>
</div>
<div class="certificate-conclusion" :class="{ eligible: myResult.conclusion === 'Eligible' }">
<el-icon><DocumentChecked /></el-icon>
<div><span>毕业资格审核结论</span><h3>{{ conclusionLabels[myResult.conclusion] }}</h3><p>{{ myResult.batchName }} · 发布于 {{ dateText(myResult.publishedAt) }}</p></div>
</div>
<div class="certificate-metrics">
<div><span>学分完成</span><b>{{ myResult.earnedCredits }} / {{ myResult.requiredCredits }}</b><el-progress :percentage="percent(myResult.earnedCredits, myResult.requiredCredits)" :show-text="false" /></div>
<div><span>必修课程</span><b>{{ myResult.passedRequiredCourseCount }} / {{ myResult.requiredCourseCount }}</b><p>已通过 / 应通过</p></div>
<div><span>未解决不及格</span><b>{{ myResult.failedCourseCount }}</b><p>门课程</p></div>
</div>
<footer>
<div><span>未完成课程</span><b>{{ myResult.missingCourseNames || '无' }}</b></div>
<div v-if="myResult.reviewComment"><span>复核意见</span><b>{{ myResult.reviewComment }}</b></div>
</footer>
</section>
<el-empty v-else v-loading="loading" description="学校尚未发布你的毕业资格审核结果" />
</template>
<template v-else>
<section class="graduation-batch-strip">
<button v-for="batch in batches" :key="batch.id" :class="{ active: selected?.id === batch.id }" @click="selectBatch(batch.id)">
<span>{{ batch.graduationYear }} · {{ batch.enrollmentYear }}</span>
<b>{{ batch.name }}</b>
<small>{{ batch.resultCount }} · {{ batch.eligibleCount }} 人符合</small>
<i>{{ statusLabels[batch.status] }}</i>
</button>
</section>
<section v-if="selected" class="graduation-workbench" v-loading="loading">
<header>
<div><span>AUDIT REGISTER</span><h3>{{ selected.name }}</h3><p>计算时间 {{ dateText(selected.calculatedAt) }} · 规则基于已发布培养方案与成绩</p></div>
<div v-if="selected.status === 'Draft' && isPublisher">
<el-button :icon="Refresh" @click="calculate">重新计算</el-button>
<el-button type="primary" :icon="Stamp" @click="publish">发布结果</el-button>
</div>
<el-tag v-else type="success" effect="plain">结果已锁定</el-tag>
</header>
<div class="graduation-summary">
<div><span>参审学生</span><b>{{ selected.results.length }}</b><small></small></div>
<div><span>符合条件</span><b>{{ selected.results.filter((x: any) => x.conclusion === 'Eligible').length }}</b><small></small></div>
<div><span>暂不符合</span><b>{{ selected.results.filter((x: any) => x.conclusion === 'Ineligible').length }}</b><small></small></div>
<div><span>人工调整</span><b>{{ selected.results.filter((x: any) => x.isOverridden).length }}</b><small></small></div>
</div>
<div class="graduation-filter">
<el-input v-model="keyword" clearable placeholder="搜索学号、姓名、专业或班级" />
<el-select v-model="conclusionFilter" clearable placeholder="全部结论">
<el-option label="符合毕业条件" value="Eligible" />
<el-option label="暂不符合" value="Ineligible" />
</el-select>
<span>显示 {{ filteredResults.length }} </span>
</div>
<div class="graduation-result-list">
<article v-for="row in filteredResults" :key="row.id">
<div class="graduation-student"><span>{{ row.studentNumber }} · {{ row.className }}</span><h4>{{ row.name }}</h4><p>{{ row.majorName }} · {{ row.planName || '未匹配培养方案' }}</p></div>
<div class="credit-progress"><span>学分完成度</span><b>{{ row.earnedCredits }} / {{ row.requiredCredits }}</b><el-progress :percentage="percent(row.earnedCredits, row.requiredCredits)" :show-text="false" /></div>
<div class="course-clearance"><span>必修通过</span><b>{{ row.passedRequiredCourseCount }} / {{ row.requiredCourseCount }}</b><small v-if="row.missingCourseNames">{{ row.missingCourseNames }}</small><small v-else>必修项目已完成</small></div>
<div class="graduation-conclusion" :class="{ eligible: row.conclusion === 'Eligible' }"><el-icon><Check /></el-icon><span>{{ conclusionLabels[row.conclusion] }}</span><small v-if="row.isOverridden">人工复核</small><small v-else>规则计算</small></div>
<el-button v-if="selected.status === 'Draft'" link type="primary" @click="openDecision(row)">人工复核</el-button>
</article>
<el-empty v-if="!filteredResults.length" description="没有符合筛选条件的审核结果" />
</div>
</section>
<el-empty v-else v-loading="loading" description="尚未建立毕业资格审核批次" />
</template>
<el-dialog v-model="createDialog" title="新建毕业资格审核批次" width="620px">
<el-form label-position="top">
<el-form-item label="批次名称"><el-input v-model="batchForm.name" /></el-form-item>
<div class="form-grid">
<el-form-item label="毕业年份"><el-input-number v-model="batchForm.graduationYear" :min="2000" :max="2200" /></el-form-item>
<el-form-item label="目标入学年级"><el-input-number v-model="batchForm.enrollmentYear" :min="2000" :max="2200" /></el-form-item>
</div>
<el-form-item label="说明"><el-input v-model="batchForm.notes" type="textarea" :rows="3" /></el-form-item>
</el-form>
<template #footer><el-button @click="createDialog = false">取消</el-button><el-button type="primary" @click="createBatch">建立并计算</el-button></template>
</el-dialog>
<el-dialog v-model="decisionDialog" title="人工复核毕业资格" width="620px">
<div v-if="decisionTarget" class="review-target">
<span>{{ decisionTarget.studentNumber }}</span><b>{{ decisionTarget.name }} · {{ decisionTarget.majorName }}</b>
<p>规则结论{{ conclusionLabels[decisionTarget.calculatedConclusion] }} · 学分 {{ decisionTarget.earnedCredits }} / {{ decisionTarget.requiredCredits }}</p>
</div>
<el-form label-position="top">
<el-form-item label="复核结论">
<el-radio-group v-model="decisionForm.conclusion">
<el-radio-button value="Eligible">符合毕业条件</el-radio-button>
<el-radio-button value="Ineligible">暂不符合</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="复核意见"><el-input v-model="decisionForm.comment" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="decisionDialog = false">取消</el-button><el-button type="primary" @click="saveDecision">保存复核结论</el-button></template>
</el-dialog>
</div>
</template>