缴费系统

This commit is contained in:
biss committed 2026-08-22 18:52:41 +08:00
1 parent 6e2eaad0fa
commit b1075acf8f
17 files changed
+1161

No files matched your search

+9
View File
@@ -163,6 +163,15 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
path: '/classroom-reservations',
label: hasAnyRole(['CollegeAdmin']) ? '教室借用审核' : '教室借用',
},
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
{ path: '/campus-registrations', label: isStudent.value ? '校园报名与缴费' : '校园支付与报名' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin']),
{ path: '/campus-wallets', label: '学生余额管理' },
),
...whenVisible(hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Student']), { path: '/tuition', label: isStudent.value ? '我的学费' : '学费管理' }),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'MaintenanceAdmin', 'MaintenanceStaff']),
{ path: '/classroom-repairs', label: '教室报修与维修' },
+13
View File
@@ -233,6 +233,19 @@ const router = createRouter({
name: 'classroom-reservations',
component: () => import('../views/ClassroomReservationsView.vue'),
},
{
path: 'campus-registrations',
name: 'campus-registrations',
component: () => import('../views/CampusRegistrationsView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'] },
},
{
path: 'campus-wallets',
name: 'campus-wallets',
component: () => import('../views/CampusWalletManagementView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
},
{ path: 'tuition', name: 'tuition', component: () => import('../views/TuitionView.vue'), meta: { roles: ['SuperAdmin','AcademicAdmin','Student'] } },
{
path: 'classroom-repairs',
name: 'classroom-repairs',
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
type ProjectStatus = 'Draft' | 'Published' | 'Closed'
type ApplicationStatus = 'PendingReview' | 'PendingPayment' | 'Confirmed' | 'Waitlisted' | 'Rejected' | 'Cancelled'
interface Project { id: string; code: string; name: string; description?: string; registrationStartsAt: string; registrationEndsAt: string; capacity: number; feeAmount: number; requiresReview: boolean; status: ProjectStatus; contactName?: string; contactPhone?: string; notes?: string; collegeName: string; confirmedCount: number; isAvailableNow: boolean }
interface Application { id: string; projectId: string; projectCode: string; projectName: string; status: ApplicationStatus; applicantNote?: string; createdAt: string; reviewComment?: string; studentId: string; studentNumber: string; studentName: string; order?: { id: string; orderNumber: string; amount: number; paymentMethod: 'Free' | 'Offline' | 'Balance'; status: 'Pending' | 'Paid' | 'Cancelled'; referenceNumber?: string } }
const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
const canManage = computed(() => auth.user?.roles.some(x => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(x)) ?? false)
const tab = ref(isStudent.value ? 'projects' : 'manage')
const projects = ref<Project[]>([]); const mine = ref<Application[]>([]); const applications = ref<Application[]>([]); const loading = ref(false); const dialog = ref(false); const editingId = ref<string | null>(null)
const balance = ref(0)
const form = reactive({ code: '', name: '', description: '', registrationStartsAt: '', registrationEndsAt: '', capacity: 50, feeAmount: 0, requiresReview: true, status: 'Draft' as ProjectStatus, contactName: '', contactPhone: '', notes: '' })
const statusLabel: Record<ApplicationStatus, string> = { PendingReview: '待审核', PendingPayment: '待缴费', Confirmed: '已确认', Waitlisted: '候补中', Rejected: '已驳回', Cancelled: '已取消' }
const statusType: Record<ApplicationStatus, 'warning' | 'success' | 'danger' | 'info'> = { PendingReview: 'warning', PendingPayment: 'warning', Confirmed: 'success', Waitlisted: 'info', Rejected: 'danger', Cancelled: 'info' }
const projectStatusLabel: Record<ProjectStatus, string> = { Draft: '草稿', Published: '已发布', Closed: '已关闭' }
const localDateTime = (value?: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
const amount = (value: number) => value === 0 ? '免费' : `¥${value.toFixed(2)}`
const application = (value: unknown) => value as Application
function applicationStatus(value: unknown) { const status = value as ApplicationStatus; return { label: statusLabel[status] ?? '未知状态', type: statusType[status] ?? 'info' as const } }
function resetForm(project?: Project) { editingId.value = project?.id ?? null; Object.assign(form, project ? { code: project.code, name: project.name, description: project.description ?? '', registrationStartsAt: project.registrationStartsAt.slice(0, 16), registrationEndsAt: project.registrationEndsAt.slice(0, 16), capacity: project.capacity, feeAmount: project.feeAmount, requiresReview: project.requiresReview, status: project.status, contactName: project.contactName ?? '', contactPhone: project.contactPhone ?? '', notes: project.notes ?? '' } : { code: '', name: '', description: '', registrationStartsAt: '', registrationEndsAt: '', capacity: 50, feeAmount: 0, requiresReview: true, status: 'Draft', contactName: '', contactPhone: '', notes: '' }); dialog.value = true }
async function loadProjects() { const { data } = await http.get('/campus-registrations/projects'); projects.value = data }
async function loadMine() { if (isStudent.value) { const { data } = await http.get('/campus-registrations/mine'); mine.value = data } }
async function loadWallet() { if (isStudent.value) { const { data } = await http.get('/campus-registrations/wallet/mine'); balance.value = data.balance } }
async function loadApplications() { if (canManage.value) { const { data } = await http.get('/campus-registrations/applications'); applications.value = data } }
async function saveProject() { try { const payload = { ...form, registrationStartsAt: new Date(form.registrationStartsAt).toISOString(), registrationEndsAt: new Date(form.registrationEndsAt).toISOString(), collegeId: null }; if (editingId.value) await http.put(`/campus-registrations/projects/${editingId.value}`, payload); else await http.post('/campus-registrations/projects', payload); ElMessage.success('项目已保存'); dialog.value = false; await loadProjects() } catch (e) { ElMessage.error(apiErrorMessage(e)) } }
async function apply(project: Project) { try { const { value } = await ElMessageBox.prompt('可选:填写报名说明', `报名:${project.name}`, { inputPlaceholder: '报名说明' }); await http.post(`/campus-registrations/projects/${project.id}/apply`, { note: value || null }); ElMessage.success('报名已提交'); await Promise.all([loadProjects(), loadMine()]) } catch (e) { if (e !== 'cancel' && e !== 'close') ElMessage.error(apiErrorMessage(e)) } }
async function review(item: Application, approved: boolean) { try { const { value } = await ElMessageBox.prompt(approved ? '审核通过后将生成缴费订单或直接确认免费报名。' : '请填写驳回原因', approved ? '通过报名' : '驳回报名', { inputPlaceholder: approved ? '可选审核意见' : '驳回原因' }); await http.post(`/campus-registrations/applications/${item.id}/review`, { approved, comment: value || null }); ElMessage.success('处理完成'); await loadApplications() } catch (e) { if (e !== 'cancel' && e !== 'close') ElMessage.error(apiErrorMessage(e)) } }
async function confirmOffline(item: Application) { try { const { value } = await ElMessageBox.prompt('填写线下收款凭据编号(可选)', '确认线下缴费'); await http.post(`/campus-registrations/orders/${item.order!.id}/confirm-offline`, { referenceNumber: value || null, note: null }); ElMessage.success('已确认收款和报名'); await loadApplications() } catch (e) { if (e !== 'cancel' && e !== 'close') ElMessage.error(apiErrorMessage(e)) } }
async function cancel(item: Application) { try { await ElMessageBox.confirm('撤销后不可恢复,确定继续吗?', '撤销报名', { type: 'warning' }); await http.post(`/campus-registrations/applications/${item.id}/cancel`); ElMessage.success('报名已撤销'); await loadMine() } catch (e) { if (e !== 'cancel' && e !== 'close') ElMessage.error(apiErrorMessage(e)) } }
async function payBalance(item: Application) { try { await ElMessageBox.confirm(`将从校园余额扣除 ${amount(item.order!.amount)},确定支付吗?`, '余额支付', { type: 'warning' }); await http.post(`/campus-registrations/orders/${item.order!.id}/pay-balance`); ElMessage.success('余额支付成功,报名已确认'); await Promise.all([loadMine(), loadWallet(), loadProjects()]) } catch (e) { if (e !== 'cancel' && e !== 'close') ElMessage.error(apiErrorMessage(e)) } }
async function exportApplications() { loading.value = true; try { const response = await http.get('/campus-registrations/applications/export.xlsx', { responseType: 'blob' }); const url = URL.createObjectURL(response.data); const a = document.createElement('a'); a.href = url; a.download = '校园报名与缴费.xlsx'; a.click(); URL.revokeObjectURL(url) } finally { loading.value = false } }
onMounted(async () => { loading.value = true; try { await Promise.all([loadProjects(), loadMine(), loadWallet(), loadApplications()]) } finally { loading.value = false } })
</script>
<template>
<section class="registration-page" v-loading="loading">
<header class="page-heading"><div><p class="eyebrow">CAMPUS SERVICES</p><h2>校园支付与报名</h2><p>报名、审核、线下缴费确认和名单导出在同一闭环内完成。</p></div><el-button v-if="canManage" type="primary" @click="resetForm()">新建报名项目</el-button></header>
<el-tabs v-model="tab"><el-tab-pane label="报名项目" name="projects"><div class="project-grid"><el-card v-for="project in projects" :key="project.id" class="project-card"><template #header><div class="card-head"><b>{{ project.name }}</b><el-tag :type="project.status === 'Published' ? 'success' : 'info'">{{ projectStatusLabel[project.status] }}</el-tag></div></template><p class="code">{{ project.code }} · {{ project.collegeName }}</p><p class="description">{{ project.description || '暂无项目说明。' }}</p><dl><div><dt>报名时间</dt><dd>{{ localDateTime(project.registrationStartsAt) }} 至 {{ localDateTime(project.registrationEndsAt) }}</dd></div><div><dt>名额 / 已确认</dt><dd>{{ project.capacity }} / {{ project.confirmedCount }} 人</dd></div><div><dt>费用</dt><dd>{{ amount(project.feeAmount) }} {{ project.requiresReview ? '· 需审核' : '· 自动受理' }}</dd></div></dl><div class="card-actions"><el-button v-if="isStudent" type="primary" :disabled="!project.isAvailableNow" @click="apply(project)">{{ project.isAvailableNow ? '立即报名' : '当前不可报名' }}</el-button><el-button v-if="canManage" @click="resetForm(project)">编辑项目</el-button></div></el-card></div><el-empty v-if="projects.length === 0" description="暂无可见报名项目" /></el-tab-pane>
<el-tab-pane v-if="isStudent" label="我的报名与订单" name="mine"><div class="wallet"><span>校园余额</span><b>{{ amount(balance) }}</b><small>余额由管理员调整,可用于支付待缴费报名。</small></div><el-table :data="mine"><el-table-column prop="projectName" label="项目" min-width="190"/><el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="applicationStatus(row.status).type">{{ applicationStatus(row.status).label }}</el-tag></template></el-table-column><el-table-column label="订单" min-width="210"><template #default="{ row }"><span v-if="application(row).order">{{ amount(application(row).order!.amount) }} · {{ application(row).order!.paymentMethod === 'Offline' ? '线下缴费' : application(row).order!.paymentMethod === 'Balance' ? '余额支付' : '免费' }} · {{ application(row).order!.status === 'Paid' ? '已确认' : '待确认' }}</span><span v-else>—</span></template></el-table-column><el-table-column prop="reviewComment" label="审核意见" min-width="160"/><el-table-column label="操作" width="160"><template #default="{ row }"><el-button v-if="application(row).status === 'PendingPayment' && application(row).order?.status === 'Pending'" link type="primary" @click="payBalance(application(row))">余额支付</el-button><el-button v-if="['PendingReview','PendingPayment','Waitlisted'].includes(application(row).status)" link type="danger" @click="cancel(application(row))">撤销</el-button></template></el-table-column></el-table><el-empty v-if="mine.length === 0" description="尚未提交报名" /></el-tab-pane>
<el-tab-pane v-if="canManage" label="审核与收款" name="manage"><div class="toolbar"><span>所有操作均按项目所属学院的数据范围限制。</span><el-button @click="exportApplications">导出当前可见报名</el-button></div><el-table :data="applications"><el-table-column label="项目与学生" min-width="220"><template #default="{ row }"><b>{{ application(row).projectName }}</b><small>{{ application(row).projectCode }} · {{ application(row).studentNumber }} {{ application(row).studentName }}</small></template></el-table-column><el-table-column label="报名状态" width="120"><template #default="{ row }"><el-tag :type="applicationStatus(row.status).type">{{ applicationStatus(row.status).label }}</el-tag></template></el-table-column><el-table-column label="订单" min-width="190"><template #default="{ row }"><span v-if="application(row).order">{{ application(row).order!.orderNumber }}<small>{{ amount(application(row).order!.amount) }} · {{ application(row).order!.status }}</small></span><span v-else>—</span></template></el-table-column><el-table-column prop="createdAt" label="提交时间" width="170"><template #default="{ row }">{{ localDateTime(application(row).createdAt) }}</template></el-table-column><el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button v-if="application(row).status === 'PendingReview'" link type="success" @click="review(application(row), true)">通过</el-button><el-button v-if="application(row).status === 'PendingReview'" link type="danger" @click="review(application(row), false)">驳回</el-button><el-button v-if="application(row).status === 'PendingPayment' && application(row).order?.paymentMethod === 'Offline'" link type="primary" @click="confirmOffline(application(row))">确认收款</el-button></template></el-table-column></el-table><el-empty v-if="applications.length === 0" description="暂无待处理或历史报名" /></el-tab-pane></el-tabs>
<el-dialog v-model="dialog" :title="editingId ? '编辑报名项目' : '新建报名项目'" width="680px"><el-form label-position="top"><div class="form-grid"><el-form-item label="项目编码" required><el-input v-model="form.code" placeholder="如 CAMPUS-2026-001"/></el-form-item><el-form-item label="项目名称" required><el-input v-model="form.name"/></el-form-item><el-form-item label="报名开始" required><el-date-picker v-model="form.registrationStartsAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%"/></el-form-item><el-form-item label="报名结束" required><el-date-picker v-model="form.registrationEndsAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%"/></el-form-item><el-form-item label="名额" required><el-input-number v-model="form.capacity" :min="1" style="width:100%"/></el-form-item><el-form-item label="收费金额(元)"><el-input-number v-model="form.feeAmount" :min="0" :precision="2" style="width:100%"/></el-form-item></div><el-form-item label="项目说明"><el-input v-model="form.description" type="textarea" :rows="3"/></el-form-item><el-form-item label="受理方式"><el-switch v-model="form.requiresReview" active-text="人工审核后受理" inactive-text="直接受理"/></el-form-item><el-form-item label="发布状态"><el-radio-group v-model="form.status"><el-radio-button value="Draft">草稿</el-radio-button><el-radio-button value="Published">发布</el-radio-button><el-radio-button value="Closed">关闭</el-radio-button></el-radio-group></el-form-item><div class="form-grid"><el-form-item label="联系人"><el-input v-model="form.contactName"/></el-form-item><el-form-item label="联系电话"><el-input v-model="form.contactPhone"/></el-form-item></div><el-form-item label="管理备注"><el-input v-model="form.notes" type="textarea" :rows="2"/></el-form-item></el-form><template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" @click="saveProject">保存</el-button></template></el-dialog>
</section>
</template>
<style scoped>
.registration-page { display:grid; gap:20px }.page-heading,.card-head,.toolbar { display:flex; align-items:center; justify-content:space-between; gap:16px }.page-heading h2{margin:0;color:#183b56}.page-heading p{margin:5px 0;color:#6c7f90}.eyebrow{color:#17867c!important;font-size:12px;font-weight:800;letter-spacing:.16em}.project-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:16px}.project-card{border:1px solid #dfe9ec}.card-head b{color:#183b56}.code,dt,small{color:#738596;font-size:13px}.description{min-height:42px;color:#385368}dl{display:grid;gap:8px;margin:16px 0}dl div{display:grid;gap:2px}dd{margin:0;color:#29495f}.card-actions{display:flex;justify-content:flex-end;border-top:1px solid #edf1f2;padding-top:12px}.toolbar{margin-bottom:16px;color:#6c7f90}.wallet{display:flex;align-items:baseline;gap:12px;padding:14px 18px;margin-bottom:16px;border:1px solid #cfe8e1;border-radius:10px;background:#f1faf7;color:#486575}.wallet b{font-size:24px;color:#17867c}.el-table b,.el-table small{display:block}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}@media(max-width:720px){.page-heading,.toolbar{align-items:flex-start;flex-direction:column}.page-heading .el-button,.toolbar .el-button{width:100%}.form-grid{grid-template-columns:1fr}.wallet{align-items:flex-start;flex-direction:column;gap:3px}}
</style>
Loaded 3 of 17 files, more files were not shown because too many files have changed in this diff. Show more