学籍异动:休学、复学、退学申请及辅导员→学院→教务处分级审批。
毕业审核:批次计算、缺失课程检查、人工复核、结果发布。 学位授予:毕业资格与 GPA 计算、人工调整、发布授予结果。 毕业离校:离校事项配置、责任角色分工、逐项办理及批次关闭。
This commit is contained in:
@@ -122,13 +122,33 @@ onMounted(async () => {
|
||||
<b>{{ data.counts.examPlans ?? 0 }} 个计划 · {{ data.counts.examSessions ?? 0 }} 个场次</b>
|
||||
<i class="done">已建立</i>
|
||||
</div>
|
||||
<div>
|
||||
<span>学籍异动</span>
|
||||
<b>{{ data.counts.studentStatusChanges ?? 0 }} 项申请 · {{ data.counts.pendingStudentStatusChanges ?? 0 }} 项审核中</b>
|
||||
<i class="done">已建立</i>
|
||||
</div>
|
||||
<div>
|
||||
<span>毕业审核</span>
|
||||
<b>{{ data.counts.graduationAuditBatches ?? 0 }} 个批次 · {{ data.counts.publishedGraduationAuditBatches ?? 0 }} 个已发布</b>
|
||||
<i class="done">已建立</i>
|
||||
</div>
|
||||
<div>
|
||||
<span>学位授予</span>
|
||||
<b>{{ data.counts.degreeAwardBatches ?? 0 }} 个批次 · {{ data.counts.publishedDegreeAwardBatches ?? 0 }} 个已发布</b>
|
||||
<i class="done">已建立</i>
|
||||
</div>
|
||||
<div>
|
||||
<span>毕业离校</span>
|
||||
<b>{{ data.counts.graduationClearanceBatches ?? 0 }} 个批次 · {{ data.counts.openGraduationClearanceBatches ?? 0 }} 个办理中</b>
|
||||
<i class="done">已建立</i>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="work-card phase-card">
|
||||
<span class="section-kicker">NEXT MILESTONE</span>
|
||||
<h3>下一段业务链</h3>
|
||||
<p>考试计划、考场、监考与个人日程已就绪,下一步进入学籍异动与毕业审核。</p>
|
||||
<p>毕业离校事项配置、责任角色分工、学生进度与批次关闭已就绪,核心教务业务链已形成闭环。</p>
|
||||
<div class="phase-line">
|
||||
<span class="active">基础底座</span>
|
||||
<span class="active">人员档案</span>
|
||||
@@ -137,6 +157,10 @@ onMounted(async () => {
|
||||
<span class="active">学生选课</span>
|
||||
<span class="active">成绩管理</span>
|
||||
<span class="active">考试考场</span>
|
||||
<span class="active">学籍异动</span>
|
||||
<span class="active">毕业审核</span>
|
||||
<span class="active">学位授予</span>
|
||||
<span class="active">毕业离校</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Check, Close, Medal, 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 loading = ref(false)
|
||||
const batches = ref<any[]>([])
|
||||
const selected = ref<any | null>(null)
|
||||
const myResult = ref<any | null>(null)
|
||||
const createDialog = ref(false)
|
||||
const reviewDialog = ref(false)
|
||||
const reviewTarget = ref<any | null>(null)
|
||||
const keyword = ref('')
|
||||
const conclusionFilter = ref('')
|
||||
const batchForm = reactive({
|
||||
name: '2030届学士学位授予审核',
|
||||
graduationYear: 2030,
|
||||
degreeName: '工学学士',
|
||||
minimumGradePoint: 2,
|
||||
notes: '',
|
||||
})
|
||||
const reviewForm = reactive({ conclusion: 'NotGranted', comment: '' })
|
||||
const conclusionLabels: Record<string, string> = {
|
||||
Granted: '建议授予',
|
||||
NotGranted: '暂不授予',
|
||||
}
|
||||
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 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('/degree-awards/my-result')).data
|
||||
return
|
||||
}
|
||||
batches.value = (await http.get('/degree-awards/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(`/degree-awards/batches/${id}`)).data
|
||||
}
|
||||
async function createBatch() {
|
||||
try {
|
||||
const response = await http.post('/degree-awards/batches', batchForm)
|
||||
createDialog.value = false
|
||||
await http.post(`/degree-awards/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(`/degree-awards/batches/${selected.value.id}/calculate`)
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
function openReview(row: any) {
|
||||
reviewTarget.value = row
|
||||
reviewForm.conclusion = row.conclusion
|
||||
reviewForm.comment = row.reviewComment ?? ''
|
||||
reviewDialog.value = true
|
||||
}
|
||||
async function saveReview() {
|
||||
if (reviewForm.comment.trim().length < 5) {
|
||||
ElMessage.warning('复核意见至少填写 5 个字。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await http.put(`/degree-awards/results/${reviewTarget.value.id}`, reviewForm)
|
||||
reviewDialog.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(`/degree-awards/batches/${selected.value.id}/publish`)
|
||||
await load()
|
||||
ElMessage.success('学位授予结果已发布。')
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack degree-page">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="section-kicker">DEGREE CONFERRAL</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="degree-certificate" :class="{ granted: myResult.conclusion === 'Granted' }" v-loading="loading">
|
||||
<div class="degree-seal"><el-icon><Medal /></el-icon><span>DEGREE CONFERRAL</span></div>
|
||||
<div class="degree-copy">
|
||||
<span>{{ myResult.graduationYear }}届 · {{ myResult.batchName }}</span>
|
||||
<h3>{{ conclusionLabels[myResult.conclusion] }}</h3>
|
||||
<p>{{ myResult.name }}({{ myResult.studentNumber }}),{{ myResult.majorName }}</p>
|
||||
<dl><div><dt>学位名称</dt><dd>{{ myResult.degreeName }}</dd></div><div><dt>平均绩点</dt><dd>{{ myResult.averageGradePoint }}</dd></div><div><dt>最低要求</dt><dd>{{ myResult.minimumGradePoint }}</dd></div></dl>
|
||||
<footer><span>发布于 {{ dateText(myResult.publishedAt) }}</span><b>{{ myResult.reviewComment || myResult.exceptionReason || '符合批次授予规则' }}</b></footer>
|
||||
</div>
|
||||
</section>
|
||||
<el-empty v-else v-loading="loading" description="学校尚未发布你的学位授予结果" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<section class="degree-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.degreeName }}</span><b>{{ batch.name }}</b>
|
||||
<small>{{ batch.resultCount }} 人 · {{ batch.grantedCount }} 人建议授予</small><i>{{ batch.status === 'Published' ? '已发布' : '审核中' }}</i>
|
||||
</button>
|
||||
</section>
|
||||
<section v-if="selected" class="degree-workbench" v-loading="loading">
|
||||
<header>
|
||||
<div><span>CONFERRAL REGISTER</span><h3>{{ selected.name }}</h3><p>{{ selected.degreeName }} · 最低平均绩点 {{ selected.minimumGradePoint }} · 计算于 {{ 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="degree-summary">
|
||||
<div><span>参审人数</span><b>{{ selected.results.length }}</b></div>
|
||||
<div><span>建议授予</span><b>{{ selected.results.filter((x: any) => x.conclusion === 'Granted').length }}</b></div>
|
||||
<div><span>暂不授予</span><b>{{ selected.results.filter((x: any) => x.conclusion === 'NotGranted').length }}</b></div>
|
||||
<div><span>人工调整</span><b>{{ selected.results.filter((x: any) => x.isOverridden).length }}</b></div>
|
||||
</div>
|
||||
<div class="graduation-filter">
|
||||
<el-input v-model="keyword" clearable placeholder="搜索学号、姓名、专业或班级" />
|
||||
<el-select v-model="conclusionFilter" clearable placeholder="全部结论"><el-option label="建议授予" value="Granted" /><el-option label="暂不授予" value="NotGranted" /></el-select>
|
||||
<span>显示 {{ filteredResults.length }} 条</span>
|
||||
</div>
|
||||
<div class="degree-result-list">
|
||||
<article v-for="row in filteredResults" :key="row.id">
|
||||
<div><span>{{ row.studentNumber }} · {{ row.className }}</span><h4>{{ row.name }}</h4><p>{{ row.majorName }}</p></div>
|
||||
<div><span>加权平均绩点</span><b>{{ row.averageGradePoint }}</b><small>批次要求 ≥ {{ selected.minimumGradePoint }}</small></div>
|
||||
<div class="degree-rule-note"><span>规则说明</span><b>{{ row.exceptionReason || '毕业资格与绩点均符合' }}</b></div>
|
||||
<div class="degree-result-chip" :class="{ granted: row.conclusion === 'Granted' }"><el-icon><Check v-if="row.conclusion === 'Granted'" /><Close v-else /></el-icon><b>{{ conclusionLabels[row.conclusion] }}</b><small>{{ row.isOverridden ? '人工复核' : '规则计算' }}</small></div>
|
||||
<el-button v-if="selected.status === 'Draft'" link type="primary" @click="openReview(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="640px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="批次名称"><el-input v-model="batchForm.name" /></el-form-item>
|
||||
<div class="form-grid three"><el-form-item label="毕业年份"><el-input-number v-model="batchForm.graduationYear" :min="2000" :max="2200" /></el-form-item><el-form-item label="学位名称"><el-input v-model="batchForm.degreeName" /></el-form-item><el-form-item label="最低平均绩点"><el-input-number v-model="batchForm.minimumGradePoint" :min="0" :max="5" :step="0.1" /></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="reviewDialog" title="人工复核学位授予结论" width="620px">
|
||||
<div v-if="reviewTarget" class="review-target"><span>{{ reviewTarget.studentNumber }}</span><b>{{ reviewTarget.name }} · 平均绩点 {{ reviewTarget.averageGradePoint }}</b><p>规则结论:{{ conclusionLabels[reviewTarget.calculatedConclusion] }}</p></div>
|
||||
<el-form label-position="top"><el-form-item label="复核结论"><el-radio-group v-model="reviewForm.conclusion"><el-radio-button value="Granted">建议授予</el-radio-button><el-radio-button value="NotGranted">暂不授予</el-radio-button></el-radio-group></el-form-item><el-form-item label="复核意见"><el-input v-model="reviewForm.comment" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="reviewDialog=false">取消</el-button><el-button type="primary" @click="saveReview">保存复核结论</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,248 @@
|
||||
<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>
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Check, CircleCheck, 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 isManager = computed(() =>
|
||||
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
|
||||
const batches = ref<any[]>([])
|
||||
const selected = ref<any | null>(null)
|
||||
const myClearance = ref<any | null>(null)
|
||||
const loading = ref(false)
|
||||
const createDialog = ref(false)
|
||||
const recordDialog = ref(false)
|
||||
const recordTarget = ref<any | null>(null)
|
||||
const recordForm = reactive({ status: 'Completed', notes: '' })
|
||||
const batchForm = reactive({
|
||||
name: '2030届毕业生离校手续',
|
||||
graduationYear: 2030,
|
||||
notes: '',
|
||||
items: [
|
||||
{ code: 'LIBRARY', name: '图书资料归还', responsibleUnit: '图书馆', responsibleRole: 'AcademicAdmin', isRequired: true },
|
||||
{ code: 'FINANCE', name: '财务费用结清', responsibleUnit: '财务处', responsibleRole: 'AcademicAdmin', isRequired: true },
|
||||
{ code: 'DORM', name: '宿舍退宿确认', responsibleUnit: '学生工作办公室', responsibleRole: 'Counselor', isRequired: true },
|
||||
{ code: 'COLLEGE', name: '学院材料归档', responsibleUnit: '所在学院', responsibleRole: 'CollegeAdmin', isRequired: true },
|
||||
{ code: 'CERTIFICATE', name: '毕业证书领取', responsibleUnit: '教务处', responsibleRole: 'AcademicAdmin', isRequired: false },
|
||||
],
|
||||
})
|
||||
const statusLabels: Record<string, string> = {
|
||||
Pending: '待办理', Completed: '已完成', Waived: '已豁免',
|
||||
}
|
||||
const roleLabels: Record<string, string> = {
|
||||
AcademicAdmin: '校级办理', CollegeAdmin: '学院办理', Counselor: '辅导员办理',
|
||||
}
|
||||
const studentLedgers = computed(() => {
|
||||
const map = new Map<string, any>()
|
||||
for (const record of selected.value?.records ?? []) {
|
||||
if (!map.has(record.studentId)) {
|
||||
map.set(record.studentId, {
|
||||
studentId: record.studentId,
|
||||
studentNumber: record.studentNumber,
|
||||
name: record.name,
|
||||
className: record.className,
|
||||
majorName: record.majorName,
|
||||
records: [],
|
||||
})
|
||||
}
|
||||
map.get(record.studentId).records.push(record)
|
||||
}
|
||||
return [...map.values()]
|
||||
})
|
||||
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))
|
||||
}
|
||||
function completedCount(records: any[]) {
|
||||
return records.filter((x) => x.status !== 'Pending').length
|
||||
}
|
||||
function canManage(record: any) {
|
||||
const roles = auth.user?.roles ?? []
|
||||
return roles.includes('SuperAdmin') || roles.includes(record.responsibleRole)
|
||||
}
|
||||
function tagType(status: string) {
|
||||
if (status === 'Completed') return 'success'
|
||||
if (status === 'Waived') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (isStudent.value) {
|
||||
myClearance.value = (await http.get('/graduation-clearance/my-clearance')).data
|
||||
return
|
||||
}
|
||||
batches.value = (await http.get('/graduation-clearance/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-clearance/batches/${id}`)).data
|
||||
}
|
||||
function addItem() {
|
||||
batchForm.items.push({
|
||||
code: '', name: '', responsibleUnit: '',
|
||||
responsibleRole: 'AcademicAdmin', isRequired: true,
|
||||
})
|
||||
}
|
||||
function removeItem(index: number) {
|
||||
batchForm.items.splice(index, 1)
|
||||
}
|
||||
async function createBatch() {
|
||||
if (!batchForm.items.length || batchForm.items.some((x) =>
|
||||
!x.code.trim() || !x.name.trim() || !x.responsibleUnit.trim())) {
|
||||
ElMessage.warning('请完整填写每一项离校事项。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await http.post('/graduation-clearance/batches', batchForm)
|
||||
createDialog.value = false
|
||||
await http.post(`/graduation-clearance/batches/${response.data.id}/generate`)
|
||||
ElMessage.success('离校批次已建立,并生成毕业生办理清单。')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
async function generate() {
|
||||
try {
|
||||
await http.post(`/graduation-clearance/batches/${selected.value.id}/generate`)
|
||||
ElMessage.success('已补齐最新毕业生的离校办理记录。')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
function openRecord(record: any, status: string) {
|
||||
recordTarget.value = record
|
||||
recordForm.status = status
|
||||
recordForm.notes = record.notes ?? ''
|
||||
recordDialog.value = true
|
||||
}
|
||||
async function saveRecord() {
|
||||
try {
|
||||
await http.put(`/graduation-clearance/records/${recordTarget.value.id}`, recordForm)
|
||||
recordDialog.value = false
|
||||
ElMessage.success('离校事项办理状态已更新。')
|
||||
await selectBatch(selected.value.id)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
async function closeBatch() {
|
||||
try {
|
||||
await ElMessageBox.confirm('关闭后所有办理记录将锁定,请确认必办事项均已办结。', '关闭离校批次', {
|
||||
type: 'warning', confirmButtonText: '确认关闭',
|
||||
})
|
||||
await http.post(`/graduation-clearance/batches/${selected.value.id}/close`)
|
||||
ElMessage.success('离校批次已关闭。')
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack clearance-page">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="section-kicker">GRADUATION CLEARANCE</span>
|
||||
<h2>{{ isStudent ? '我的毕业离校' : '毕业离校办理' }}</h2>
|
||||
<p>{{ isStudent ? '查看各责任部门的离校手续办理进度。' : '按毕业生生成跨部门事项清单,责任角色分工办理,必办事项全部完成后统一关闭。' }}</p>
|
||||
</div>
|
||||
<el-button v-if="isManager && !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="myClearance" class="clearance-pass" v-loading="loading">
|
||||
<header>
|
||||
<div><span>LEAVING CAMPUS CHECKLIST</span><h3>{{ myClearance.name }}</h3><p>{{ myClearance.graduationYear }}届 · {{ myClearance.status === 'Closed' ? '离校手续已完成' : '离校手续办理中' }}</p></div>
|
||||
<b>{{ completedCount(myClearance.items) }} / {{ myClearance.items.length }}</b>
|
||||
</header>
|
||||
<div class="clearance-student-list">
|
||||
<article v-for="(item, index) in myClearance.items" :key="item.id" :class="{ done: item.status !== 'Pending' }">
|
||||
<i><el-icon v-if="item.status !== 'Pending'"><Check /></el-icon><span v-else>{{ Number(index) + 1 }}</span></i>
|
||||
<div><span>{{ item.responsibleUnit }}</span><h4>{{ item.itemName }}</h4><p>{{ item.notes || (item.isRequired ? '必办事项' : '非必办事项') }}</p></div>
|
||||
<el-tag :type="tagType(item.status)" effect="plain">{{ statusLabels[item.status] }}</el-tag>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<el-empty v-else v-loading="loading" description="暂未生成你的毕业离校办理清单" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<section class="clearance-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.itemCount }} 项手续</span><b>{{ batch.name }}</b>
|
||||
<small>{{ batch.studentCount }} 名毕业生 · {{ batch.completedCount }}/{{ batch.recordCount }} 项已办</small><i>{{ batch.status === 'Closed' ? '已关闭' : '办理中' }}</i>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="selected" class="clearance-workbench" v-loading="loading">
|
||||
<header>
|
||||
<div><span>CLEARANCE LEDGER</span><h3>{{ selected.name }}</h3><p>{{ selected.items.length }} 项离校手续 · {{ studentLedgers.length }} 名毕业生</p></div>
|
||||
<div v-if="selected.status === 'Open' && isManager"><el-button :icon="Refresh" @click="generate">补齐名单</el-button><el-button type="primary" :icon="Stamp" @click="closeBatch">关闭批次</el-button></div>
|
||||
<el-tag v-else type="success" effect="plain">批次已锁定</el-tag>
|
||||
</header>
|
||||
<div class="clearance-item-legend">
|
||||
<div v-for="item in selected.items" :key="item.id"><span>{{ item.code }}</span><b>{{ item.name }}</b><small>{{ item.responsibleUnit }} · {{ roleLabels[item.responsibleRole] }}</small></div>
|
||||
</div>
|
||||
<div class="clearance-ledgers">
|
||||
<article v-for="student in studentLedgers" :key="student.studentId">
|
||||
<header><div><span>{{ student.studentNumber }} · {{ student.className }}</span><h4>{{ student.name }}</h4><p>{{ student.majorName }}</p></div><b>{{ completedCount(student.records) }}/{{ student.records.length }}</b></header>
|
||||
<div class="clearance-record-grid">
|
||||
<div v-for="record in student.records" :key="record.id" :class="{ done: record.status !== 'Pending' }">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
<span>{{ record.responsibleUnit }}</span><b>{{ record.itemName }}</b>
|
||||
<small>{{ statusLabels[record.status] }}<template v-if="record.completedAt"> · {{ dateText(record.completedAt) }}</template></small>
|
||||
<div v-if="selected.status === 'Open' && canManage(record)">
|
||||
<el-button link type="primary" @click="openRecord(record, 'Completed')">办结</el-button>
|
||||
<el-button link @click="openRecord(record, 'Waived')">豁免</el-button>
|
||||
<el-button v-if="record.status !== 'Pending'" link type="danger" @click="openRecord(record, 'Pending')">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<el-empty v-if="!studentLedgers.length" description="尚未生成毕业生离校办理记录" />
|
||||
</div>
|
||||
</section>
|
||||
<el-empty v-else v-loading="loading" description="尚未建立毕业离校批次" />
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="createDialog" title="新建毕业离校批次" width="900px">
|
||||
<el-form label-position="top">
|
||||
<div class="form-grid"><el-form-item label="批次名称"><el-input v-model="batchForm.name" /></el-form-item><el-form-item label="毕业年份"><el-input-number v-model="batchForm.graduationYear" :min="2000" :max="2200" /></el-form-item></div>
|
||||
<el-form-item label="说明"><el-input v-model="batchForm.notes" /></el-form-item>
|
||||
<div class="clearance-form-head"><b>离校事项配置</b><el-button :icon="Plus" @click="addItem">增加事项</el-button></div>
|
||||
<div class="clearance-form-list">
|
||||
<div v-for="(item, index) in batchForm.items" :key="index">
|
||||
<el-input v-model="item.code" placeholder="事项编码" />
|
||||
<el-input v-model="item.name" placeholder="事项名称" />
|
||||
<el-input v-model="item.responsibleUnit" placeholder="责任部门" />
|
||||
<el-select v-model="item.responsibleRole"><el-option label="校级教务" value="AcademicAdmin" /><el-option label="学院教务" value="CollegeAdmin" /><el-option label="辅导员" value="Counselor" /></el-select>
|
||||
<el-switch v-model="item.isRequired" active-text="必办" />
|
||||
<el-button link type="danger" @click="removeItem(index)">移除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</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="recordDialog" title="更新离校事项" width="580px">
|
||||
<div v-if="recordTarget" class="review-target"><span>{{ recordTarget.studentNumber }}</span><b>{{ recordTarget.name }} · {{ recordTarget.itemName }}</b><p>{{ recordTarget.responsibleUnit }}</p></div>
|
||||
<el-form label-position="top"><el-form-item label="办理状态"><el-radio-group v-model="recordForm.status"><el-radio-button value="Completed">已完成</el-radio-button><el-radio-button value="Waived">已豁免</el-radio-button><el-radio-button value="Pending">重置待办</el-radio-button></el-radio-group></el-form-item><el-form-item label="办理备注"><el-input v-model="recordForm.notes" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="recordDialog=false">取消</el-button><el-button type="primary" @click="saveRecord">保存办理状态</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Check, Close, Plus, RefreshRight, Stamp } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
type ChangeState =
|
||||
| 'Submitted'
|
||||
| 'CounselorApproved'
|
||||
| 'CollegeApproved'
|
||||
| 'Approved'
|
||||
| 'Rejected'
|
||||
| 'Cancelled'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
|
||||
const changes = ref<any[]>([])
|
||||
const options = ref<any | null>(null)
|
||||
const loading = ref(false)
|
||||
const applyDialog = ref(false)
|
||||
const reviewDialog = ref(false)
|
||||
const selected = ref<any | null>(null)
|
||||
const reviewApproved = ref(true)
|
||||
const applyForm = reactive({ type: '', reason: '' })
|
||||
const reviewForm = reactive({ comment: '' })
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
Suspension: '休学',
|
||||
Resumption: '复学',
|
||||
Withdrawal: '退学',
|
||||
}
|
||||
const statusLabels: Record<string, string> = {
|
||||
Active: '在籍',
|
||||
Suspended: '休学',
|
||||
Withdrawn: '退学',
|
||||
Graduated: '毕业',
|
||||
}
|
||||
const stateLabels: Record<ChangeState, string> = {
|
||||
Submitted: '待辅导员审核',
|
||||
CounselorApproved: '待学院审核',
|
||||
CollegeApproved: '待校级审核',
|
||||
Approved: '已批准',
|
||||
Rejected: '已驳回',
|
||||
Cancelled: '已撤回',
|
||||
}
|
||||
const steps = [
|
||||
{ label: '辅导员审核', state: 'Submitted' },
|
||||
{ label: '学院审核', state: 'CounselorApproved' },
|
||||
{ label: '校级审批', state: 'CollegeApproved' },
|
||||
]
|
||||
const currentQueue = computed(() => changes.value.filter(canReview))
|
||||
const finishedCount = computed(() =>
|
||||
changes.value.filter((x) => ['Approved', 'Rejected', 'Cancelled'].includes(x.state)).length)
|
||||
|
||||
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))
|
||||
}
|
||||
function stageIndex(state: ChangeState) {
|
||||
if (state === 'Submitted') return 0
|
||||
if (state === 'CounselorApproved') return 1
|
||||
if (state === 'CollegeApproved') return 2
|
||||
if (state === 'Approved') return 3
|
||||
return -1
|
||||
}
|
||||
function stepClass(change: any, index: number) {
|
||||
const current = stageIndex(change.state)
|
||||
return {
|
||||
done: change.state === 'Approved' || current > index,
|
||||
active: current === index,
|
||||
stopped: ['Rejected', 'Cancelled'].includes(change.state) && index === Math.max(current, 0),
|
||||
}
|
||||
}
|
||||
function canReview(change: any) {
|
||||
const roles = auth.user?.roles ?? []
|
||||
return (
|
||||
(change.state === 'Submitted' && roles.includes('Counselor')) ||
|
||||
(change.state === 'CounselorApproved' && roles.includes('CollegeAdmin')) ||
|
||||
(change.state === 'CollegeApproved' &&
|
||||
roles.some((role) => ['AcademicAdmin', 'SuperAdmin'].includes(role)))
|
||||
)
|
||||
}
|
||||
function stateTagType(state: ChangeState) {
|
||||
if (state === 'Approved') return 'success'
|
||||
if (state === 'Rejected') return 'danger'
|
||||
if (state === 'Cancelled') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const requests = [http.get('/student-status-changes')]
|
||||
if (isStudent.value) requests.push(http.get('/student-status-changes/options'))
|
||||
const [changeResponse, optionResponse] = await Promise.all(requests)
|
||||
changes.value = changeResponse.data
|
||||
if (optionResponse) options.value = optionResponse.data
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function openApply() {
|
||||
applyForm.type = options.value?.types?.[0] ?? ''
|
||||
applyForm.reason = ''
|
||||
applyDialog.value = true
|
||||
}
|
||||
async function submitApply() {
|
||||
if (!applyForm.type || applyForm.reason.trim().length < 10) {
|
||||
ElMessage.warning('请选择异动类型,并填写至少 10 个字的申请说明。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await http.post('/student-status-changes', applyForm)
|
||||
applyDialog.value = false
|
||||
ElMessage.success('申请已提交,等待辅导员审核。')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
async function cancel(change: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm('撤回后本次申请将结束,需要时可重新提交。', '撤回申请', {
|
||||
type: 'warning', confirmButtonText: '确认撤回',
|
||||
})
|
||||
await http.post(`/student-status-changes/${change.id}/cancel`)
|
||||
ElMessage.success('申请已撤回。')
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
function openReview(change: any, approved: boolean) {
|
||||
selected.value = change
|
||||
reviewApproved.value = approved
|
||||
reviewForm.comment = ''
|
||||
reviewDialog.value = true
|
||||
}
|
||||
async function submitReview() {
|
||||
if (!reviewApproved.value && !reviewForm.comment.trim()) {
|
||||
ElMessage.warning('驳回申请时必须填写审核意见。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await http.post(`/student-status-changes/${selected.value.id}/review`, {
|
||||
approved: reviewApproved.value,
|
||||
comment: reviewForm.comment,
|
||||
})
|
||||
reviewDialog.value = false
|
||||
ElMessage.success(reviewApproved.value ? '审核已通过,申请进入下一环节。' : '申请已驳回。')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack status-change-page">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="section-kicker">STUDENT RECORD TRANSFER</span>
|
||||
<h2>{{ isStudent ? '学籍异动申请' : '学籍异动审核' }}</h2>
|
||||
<p>{{ isStudent ? '在线办理休学、复学或退学申请,清晰跟踪每一级审核进度。' : '按辅导员、学院、学校三级权限逐级审核,最终结果同步学生学籍。' }}</p>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="isStudent"
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
:disabled="options?.hasPending || !options?.types?.length"
|
||||
@click="openApply"
|
||||
>发起申请</el-button>
|
||||
<el-button v-else :icon="RefreshRight" @click="load">刷新队列</el-button>
|
||||
</section>
|
||||
|
||||
<section v-if="isStudent && options" class="status-identity">
|
||||
<div>
|
||||
<span>CURRENT STUDENT STATUS</span>
|
||||
<b>{{ statusLabels[options.status] }}</b>
|
||||
<p>{{ options.hasPending ? '当前有申请正在流转,请等待处理。' : '当前可以提交新的学籍异动申请。' }}</p>
|
||||
</div>
|
||||
<div class="status-available">
|
||||
<span>可申请业务</span>
|
||||
<strong v-for="type in options.types" :key="type">{{ typeLabels[type] }}</strong>
|
||||
<em v-if="!options.types.length">当前状态无可申请业务</em>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!isStudent" class="status-review-summary">
|
||||
<div><span>当前待我审核</span><b>{{ currentQueue.length }}</b><small>件</small></div>
|
||||
<div><span>辖区申请总数</span><b>{{ changes.length }}</b><small>件</small></div>
|
||||
<div><span>已结束</span><b>{{ finishedCount }}</b><small>件</small></div>
|
||||
<p>系统仅开放当前审核层级的操作,所有越级请求都会由服务端拒绝。</p>
|
||||
</section>
|
||||
|
||||
<section class="status-folio-list" v-loading="loading">
|
||||
<article v-for="change in changes" :key="change.id" :class="{ actionable: canReview(change) }">
|
||||
<header>
|
||||
<div class="folio-number">
|
||||
<span>APPLICATION FOLIO</span>
|
||||
<b>{{ change.studentNumber }}</b>
|
||||
</div>
|
||||
<div class="folio-person">
|
||||
<span>{{ change.collegeName }} · {{ change.className }}</span>
|
||||
<h3>{{ change.name }} · {{ typeLabels[change.type] }}申请</h3>
|
||||
<p>{{ statusLabels[change.originalStatus] }} → {{ statusLabels[change.targetStatus] }} · 提交于 {{ dateText(change.submittedAt) }}</p>
|
||||
</div>
|
||||
<el-tag :type="stateTagType(change.state)" effect="plain">{{ stateLabels[change.state as ChangeState] }}</el-tag>
|
||||
</header>
|
||||
|
||||
<div class="folio-reason">
|
||||
<span>申请说明</span>
|
||||
<p>{{ change.reason }}</p>
|
||||
</div>
|
||||
|
||||
<div class="approval-track">
|
||||
<div v-for="(step, index) in steps" :key="step.state" :class="stepClass(change, index)">
|
||||
<i><el-icon v-if="stepClass(change, index).done"><Check /></el-icon><span v-else>{{ index + 1 }}</span></i>
|
||||
<b>{{ step.label }}</b>
|
||||
<small>{{ stepClass(change, index).done ? '已通过' : stepClass(change, index).active ? '处理中' : '待流转' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div v-if="change.reviewComment" class="review-comment">
|
||||
<span>最近审核意见</span><b>{{ change.reviewComment }}</b>
|
||||
</div>
|
||||
<div v-else class="review-comment"><span>流程编号</span><b>{{ change.id.slice(0, 8).toUpperCase() }}</b></div>
|
||||
<div class="folio-actions">
|
||||
<el-button v-if="isStudent && change.state === 'Submitted'" type="danger" plain @click="cancel(change)">撤回申请</el-button>
|
||||
<template v-if="canReview(change)">
|
||||
<el-button :icon="Close" type="danger" plain @click="openReview(change, false)">驳回</el-button>
|
||||
<el-button :icon="Stamp" type="primary" @click="openReview(change, true)">审核通过</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
<el-empty v-if="!changes.length && !loading" :description="isStudent ? '尚未提交学籍异动申请' : '当前辖区暂无学籍异动申请'" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="applyDialog" title="发起学籍异动申请" width="620px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="申请类型">
|
||||
<el-radio-group v-model="applyForm.type">
|
||||
<el-radio-button v-for="type in options?.types" :key="type" :value="type">{{ typeLabels[type] }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请说明">
|
||||
<el-input v-model="applyForm.reason" type="textarea" :rows="6" maxlength="1000" show-word-limit placeholder="请说明申请原因、预计时间以及需要学校了解的情况(至少 10 个字)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="applyDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitApply">提交申请</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="reviewDialog" :title="reviewApproved ? '审核通过' : '驳回申请'" width="600px">
|
||||
<div v-if="selected" class="review-target">
|
||||
<span>{{ selected.studentNumber }}</span>
|
||||
<b>{{ selected.name }} · {{ typeLabels[selected.type] }}申请</b>
|
||||
<p>{{ selected.collegeName }} · {{ selected.className }}</p>
|
||||
</div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item :label="reviewApproved ? '审核意见(选填)' : '驳回原因(必填)'">
|
||||
<el-input v-model="reviewForm.comment" type="textarea" :rows="4" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="reviewDialog = false">取消</el-button>
|
||||
<el-button :type="reviewApproved ? 'primary' : 'danger'" @click="submitReview">{{ reviewApproved ? '确认通过' : '确认驳回' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user