303 lines
12 KiB
Vue
303 lines
12 KiB
Vue
<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>
|