Files
Academic-Affairs-System/web/src/views/StudentStatusChangesView.vue
T
2026-08-11 11:52:43 +08:00

303 lines
12 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, 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 page = ref(1)
const total = ref(0)
const actionableTotal = ref(0)
const finishedTotal = ref(0)
const pageSize = 20
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' },
]
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(resetPage = false) {
if (resetPage) page.value = 1
loading.value = true
try {
const requests = [http.get('/student-status-changes', {
params: { page: page.value, pageSize },
})]
if (isStudent.value) requests.push(http.get('/student-status-changes/options'))
const [changeResponse, optionResponse] = await Promise.all(requests)
changes.value = changeResponse.data.items
total.value = changeResponse.data.total
actionableTotal.value = changeResponse.data.actionableTotal
finishedTotal.value = changeResponse.data.finishedTotal
if (optionResponse) options.value = optionResponse.data
if (changes.value.length === 0 && page.value > 1) {
page.value--
await load()
}
} 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(true)
} 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>{{ actionableTotal }}</b><small></small></div>
<div><span>辖区申请总数</span><b>{{ total }}</b><small></small></div>
<div><span>已结束</span><b>{{ finishedTotal }}</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 ? '尚未提交学籍异动申请' : '当前辖区暂无学籍异动申请'" />
<el-pagination
v-if="total > pageSize"
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
class="table-pagination"
@current-change="() => load()"
/>
</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>