新增数据统计页面

This commit was merged in pull request #47.
This commit is contained in:
2026-07-10 13:56:16 +08:00
parent 8ee326b43a
commit 593f6f13e7
11 changed files with 637 additions and 7 deletions
+100
View File
@@ -0,0 +1,100 @@
import request from '@/utils/request'
// ========== 接口类型定义 ==========
export interface MemberStatistics {
statDate: string
newMembers: number
activeMembers: number
totalMembers: number
signInMembers: number
bookingMembers: number
cancelBookingMembers: number
}
export interface BookingStatistics {
statDate: string
newBookings: number
cancelBookings: number
attendBookings: number
absentBookings: number
attendanceRate: number
cancelRate: number
bookingMembers: number
cancelMembers: number
}
export interface SignInStatistics {
statDate: string
totalSignIns: number
successSignIns: number
failedSignIns: number
successRate: number
signInMembers: number
qrCodeSignIns: number
manualSignIns: number
faceSignIns: number
}
export interface StatisticsSummary {
statDate: string
memberStatistics: MemberStatistics
bookingStatistics: BookingStatistics
signInStatistics: SignInStatistics
generatedAt: string
}
export interface HistoryRecord {
statType: string
periodType: string
statDate: string
count: number
relatedId: number
extraData: string
}
export interface StatisticsQuery {
statType?: string
periodType?: string
startTime?: string
endTime?: string
page?: number
size?: number
}
// ========== API ==========
export const statisticsApi = {
/** 获取综合统计摘要 */
getSummary(params?: StatisticsQuery) {
return request.get<any, StatisticsSummary>('/datacount/summary', { params })
},
/** 获取会员统计 */
getMember(params?: StatisticsQuery) {
return request.get<any, MemberStatistics>('/datacount/member', { params })
},
/** 获取预约统计 */
getBooking(params?: StatisticsQuery) {
return request.get<any, BookingStatistics>('/datacount/booking', { params })
},
/** 获取签到统计 */
getSignIn(params?: StatisticsQuery) {
return request.get<any, SignInStatistics>('/datacount/signin', { params })
},
/** 查询历史统计数据 */
getHistory(params?: StatisticsQuery) {
return request.get<any, HistoryRecord[]>('/datacount/history', { params })
},
/** 导出统计报表 (Excel) */
exportStatistics(params?: StatisticsQuery) {
return request.get('/datacount/export', {
params,
responseType: 'blob',
})
},
}
+6
View File
@@ -129,6 +129,12 @@ const routes: RouteRecordRaw[] = [
name: 'MemberCardManagement',
component: () => import('@/views/member/MemberCardManagement.vue'),
meta: { title: '会员卡管理' }
},
{
path: 'statistics',
name: 'StatisticsDashboard',
component: () => import('@/views/statistics/StatisticsDashboard.vue'),
meta: { title: '数据统计看板' }
}
]
}
+2
View File
@@ -44,6 +44,7 @@ function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
'gymCourse/recommend/index': '/courses/recommend',
'member/member/index': '/members',
'member/card/index': '/member-cards',
'statistics/dashboard/index': '/statistics',
}
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
@@ -105,6 +106,7 @@ function getMenuIcon(menuName: string): string {
'团课推荐': 'Star',
'会员管理': 'Avatar',
'会员卡管理': 'CreditCard',
'数据统计': 'DataAnalysis',
}
return iconMap[menuName] || 'Document'
}
@@ -0,0 +1,402 @@
<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--info">
<div class="stat-card__label">签到总数</div>
<div class="stat-card__value">{{ summary?.signInStatistics?.totalSignIns ?? '-' }}</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>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } 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
}
}
onMounted(() => {
fetchSummary()
})
</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; }
/* ---- 明细分区 ---- */
.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;
}
.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>