Files
Academic-Affairs-System/web/src/views/GraduationClearanceView.vue
T
2026-07-26 22:16:58 +08:00

292 lines
14 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } 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 keyword = ref('')
const progressFilter = ref('')
const resultPage = ref(1)
const resultPageSize = 20
const resultRequestId = ref(0)
let filterTimer: ReturnType<typeof setTimeout> | undefined
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 studentItems = computed(() => selected.value?.students?.items ?? [])
const studentTotal = computed(() => selected.value?.students?.total ?? 0)
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) {
resultPage.value = 1
await loadBatch(id)
}
async function loadBatch(id = selected.value?.id) {
if (!id) return
const requestId = ++resultRequestId.value
loading.value = true
try {
const response = await http.get(`/graduation-clearance/batches/${id}`, {
params: {
page: resultPage.value,
pageSize: resultPageSize,
keyword: keyword.value.trim() || undefined,
progress: progressFilter.value || undefined,
},
})
if (requestId === resultRequestId.value) selected.value = response.data
} catch (error) {
if (requestId === resultRequestId.value) ElMessage.error(apiErrorMessage(error))
} finally {
if (requestId === resultRequestId.value) loading.value = false
}
}
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 loadBatch()
} 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))
}
}
watch([keyword, progressFilter], () => {
resultPage.value = 1
if (filterTimer) clearTimeout(filterTimer)
filterTimer = setTimeout(() => loadBatch(), 300)
})
onUnmounted(() => {
if (filterTimer) clearTimeout(filterTimer)
resultRequestId.value++
})
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 }} 项离校手续 · {{ selected.studentCount }} 名毕业生</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="graduation-filter">
<el-input v-model="keyword" clearable placeholder="搜索学号、姓名、专业或班级" />
<el-select v-model="progressFilter" clearable placeholder="全部进度">
<el-option label="有必办待办" value="Pending" />
<el-option label="必办已完成" value="Completed" />
</el-select>
<span> {{ studentTotal }} 名学生</span>
</div>
<div class="clearance-ledgers">
<article v-for="student in studentItems" :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="!studentItems.length" description="没有符合筛选条件的离校办理记录" />
<el-pagination
v-if="studentTotal > resultPageSize"
v-model:current-page="resultPage"
:page-size="resultPageSize"
:total="studentTotal"
layout="total, prev, pager, next"
@current-change="loadBatch()"
/>
</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>