<template>
|
<div class="dashboard">
|
<el-row :gutter="20" class="stats-row">
|
<el-col :span="6">
|
<div class="stat-card">
|
<div class="icon-container blue">
|
<el-icon><Trophy /></el-icon>
|
</div>
|
<div class="stat-number">{{ stats.activeActivities }}</div>
|
<div class="stat-title">当前比赛</div>
|
</div>
|
</el-col>
|
<el-col :span="6">
|
<div class="stat-card">
|
<div class="icon-container green">
|
<el-icon><UserFilled /></el-icon>
|
</div>
|
<div class="stat-number">{{ stats.totalPlayers }}</div>
|
<div class="stat-title">参赛总人数</div>
|
</div>
|
</el-col>
|
<el-col :span="6">
|
<div class="stat-card">
|
<div class="icon-container yellow">
|
<el-icon><Clock /></el-icon>
|
</div>
|
<div class="stat-number">{{ stats.pendingReviews }}</div>
|
<div class="stat-title">报名待审核</div>
|
</div>
|
</el-col>
|
<el-col :span="6">
|
<div class="stat-card">
|
<div class="icon-container red">
|
<el-icon><User /></el-icon>
|
</div>
|
<div class="stat-number">{{ stats.totalJudges }}</div>
|
<div class="stat-title">评委总数</div>
|
</div>
|
</el-col>
|
</el-row>
|
|
<div class="chart-section">
|
<div class="chart-card">
|
<div v-if="trendChartLoading" class="chart-mask">加载中...</div>
|
<div class="chart-header">
|
<h3 class="chart-header-title">报名趋势</h3>
|
<span class="chart-header-desc">最近 15 天</span>
|
</div>
|
<div class="chart-container" ref="trendChartRef"></div>
|
</div>
|
<div class="chart-card">
|
<div v-if="regionChartLoading" class="chart-mask">加载中...</div>
|
<div class="chart-header">
|
<h3 class="chart-header-title">区域报名分布</h3>
|
<span class="chart-header-desc">按叶子地区统计</span>
|
</div>
|
<div class="chart-container" ref="regionChartRef"></div>
|
</div>
|
</div>
|
|
<div class="table-card">
|
<div class="table-header">
|
<h3 class="table-title">最近比赛</h3>
|
<el-button type="primary" @click="$router.push('/activity')">查看全部</el-button>
|
</div>
|
|
<el-table :data="recentActivities" class="recent-table">
|
<el-table-column prop="name" label="比赛名称" width="180" />
|
<el-table-column prop="playerCount" label="报名人数" width="120" />
|
<el-table-column prop="startTime" label="开始时间" width="180" />
|
<el-table-column prop="endTime" label="结束时间" width="180" />
|
<el-table-column prop="status" label="状态" width="100">
|
<template #default="{ row }">
|
<span :class="getStatusClass(row.status)">{{ row.status }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="操作">
|
<template #default="scope">
|
<a class="action-link" @click="viewActivity(scope.row)">查看</a>
|
</template>
|
</el-table-column>
|
</el-table>
|
</div>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
import { useRouter } from 'vue-router'
|
import { ElMessage } from 'element-plus'
|
import { Trophy, UserFilled, Clock, User } from '@element-plus/icons-vue'
|
import * as echarts from 'echarts'
|
import { getDashboardStats, getRegistrationTrend, getRegionRegistrationStats } from '@/api/dashboard'
|
import { getActivities } from '@/api/activity'
|
|
const router = useRouter()
|
|
interface RegistrationTrendPoint {
|
date: string
|
count: number
|
}
|
|
interface RegionRegistrationStat {
|
regionId: number | null
|
regionName: string
|
leafFlag?: boolean | null
|
count: number
|
}
|
|
const UNASSIGNED_REGION_LABEL = '未选择区域'
|
|
const registrationTrend = ref<RegistrationTrendPoint[]>([])
|
const regionStats = ref<RegionRegistrationStat[]>([])
|
const trendChartRef = ref<HTMLDivElement | null>(null)
|
const regionChartRef = ref<HTMLDivElement | null>(null)
|
const trendChartLoading = ref(false)
|
const regionChartLoading = ref(false)
|
|
let trendChartInstance: echarts.ECharts | null = null
|
let regionChartInstance: echarts.ECharts | null = null
|
|
// 统计数据
|
const stats = ref({
|
activeActivities: 0,
|
totalPlayers: 0,
|
pendingReviews: 0,
|
totalJudges: 0
|
})
|
|
// 最近活动数据
|
const recentActivities = ref<any[]>([])
|
|
// 加载统计数据
|
const loadStats = async () => {
|
try {
|
const data = await getDashboardStats()
|
stats.value = data
|
} catch (error) {
|
console.error('加载统计数据失败:', error)
|
ElMessage.error('加载统计数据失败')
|
}
|
}
|
|
// 加载最近活动
|
const loadRecentActivities = async () => {
|
try {
|
const data = await getActivities(0, 5)
|
const { content } = data || {}
|
recentActivities.value = (content || []).map(item => ({
|
id: item.id,
|
name: item.name,
|
playerCount: item.playerCount || 0,
|
startTime: item.matchTime,
|
endTime: item.matchTime,
|
status: item.stateName
|
}))
|
} catch (error) {
|
console.error('加载最近活动失败:', error)
|
ElMessage.error('加载最近活动失败')
|
}
|
}
|
|
const renderTrendChart = () => {
|
if (!trendChartRef.value) return
|
if (!trendChartInstance) {
|
trendChartInstance = echarts.init(trendChartRef.value)
|
}
|
|
const dates = registrationTrend.value.map(item => item.date)
|
const values = registrationTrend.value.map(item => Number(item.count || 0))
|
|
trendChartInstance.setOption({
|
grid: { left: 16, right: 16, top: 32, bottom: 24, containLabel: true },
|
tooltip: { trigger: 'axis' },
|
xAxis: {
|
type: 'category',
|
boundaryGap: false,
|
data: dates
|
},
|
yAxis: {
|
type: 'value',
|
minInterval: 1
|
},
|
series: [
|
{
|
name: '报名数量',
|
type: 'line',
|
smooth: true,
|
symbol: 'circle',
|
symbolSize: 8,
|
lineStyle: { width: 3, color: '#409EFF' },
|
areaStyle: { color: 'rgba(64, 158, 255, 0.2)' },
|
data: values
|
}
|
]
|
})
|
}
|
|
const renderRegionChart = () => {
|
if (!regionChartRef.value) return
|
if (!regionChartInstance) {
|
regionChartInstance = echarts.init(regionChartRef.value)
|
}
|
|
const pieData = regionStats.value.map(item => ({
|
name: item.regionName || UNASSIGNED_REGION_LABEL,
|
value: Number(item.count || 0)
|
}))
|
|
regionChartInstance.setOption({
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
legend: { orient: 'vertical', right: 10, top: 'center' },
|
series: [
|
{
|
name: '区域报名',
|
type: 'pie',
|
radius: ['40%', '70%'],
|
avoidLabelOverlap: false,
|
itemStyle: {
|
borderRadius: 8,
|
borderColor: '#fff',
|
borderWidth: 2
|
},
|
label: { formatter: '{b}\n{c}人' },
|
data: pieData.length > 0 ? pieData : [{ name: UNASSIGNED_REGION_LABEL, value: 0 }]
|
}
|
]
|
})
|
}
|
|
const handleResize = () => {
|
trendChartInstance?.resize()
|
regionChartInstance?.resize()
|
}
|
|
const loadRegistrationTrend = async () => {
|
trendChartLoading.value = true
|
try {
|
const data = await getRegistrationTrend(15)
|
registrationTrend.value = data || []
|
await nextTick()
|
renderTrendChart()
|
} catch (error) {
|
console.error('加载报名趋势失败:', error)
|
ElMessage.error('加载报名趋势失败')
|
} finally {
|
trendChartLoading.value = false
|
}
|
}
|
|
const loadRegionStats = async () => {
|
regionChartLoading.value = true
|
try {
|
const data = await getRegionRegistrationStats()
|
regionStats.value = (data || [])
|
.filter(item => item.leafFlag !== false)
|
.map(item => ({
|
regionId: item.regionId ?? null,
|
regionName: item.regionName || UNASSIGNED_REGION_LABEL,
|
leafFlag: item.leafFlag,
|
count: item.count ?? 0
|
}))
|
await nextTick()
|
renderRegionChart()
|
} catch (error) {
|
console.error('加载区域报名统计失败:', error)
|
ElMessage.error('加载区域报名统计失败')
|
} finally {
|
regionChartLoading.value = false
|
}
|
}
|
|
// 页面加载时获取数据
|
onMounted(() => {
|
loadStats()
|
loadRecentActivities()
|
loadRegistrationTrend()
|
loadRegionStats()
|
window.addEventListener('resize', handleResize)
|
})
|
|
onBeforeUnmount(() => {
|
window.removeEventListener('resize', handleResize)
|
if (trendChartInstance) {
|
trendChartInstance.dispose()
|
trendChartInstance = null
|
}
|
if (regionChartInstance) {
|
regionChartInstance.dispose()
|
regionChartInstance = null
|
}
|
})
|
|
const viewActivity = (activity: any) => {
|
router.push(`/activity/${activity.id}`)
|
}
|
|
// 获取状态样式类
|
const getStatusClass = (status: string) => {
|
const statusMap: Record<string, string> = {
|
已发布: 'status-published',
|
发布: 'status-published',
|
进行中: 'status-published',
|
未发布: 'status-unpublished',
|
报名中: 'status-unpublished',
|
待开始: 'status-unpublished',
|
关闭: 'status-closed',
|
已结束: 'status-closed'
|
}
|
return statusMap[status] || 'status-unpublished'
|
}
|
</script>
|
|
<style scoped>
|
/* 页面整体样式 */
|
.dashboard {
|
padding: 24px;
|
background-color: #ffffff;
|
min-height: 100vh;
|
}
|
|
/* 统计卡片区域 */
|
.stats-row {
|
margin-bottom: 20px;
|
}
|
|
/* 统计卡片样式 */
|
.stat-card {
|
background: #ffffff;
|
border-radius: 12px;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
border: none;
|
padding: 24px;
|
height: 120px;
|
position: relative;
|
overflow: hidden;
|
transition: all 0.3s ease;
|
}
|
|
.stat-card:hover {
|
transform: translateY(-2px);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
}
|
|
.icon-container {
|
width: 48px;
|
height: 48px;
|
border-radius: 50%;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
position: absolute;
|
top: 24px;
|
left: 24px;
|
}
|
|
.icon-container.blue {
|
background-color: #e0e7ff;
|
color: #6366f1;
|
}
|
|
.icon-container.green {
|
background-color: #d1fae5;
|
color: #10b981;
|
}
|
|
.icon-container.yellow {
|
background-color: #fef3c7;
|
color: #f59e0b;
|
}
|
|
.icon-container.red {
|
background-color: #fecaca;
|
color: #ef4444;
|
}
|
|
.stat-number {
|
font-size: 32px;
|
font-weight: 700;
|
color: #1f2937;
|
position: absolute;
|
top: 24px;
|
right: 24px;
|
}
|
|
.stat-title {
|
font-size: 14px;
|
font-weight: 500;
|
color: #6b7280;
|
position: absolute;
|
bottom: 24px;
|
left: 24px;
|
}
|
|
.chart-section {
|
display: grid;
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
gap: 20px;
|
margin-top: 24px;
|
}
|
|
.chart-card {
|
background: #ffffff;
|
border-radius: 12px;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
border: none;
|
padding: 20px;
|
min-height: 340px;
|
position: relative;
|
}
|
|
.chart-mask {
|
position: absolute;
|
inset: 0;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
background-color: rgba(255, 255, 255, 0.8);
|
z-index: 1;
|
font-size: 14px;
|
color: #6b7280;
|
}
|
|
.chart-header {
|
display: flex;
|
align-items: baseline;
|
justify-content: space-between;
|
margin-bottom: 12px;
|
}
|
|
.chart-header-title {
|
font-size: 18px;
|
font-weight: 600;
|
color: #1f2937;
|
margin: 0;
|
}
|
|
.chart-header-desc {
|
font-size: 13px;
|
color: #9ca3af;
|
}
|
|
.chart-container {
|
width: 100%;
|
height: 280px;
|
}
|
|
.table-card {
|
background: #ffffff;
|
border-radius: 12px;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
border: none;
|
padding: 24px;
|
margin-top: 20px;
|
}
|
|
.table-header {
|
margin-bottom: 20px;
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
}
|
|
.table-title {
|
font-size: 18px;
|
font-weight: 600;
|
color: #1f2937;
|
margin: 0;
|
}
|
|
.recent-table {
|
width: 100%;
|
}
|
|
:deep(.el-table__header) {
|
background-color: #f9fafb;
|
}
|
|
:deep(.el-table__header th) {
|
background-color: #f9fafb !important;
|
color: #374151;
|
font-size: 14px;
|
font-weight: 500;
|
height: 48px;
|
border-bottom: 1px solid #e5e7eb;
|
}
|
|
:deep(.el-table__row) {
|
height: 56px;
|
}
|
|
:deep(.el-table__row:nth-child(even)) {
|
background-color: #f9fafb;
|
}
|
|
:deep(.el-table__row:nth-child(odd)) {
|
background-color: #ffffff;
|
}
|
|
:deep(.el-table td) {
|
color: #1f2937;
|
font-size: 14px;
|
font-weight: 400;
|
border-bottom: 1px solid #f3f4f6;
|
}
|
|
.status-published {
|
color: #67c23a;
|
background-color: #f0f9ff;
|
padding: 4px 8px;
|
border-radius: 4px;
|
font-size: 12px;
|
}
|
|
.status-unpublished {
|
color: #e6a23c;
|
background-color: #fdf6ec;
|
padding: 4px 8px;
|
border-radius: 4px;
|
font-size: 12px;
|
}
|
|
.status-closed {
|
color: #f56c6c;
|
background-color: #fef0f0;
|
padding: 4px 8px;
|
border-radius: 4px;
|
font-size: 12px;
|
}
|
|
.action-link {
|
color: #409eff;
|
cursor: pointer;
|
font-size: 14px;
|
margin: 0 8px;
|
text-decoration: none;
|
}
|
|
.action-link:hover {
|
color: #66b1ff;
|
text-decoration: underline;
|
}
|
|
.action-link:first-child {
|
margin-left: 0;
|
}
|
|
@media (max-width: 768px) {
|
.dashboard {
|
padding: 16px;
|
}
|
|
.stat-card {
|
margin-bottom: 16px;
|
}
|
}
|
</style>
|