Files
gym-manage/gym-manage-web/src/views/statistics/StatisticsDashboard.vue
T

477 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="statistics-dashboard">
<!-- 顶部操作栏 -->
<div class="toolbar">
<div class="toolbar-left">
<span class="toolbar-label">统计区间</span>
<el-radio-group v-model="selectedRange" size="default" @change="onRangeChange">
<el-radio-button value="today">今日</el-radio-button>
<el-radio-button value="week">本周</el-radio-button>
<el-radio-button value="month">本月</el-radio-button>
<el-radio-button value="last30">近30天</el-radio-button>
<el-radio-button value="last90">近90天</el-radio-button>
<el-radio-button value="year">今年</el-radio-button>
</el-radio-group>
</div>
<div class="toolbar-right">
<el-button
type="primary"
:icon="Download"
:loading="exporting"
@click="handleExport"
>
导出报表
</el-button>
<span class="generated-hint" v-if="generatedAt">
数据更新时间{{ generatedAt }}
</span>
</div>
</div>
<div v-loading="loading" class="dashboard-content">
<!-- ========== 总览统计卡片 ========== -->
<el-row :gutter="16" class="stat-cards-row">
<el-col :xs="12" :sm="6">
<el-card shadow="hover" class="stat-card stat-card--primary">
<div class="stat-card__label">会员总数</div>
<div class="stat-card__value">{{ summary?.memberStatistics?.totalMembers ?? '-' }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="hover" class="stat-card stat-card--success">
<div class="stat-card__label">活跃会员</div>
<div class="stat-card__value">{{ summary?.memberStatistics?.activeMembers ?? '-' }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="hover" class="stat-card stat-card--warning">
<div class="stat-card__label">预约总数</div>
<div class="stat-card__value">{{ summary?.bookingStatistics?.newBookings ?? '-' }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="hover" class="stat-card stat-card--danger">
<div class="stat-card__label">教练违规</div>
<div class="stat-card__value">{{ summary?.coachStatistics?.totalViolations ?? '-' }}</div>
</el-card>
</el-col>
</el-row>
<!-- ========== 会员统计明细 ========== -->
<el-card class="section-card">
<template #header>
<span class="section-title">会员统计</span>
</template>
<el-row :gutter="20">
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">新增会员</span>
<span class="metric-value">{{ summary?.memberStatistics?.newMembers ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">签到会员</span>
<span class="metric-value">{{ summary?.memberStatistics?.signInMembers ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">预约会员</span>
<span class="metric-value">{{ summary?.memberStatistics?.bookingMembers ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">取消预约</span>
<span class="metric-value">{{ summary?.memberStatistics?.cancelBookingMembers ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">活跃会员</span>
<span class="metric-value">{{ summary?.memberStatistics?.activeMembers ?? '-' }}</span>
</div>
</el-col>
</el-row>
</el-card>
<!-- ========== 预约统计明细 ========== -->
<el-card class="section-card">
<template #header>
<span class="section-title">预约统计</span>
</template>
<el-row :gutter="20">
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">总预约</span>
<span class="metric-value">{{ summary?.bookingStatistics?.newBookings ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">已签到</span>
<span class="metric-value">{{ summary?.bookingStatistics?.attendBookings ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">已取消</span>
<span class="metric-value">{{ summary?.bookingStatistics?.cancelBookings ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">缺席</span>
<span class="metric-value">{{ summary?.bookingStatistics?.absentBookings ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="16" :sm="8">
<div class="rate-group">
<div class="rate-item">
<span class="rate-label">出勤率</span>
<el-progress
:percentage="Number(summary?.bookingStatistics?.attendanceRate ?? 0)"
:stroke-width="12"
:color="rateColor(summary?.bookingStatistics?.attendanceRate)"
/>
</div>
<div class="rate-item">
<span class="rate-label">取消率</span>
<el-progress
:percentage="Number(summary?.bookingStatistics?.cancelRate ?? 0)"
:stroke-width="12"
:color="rateColor(summary?.bookingStatistics?.cancelRate, true)"
/>
</div>
</div>
</el-col>
</el-row>
</el-card>
<!-- ========== 签到统计明细 ========== -->
<el-card class="section-card">
<template #header>
<span class="section-title">签到统计</span>
</template>
<el-row :gutter="20">
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">总签到</span>
<span class="metric-value">{{ summary?.signInStatistics?.totalSignIns ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">成功</span>
<span class="metric-value">{{ summary?.signInStatistics?.successSignIns ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">失败</span>
<span class="metric-value">{{ summary?.signInStatistics?.failedSignIns ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="24" :sm="12">
<div class="rate-group">
<div class="rate-item">
<span class="rate-label">成功率</span>
<el-progress
:percentage="Number(summary?.signInStatistics?.successRate ?? 0)"
:stroke-width="12"
color="#67c23a"
/>
</div>
<div class="signin-type-row">
<el-tag type="primary" size="small">扫码: {{ summary?.signInStatistics?.qrCodeSignIns ?? 0 }}</el-tag>
<el-tag type="warning" size="small">手动: {{ summary?.signInStatistics?.manualSignIns ?? 0 }}</el-tag>
<el-tag type="success" size="small">人脸: {{ summary?.signInStatistics?.faceSignIns ?? 0 }}</el-tag>
</div>
</div>
</el-col>
</el-row>
</el-card>
<!-- ========== 教练统计明细 ========== -->
<el-card class="section-card">
<template #header>
<span class="section-title">教练统计</span>
</template>
<el-row :gutter="20">
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">教练总数</span>
<span class="metric-value">{{ summary?.coachStatistics?.totalCoaches ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">违规教练</span>
<span class="metric-value" :class="{ 'metric-value--danger': (summary?.coachStatistics?.violatedCoaches ?? 0) > 0 }">
{{ summary?.coachStatistics?.violatedCoaches ?? '-' }}
</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">开课总数</span>
<span class="metric-value">{{ summary?.coachStatistics?.totalCourses ?? '-' }}</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">迟到</span>
<span class="metric-value" :class="{ 'metric-value--warn': (summary?.coachStatistics?.lateCount ?? 0) > 0 }">
{{ summary?.coachStatistics?.lateCount ?? '-' }}
</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">缺席</span>
<span class="metric-value" :class="{ 'metric-value--danger': (summary?.coachStatistics?.absentCount ?? 0) > 0 }">
{{ summary?.coachStatistics?.absentCount ?? '-' }}
</span>
</div>
</el-col>
<el-col :xs="8" :sm="4">
<div class="metric-item">
<span class="metric-label">未手动结课</span>
<span class="metric-value" :class="{ 'metric-value--warn': (summary?.coachStatistics?.notManualEndCount ?? 0) > 0 }">
{{ summary?.coachStatistics?.notManualEndCount ?? '-' }}
</span>
</div>
</el-col>
</el-row>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { statisticsApi, type StatisticsSummary } from '@/api/statistics.api'
// ---- 时间区间映射 ----
const RANGE_MAP: Record<string, { periodType: string }> = {
today: { periodType: 'DAY' },
week: { periodType: 'WEEK' },
month: { periodType: 'MONTH' },
last30: { periodType: 'LAST_30_DAYS' },
last90: { periodType: 'LAST_90_DAYS' },
year: { periodType: 'YEAR' },
}
const loading = ref(false)
const exporting = ref(false)
const summary = ref<StatisticsSummary>()
const generatedAt = ref('')
const selectedRange = ref('today')
const rateColor = (rate: number | undefined, inverse = false) => {
const val = Number(rate ?? 0)
if (inverse) {
if (val <= 20) return '#67c23a'
if (val <= 50) return '#e6a23c'
return '#f56c6c'
}
if (val >= 80) return '#67c23a'
if (val >= 50) return '#e6a23c'
return '#f56c6c'
}
const getQueryParams = () => {
const config = RANGE_MAP[selectedRange.value]
if (!config) return {}
return { periodType: config.periodType }
}
const fetchSummary = async () => {
loading.value = true
try {
const data = await statisticsApi.getSummary(getQueryParams())
summary.value = data
if (data?.generatedAt) {
generatedAt.value = new Date(data.generatedAt).toLocaleString()
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || e?.message || '获取统计数据失败')
} finally {
loading.value = false
}
}
const onRangeChange = () => {
fetchSummary()
}
const handleExport = async () => {
exporting.value = true
try {
const blob: any = await statisticsApi.exportStatistics(getQueryParams())
const url = URL.createObjectURL(blob instanceof Blob ? blob : new Blob([blob]))
const link = document.createElement('a')
link.href = url
const label = selectedRange.value === 'today' ? '今日' :
selectedRange.value === 'week' ? '本周' :
selectedRange.value === 'month' ? '本月' :
selectedRange.value === 'last30' ? '近30天' :
selectedRange.value === 'last90' ? '近90天' : '今年'
link.download = `statistics_${label}_${new Date().toISOString().slice(0, 10)}.xlsx`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || e?.message || '导出失败')
} finally {
exporting.value = false
}
}
let statsTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
fetchSummary()
statsTimer = setInterval(() => {
fetchSummary()
}, 60_000)
})
onUnmounted(() => {
if (statsTimer !== null) {
clearInterval(statsTimer)
statsTimer = null
}
})
</script>
<style scoped>
.statistics-dashboard {
padding: 16px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-label {
color: #606266;
font-size: 14px;
white-space: nowrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.generated-hint {
color: #909399;
font-size: 13px;
white-space: nowrap;
}
.dashboard-content {
min-height: 400px;
}
/* ---- 统计卡片 ---- */
.stat-cards-row {
margin-bottom: 16px;
}
.stat-card {
text-align: center;
margin-bottom: 12px;
}
.stat-card__label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.stat-card__value {
font-size: 28px;
font-weight: 700;
}
.stat-card--primary .stat-card__value { color: #409eff; }
.stat-card--success .stat-card__value { color: #67c23a; }
.stat-card--warning .stat-card__value { color: #e6a23c; }
.stat-card--info .stat-card__value { color: #909399; }
.stat-card--danger .stat-card__value { color: #f56c6c; }
/* ---- 明细分区 ---- */
.section-card {
margin-bottom: 16px;
}
.section-title {
font-size: 15px;
font-weight: 600;
}
.metric-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0;
}
.metric-label {
font-size: 13px;
color: #909399;
margin-bottom: 6px;
}
.metric-value {
font-size: 22px;
font-weight: 600;
color: #303133;
}
.metric-value--warn {
color: #e6a23c;
}
.metric-value--danger {
color: #f56c6c;
}
.rate-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.rate-item .rate-label {
font-size: 13px;
color: #606266;
display: block;
margin-bottom: 4px;
}
.signin-type-row {
display: flex;
gap: 8px;
margin-top: 4px;
flex-wrap: wrap;
}
</style>