You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
653 lines
18 KiB
Vue
653 lines
18 KiB
Vue
<template>
|
|
<div class="admin-container">
|
|
<!-- 头部 -->
|
|
<div class="admin-header">
|
|
<div class="admin-logo">
|
|
<el-icon><Lock /></el-icon>
|
|
<span>培训报名管理系统</span>
|
|
</div>
|
|
<div class="admin-user">
|
|
<el-dropdown @command="handleCommand">
|
|
<span class="user-info">
|
|
<el-icon><Avatar /></el-icon>
|
|
<span>{{ adminUser.username }}</span>
|
|
<el-icon><ArrowDown /></el-icon>
|
|
</span>
|
|
<template #dropdown>
|
|
<el-dropdown-menu>
|
|
<el-dropdown-item command="logout">
|
|
<el-icon><SwitchButton /></el-icon>
|
|
退出登录
|
|
</el-dropdown-item>
|
|
</el-dropdown-menu>
|
|
</template>
|
|
</el-dropdown>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 主内容 -->
|
|
<div class="admin-main">
|
|
<!-- 统计卡片 -->
|
|
<div class="admin-stats">
|
|
<div class="stat-card">
|
|
<div class="stat-icon blue">
|
|
<el-icon><Document /></el-icon>
|
|
</div>
|
|
<div class="stat-value">{{ statistics.total }}</div>
|
|
<div class="stat-label">总报名数</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon green">
|
|
<el-icon><CircleCheck /></el-icon>
|
|
</div>
|
|
<div class="stat-value">{{ statistics.paid }}</div>
|
|
<div class="stat-label">已支付</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon orange">
|
|
<el-icon><Clock /></el-icon>
|
|
</div>
|
|
<div class="stat-value">{{ statistics.submitted }}</div>
|
|
<div class="stat-label">待支付</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon red">
|
|
<el-icon><Money /></el-icon>
|
|
</div>
|
|
<div class="stat-value">¥{{ statistics.amount }}</div>
|
|
<div class="stat-label">收款金额</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 搜索筛选 -->
|
|
<div class="search-bar">
|
|
<el-input
|
|
v-model="searchKeyword"
|
|
placeholder="搜索报名编号、姓名、手机号、所在单位"
|
|
prefix-icon="Search"
|
|
clearable
|
|
style="max-width: 300px;"
|
|
@clear="handleSearch"
|
|
@keyup.enter="handleSearch"
|
|
/>
|
|
<el-select v-model="filterStatus" placeholder="筛选状态" clearable style="width: 150px;">
|
|
<el-option label="全部" value="" />
|
|
<el-option label="已支付" value="paid" />
|
|
<el-option label="待支付" value="submitted" />
|
|
</el-select>
|
|
<el-button type="primary" icon="Search" @click="handleSearch">搜索</el-button>
|
|
<el-button icon="Refresh" @click="refreshData">刷新</el-button>
|
|
</div>
|
|
|
|
<!-- 数据表格 -->
|
|
<div class="admin-table-card">
|
|
<div class="table-header">
|
|
<h3>报名信息列表</h3>
|
|
<el-button type="success" icon="Download" @click="exportData">
|
|
导出数据
|
|
</el-button>
|
|
</div>
|
|
|
|
<el-table
|
|
:data="filteredData"
|
|
stripe
|
|
border
|
|
style="width: 100%"
|
|
v-loading="loading"
|
|
:header-cell-style="{ background: '#f5f7fa', color: '#303133', fontWeight: '600' }"
|
|
>
|
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
|
<el-table-column prop="id" label="报名编号" width="140" align="center">
|
|
<template #default="{ row }">
|
|
<span class="registration-id">{{ row.id }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="name" label="姓名" width="100" align="center" />
|
|
<el-table-column prop="organization" label="所在单位" min-width="180" show-overflow-tooltip />
|
|
<el-table-column prop="referrer" label="推荐人" width="100" align="center" />
|
|
<el-table-column prop="referrerCompany" label="推荐人单位" min-width="180" show-overflow-tooltip />
|
|
<el-table-column prop="department" label="部门" width="140" show-overflow-tooltip />
|
|
<el-table-column prop="title" label="职务" width="100" align="center" />
|
|
<el-table-column prop="phone" label="手机号" width="120" align="center" />
|
|
<el-table-column prop="period" label="期次" width="180" align="center" />
|
|
<el-table-column prop="amount" label="金额" width="90" align="center">
|
|
<template #default="{ row }">
|
|
<span class="amount-text">¥{{ row.amount }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="status" label="状态" width="100" align="center">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.status === 'paid' ? 'success' : 'warning'">
|
|
{{ row.status === 'paid' ? '已支付' : '待支付' }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="createTime" label="报名时间" width="160" align="center">
|
|
<template #default="{ row }">
|
|
{{ formatTime(row.createTime) }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="180" align="center" fixed="right">
|
|
<template #default="{ row }">
|
|
<el-button type="primary" link @click="viewDetail(row)">
|
|
<el-icon><View /></el-icon>
|
|
详情
|
|
</el-button>
|
|
<el-button type="warning" link @click="editStatus(row)">
|
|
<el-icon><Edit /></el-icon>
|
|
{{ row.status === 'paid' ? '改为待支付' : '改为已支付' }}
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
|
|
<!-- 分页 -->
|
|
<div class="pagination-wrapper">
|
|
<el-pagination
|
|
:current-page="currentPage"
|
|
:page-size="pageSize"
|
|
:page-sizes="[10, 20, 50, 100]"
|
|
:total="total"
|
|
layout="total, sizes, prev, pager, next, jumper"
|
|
@size-change="handleSizeChange"
|
|
@current-change="handleCurrentChange"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 详情弹窗 -->
|
|
<el-dialog
|
|
v-model="detailDialogVisible"
|
|
title="报名详情"
|
|
width="600px"
|
|
destroy-on-close
|
|
>
|
|
<div class="detail-content" v-if="currentDetail">
|
|
<el-descriptions :column="2" border>
|
|
<el-descriptions-item label="报名编号">{{ currentDetail.id }}</el-descriptions-item>
|
|
<el-descriptions-item label="姓名">{{ currentDetail.name }}</el-descriptions-item>
|
|
<el-descriptions-item label="所在单位">{{ currentDetail.organization }}</el-descriptions-item>
|
|
<el-descriptions-item label="所在部门">{{ currentDetail.department || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="推荐人">{{ currentDetail.referrer }}</el-descriptions-item>
|
|
<el-descriptions-item label="推荐人单位">{{ currentDetail.referrerCompany }}</el-descriptions-item>
|
|
<el-descriptions-item label="职务/职称">{{ currentDetail.title }}</el-descriptions-item>
|
|
<el-descriptions-item label="手机号码">{{ currentDetail.phone }}</el-descriptions-item>
|
|
<el-descriptions-item label="电子邮箱">{{ currentDetail.email }}</el-descriptions-item>
|
|
<el-descriptions-item label="身份证号">{{ currentDetail.idCard }}</el-descriptions-item>
|
|
<el-descriptions-item label="报名期次">{{ currentDetail.period }}</el-descriptions-item>
|
|
<el-descriptions-item label="报名金额">¥{{ currentDetail.amount }}</el-descriptions-item>
|
|
<el-descriptions-item label="报名状态">
|
|
<el-tag :type="currentDetail.status === 'paid' ? 'success' : 'warning'">
|
|
{{ currentDetail.status === 'paid' ? '已支付' : '待支付' }}
|
|
</el-tag>
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="报名时间">
|
|
{{ formatTime(currentDetail.createTime) }}
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="备注说明" :span="2">
|
|
{{ currentDetail.remark || '-' }}
|
|
</el-descriptions-item>
|
|
</el-descriptions>
|
|
</div>
|
|
<template #footer>
|
|
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
|
<el-button
|
|
v-if="currentDetail && currentDetail.status === 'submitted'"
|
|
type="success"
|
|
@click="markAsPaid"
|
|
>
|
|
标记为已支付
|
|
</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { getRegistrationList, getRegistrationStatistics, updateRegistrationStatus, getRegistrationDetail } from '../../api/registration'
|
|
|
|
const router = useRouter()
|
|
// 数据
|
|
const loading = ref(false)
|
|
const searchKeyword = ref('')
|
|
const filterStatus = ref('')
|
|
const currentPage = ref(1)
|
|
const pageSize = ref(10)
|
|
const total = ref(0)
|
|
const detailDialogVisible = ref(false)
|
|
const currentDetail = ref(null)
|
|
|
|
// 管理员信息
|
|
const adminUser = reactive({
|
|
username: 'admin'
|
|
})
|
|
|
|
// 统计数据
|
|
const statistics = reactive({
|
|
total: 0,
|
|
paid: 0,
|
|
submitted: 0,
|
|
amount: 0
|
|
})
|
|
|
|
// 原始数据
|
|
const allData = ref([])
|
|
|
|
// 过滤后的数据
|
|
const filteredData = computed(() => {
|
|
return allData.value
|
|
})
|
|
|
|
// 加载数据
|
|
const loadData = async () => {
|
|
loading.value = true
|
|
try {
|
|
const res = await getRegistrationList({
|
|
keyword: searchKeyword.value,
|
|
pageNum: currentPage.value,
|
|
pageSize: pageSize.value,
|
|
status: filterStatus.value
|
|
})
|
|
|
|
// 映射接口字段
|
|
allData.value = res.data.records.map(item => ({
|
|
id: item.bmId,
|
|
name: item.name,
|
|
organization: item.company,
|
|
department: item.department,
|
|
title: item.title,
|
|
phone: item.phone,
|
|
period: item.period,
|
|
amount: item.fee,
|
|
status: item.status,
|
|
createTime: item.submitTime,
|
|
email: item.email,
|
|
idCard: item.idCard,
|
|
remark: item.remark,
|
|
randomCode: item.randomCode,
|
|
referrer: item.referrer,
|
|
referrerCompany: item.referrerCompany
|
|
}))
|
|
|
|
total.value = res.data.total
|
|
} catch (e) {
|
|
console.error('加载数据失败', e)
|
|
}
|
|
loading.value = false
|
|
}
|
|
|
|
// 加载统计数据
|
|
const loadStatistics = async () => {
|
|
try {
|
|
const res = await getRegistrationStatistics()
|
|
statistics.total = res.data.totalRegistrations
|
|
statistics.paid = res.data.paidRegistrations
|
|
statistics.submitted = res.data.pendingPaymentRegistrations
|
|
statistics.amount = res.data.paidAmount
|
|
} catch (e) {
|
|
console.error('加载统计数据失败', e)
|
|
}
|
|
}
|
|
|
|
// 刷新数据
|
|
const refreshData = () => {
|
|
currentPage.value = 1
|
|
loadData()
|
|
ElMessage.success('数据已刷新')
|
|
}
|
|
|
|
// 搜索
|
|
const handleSearch = () => {
|
|
currentPage.value = 1
|
|
loadData()
|
|
}
|
|
|
|
// 分页
|
|
const handleSizeChange = (val) => {
|
|
pageSize.value = val
|
|
currentPage.value = 1
|
|
loadData()
|
|
}
|
|
|
|
const handleCurrentChange = (val) => {
|
|
currentPage.value = val
|
|
loadData()
|
|
}
|
|
|
|
// 格式化时间
|
|
const formatTime = (time) => {
|
|
if (!time) return '-'
|
|
const date = new Date(time)
|
|
return date.toLocaleString('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
}
|
|
|
|
// 查看详情
|
|
const viewDetail = (row) => {
|
|
currentDetail.value = row
|
|
detailDialogVisible.value = true
|
|
}
|
|
|
|
// 标记为已支付
|
|
const markAsPaid = async () => {
|
|
ElMessageBox.confirm('确定将该报名标记为已支付吗?', '提示', {
|
|
confirmButtonText: '确定',
|
|
cancelButtonText: '取消',
|
|
type: 'warning'
|
|
}).then(async () => {
|
|
try {
|
|
await updateRegistrationStatus(currentDetail.value.randomCode, 'paid')
|
|
detailDialogVisible.value = false
|
|
loadData()
|
|
loadStatistics()
|
|
ElMessage.success('操作成功!')
|
|
} catch (e) {
|
|
console.error('操作失败', e)
|
|
}
|
|
}).catch(() => {})
|
|
}
|
|
|
|
// 编辑支付状态
|
|
const editStatus = (row) => {
|
|
const newStatus = row.status === 'paid' ? 'submitted' : 'paid'
|
|
const actionText = newStatus === 'paid' ? '标记为已支付' : '标记为待支付'
|
|
|
|
ElMessageBox.confirm(`确定要将该报名${actionText}吗?`, '提示', {
|
|
confirmButtonText: '确定',
|
|
cancelButtonText: '取消',
|
|
type: 'warning'
|
|
}).then(async () => {
|
|
try {
|
|
await updateRegistrationStatus(row.randomCode, newStatus)
|
|
loadData()
|
|
loadStatistics()
|
|
ElMessage.success('状态修改成功!')
|
|
} catch (e) {
|
|
console.error('操作失败', e)
|
|
}
|
|
}).catch(() => {})
|
|
}
|
|
|
|
// 导出数据
|
|
const exportData = () => {
|
|
if (allData.value.length === 0) {
|
|
ElMessage.warning('暂无数据可导出')
|
|
return
|
|
}
|
|
|
|
const exportData = allData.value.map(item => ({
|
|
报名编号: item.id,
|
|
姓名: item.name,
|
|
所在单位: item.organization,
|
|
推荐人: item.referrer,
|
|
推荐人单位: item.referrerCompany,
|
|
部门: item.department || '',
|
|
职务: item.title,
|
|
手机号: item.phone,
|
|
邮箱: item.email,
|
|
身份证号: item.idCard,
|
|
期次: item.period,
|
|
金额: item.amount,
|
|
状态: item.status === 'paid' ? '已支付' : '待支付',
|
|
报名时间: formatTime(item.createTime),
|
|
备注: item.remark || ''
|
|
}))
|
|
|
|
// 简单导出为CSV
|
|
const headers = Object.keys(exportData[0]).join(',')
|
|
const rows = exportData.map(item => Object.values(item).join(','))
|
|
const csv = [headers, ...rows].join('\n')
|
|
|
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = `报名数据_${new Date().toLocaleDateString()}.csv`
|
|
link.click()
|
|
URL.revokeObjectURL(url)
|
|
|
|
ElMessage.success('数据导出成功!')
|
|
}
|
|
|
|
// 退出登录
|
|
const handleCommand = (command) => {
|
|
if (command === 'logout') {
|
|
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
|
confirmButtonText: '确定',
|
|
cancelButtonText: '取消',
|
|
type: 'warning'
|
|
}).then(() => {
|
|
localStorage.removeItem('adminToken')
|
|
localStorage.removeItem('adminUser')
|
|
router.push('/admin/login')
|
|
ElMessage.success('已退出登录')
|
|
}).catch(() => {})
|
|
}
|
|
}
|
|
|
|
// 初始化
|
|
onMounted(() => {
|
|
const storedUser = localStorage.getItem('adminUser')
|
|
if (storedUser) {
|
|
const user = JSON.parse(storedUser)
|
|
adminUser.username = user.username
|
|
}
|
|
loadStatistics()
|
|
loadData()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.admin-container {
|
|
min-height: 100vh;
|
|
background: var(--bg-color);
|
|
}
|
|
|
|
.admin-header {
|
|
background: #fff;
|
|
padding: 16px 32px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.admin-logo {
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: var(--primary-color);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.user-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
cursor: pointer;
|
|
padding: 8px 12px;
|
|
border-radius: 8px;
|
|
transition: background 0.3s;
|
|
}
|
|
|
|
.user-info:hover {
|
|
background: var(--bg-color);
|
|
}
|
|
|
|
.admin-main {
|
|
padding: 24px 32px;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.admin-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: #fff;
|
|
padding: 24px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
|
}
|
|
|
|
.stat-card:hover {
|
|
transform: translateY(-4px);
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
|
}
|
|
|
|
.stat-icon {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.stat-icon.blue { background: linear-gradient(135deg, #409EFF 0%, #66b1ff 100%); }
|
|
.stat-icon.green { background: linear-gradient(135deg, #67C23A 0%, #85ce61 100%); }
|
|
.stat-icon.orange { background: linear-gradient(135deg, #E6A23C 0%, #ebb563 100%); }
|
|
.stat-icon.red { background: linear-gradient(135deg, #F56C6C 0%, #f78989 100%); }
|
|
|
|
.stat-icon .el-icon {
|
|
font-size: 24px;
|
|
color: #fff;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 14px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.search-bar {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 20px;
|
|
background: #fff;
|
|
padding: 16px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
|
}
|
|
|
|
.admin-table-card {
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
|
}
|
|
|
|
.table-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.table-header h3 {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.registration-id {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 13px;
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
.amount-text {
|
|
font-weight: 600;
|
|
color: var(--danger-color);
|
|
}
|
|
|
|
.pagination-wrapper {
|
|
margin-top: 20px;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.detail-content {
|
|
padding: 10px 0;
|
|
}
|
|
|
|
@media screen and (max-width: 768px) {
|
|
.admin-header {
|
|
padding: 12px 16px;
|
|
}
|
|
|
|
.admin-main {
|
|
padding: 16px;
|
|
}
|
|
|
|
.admin-stats {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
|
|
.stat-card {
|
|
padding: 16px;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 22px;
|
|
}
|
|
|
|
.search-bar {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.search-bar .el-input,
|
|
.search-bar .el-select {
|
|
width: 100% !important;
|
|
}
|
|
|
|
.table-header {
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.el-table {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.pagination-wrapper {
|
|
justify-content: center;
|
|
}
|
|
}
|
|
|
|
@media screen and (max-width: 480px) {
|
|
.admin-stats {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|