学籍异动:休学、复学、退学申请及辅导员→学院→教务处分级审批。

毕业审核:批次计算、缺失课程检查、人工复核、结果发布。
学位授予:毕业资格与 GPA 计算、人工调整、发布授予结果。
毕业离校:离校事项配置、责任角色分工、逐项办理及批次关闭。
This commit is contained in:
2026-07-24 17:30:05 +08:00 Unverified
parent 7493ab4a60
commit b0eaa20da6
34 changed files with 11137 additions and 8 deletions
+282
View File
@@ -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>