完成部分功能的后台管理 #47
@@ -67,7 +67,12 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.2.5</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Fix commons-compress version for POI compatibility -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Gym Modules -->
|
||||
|
||||
+6
@@ -59,6 +59,12 @@ public class DataStatistics {
|
||||
public static final String WEEK = "WEEK";
|
||||
/** 月统计 */
|
||||
public static final String MONTH = "MONTH";
|
||||
/** 近30天 */
|
||||
public static final String LAST_30_DAYS = "LAST_30_DAYS";
|
||||
/** 近90天 */
|
||||
public static final String LAST_90_DAYS = "LAST_90_DAYS";
|
||||
/** 今年 */
|
||||
public static final String YEAR = "YEAR";
|
||||
|
||||
private PeriodType() {}
|
||||
}
|
||||
|
||||
+19
-6
@@ -458,13 +458,16 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
|
||||
String periodType = query.getPeriodType();
|
||||
|
||||
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
|
||||
// 周统计:本周一
|
||||
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
|
||||
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
||||
// 月统计:本月第一天
|
||||
return today.withDayOfMonth(1).atStartOfDay();
|
||||
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
|
||||
return today.minusDays(29).atStartOfDay();
|
||||
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
|
||||
return today.minusDays(89).atStartOfDay();
|
||||
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
|
||||
return today.withDayOfYear(1).atStartOfDay();
|
||||
} else {
|
||||
// 日统计:当天零点
|
||||
return today.atStartOfDay();
|
||||
}
|
||||
}
|
||||
@@ -478,13 +481,14 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
|
||||
String periodType = query.getPeriodType();
|
||||
|
||||
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
|
||||
// 周统计:本周日 23:59:59
|
||||
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59);
|
||||
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
||||
// 月统计:本月最后一天 23:59:59
|
||||
return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
|
||||
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)
|
||||
|| DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)
|
||||
|| DataStatistics.PeriodType.YEAR.equals(periodType)) {
|
||||
return LocalDateTime.now();
|
||||
} else {
|
||||
// 日统计:当前时间
|
||||
return LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -503,6 +507,15 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
|
||||
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
||||
startTime = date.withDayOfMonth(1).atStartOfDay();
|
||||
endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
|
||||
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
|
||||
startTime = date.minusDays(29).atStartOfDay();
|
||||
endTime = date.atTime(23, 59, 59);
|
||||
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
|
||||
startTime = date.minusDays(89).atStartOfDay();
|
||||
endTime = date.atTime(23, 59, 59);
|
||||
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
|
||||
startTime = date.withDayOfYear(1).atStartOfDay();
|
||||
endTime = date.atTime(23, 59, 59);
|
||||
} else {
|
||||
startTime = date.atStartOfDay();
|
||||
endTime = date.plusDays(1).atStartOfDay();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- ============================================
|
||||
-- V31: 新增数据统计菜单
|
||||
-- 描述: 为数据统计看板功能创建菜单项
|
||||
-- ============================================
|
||||
|
||||
-- 数据统计一级菜单(目录)
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||
(504, '数据统计', 0, 7, 'M', NULL, NULL, 1, NOW(), NOW());
|
||||
|
||||
-- 数据统计子菜单(页面)
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||
(505, '数据统计看板', 504, 1, 'C', 'business:statistics:view', 'statistics/dashboard/index', 1, NOW(), NOW());
|
||||
|
||||
-- 数据统计按钮权限
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||
(5051, '数据统计导出', 505, 1, 'F', 'business:statistics:export', NULL, 1, NOW(), NOW());
|
||||
|
||||
-- 重置菜单序列
|
||||
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
-- ============================================================
|
||||
-- V32: 新增测试数据(用于数据统计看板展示)
|
||||
-- 描述: 基于当天日期,创建跨多个时间区间的会员、签到、预约数据
|
||||
-- 覆盖时间区间: 今日、本周、本月、近30天、近90天、今年
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 新增会员(100人,ID 2001-2100)
|
||||
-- 按时间分布:今日10人 / 本周20人 / 本月30人 / 近30天60人 / 近90天85人 / 今年100人
|
||||
-- 创建日期从 180 天前均匀分布到今天
|
||||
-- ============================================================
|
||||
INSERT INTO member_user (id, member_no, nickname, phone, gender, subscribed, is_deleted, created_at, updated_at)
|
||||
SELECT
|
||||
2000 + g,
|
||||
'M' || TO_CHAR(CURRENT_DATE - (g % 180), 'YYYYMMDD') || LPAD(g::text, 3, '0'),
|
||||
'测试会员' || g,
|
||||
'138' || LPAD(g::text, 8, '0'),
|
||||
(g % 3)::int,
|
||||
false,
|
||||
false,
|
||||
(CURRENT_DATE - (g % 180) + TIME '10:00:00')::timestamp,
|
||||
(CURRENT_DATE - (g % 180) + TIME '10:00:00')::timestamp
|
||||
FROM generate_series(1, 100) AS g
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 签到记录(80条,使用已有会员 ID 1001-1010)
|
||||
-- 按时间分布:最近90天,每天约1条
|
||||
-- 签到类型:QR_CODE/MANUAL/FACE 均匀分布
|
||||
-- 签到状态:SUCCESS(90%) / FAILED(10%)
|
||||
-- ============================================================
|
||||
INSERT INTO sign_in_record (member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete, created_at, updated_at)
|
||||
SELECT
|
||||
1001 + (g % 10),
|
||||
(CURRENT_DATE - (g % 90) + TIME '08:00:00' + (INTERVAL '1 hour' * (g % 12)))::timestamp,
|
||||
CASE (g % 3) WHEN 0 THEN 'QR_CODE' WHEN 1 THEN 'MANUAL' ELSE 'FACE' END,
|
||||
CASE WHEN (g % 10) = 0 THEN 'FAILED' ELSE 'SUCCESS' END,
|
||||
CASE (g % 2) WHEN 0 THEN 'MINI_PROGRAM' ELSE 'PC_BACKEND' END,
|
||||
false,
|
||||
(CURRENT_DATE - (g % 90) + TIME '08:00:00')::timestamp,
|
||||
(CURRENT_DATE - (g % 90) + TIME '08:00:00')::timestamp
|
||||
FROM generate_series(1, 80) AS g;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 团课预约(40条,使用已有会员 ID 1001-1010)
|
||||
-- 按时间分布:最近90天
|
||||
-- 状态分布:出勤(2) 60% / 取消(1) 20% / 缺席(3) 20%
|
||||
-- ============================================================
|
||||
INSERT INTO group_course_booking (id, course_id, member_id, member_card_id, booking_time, status, created_at, updated_at)
|
||||
SELECT
|
||||
5100 + g,
|
||||
CASE (g % 3) WHEN 0 THEN 3001 WHEN 1 THEN 3002 ELSE 3003 END,
|
||||
1001 + (g % 10),
|
||||
(SELECT id FROM member_card_record ORDER BY id LIMIT 1),
|
||||
(CURRENT_DATE - (g % 90) + TIME '10:00:00')::timestamp,
|
||||
CASE WHEN (g % 5) = 0 THEN '1' WHEN (g % 5) = 1 THEN '3' ELSE '2' END,
|
||||
(CURRENT_DATE - (g % 90) + TIME '10:00:00')::timestamp,
|
||||
(CURRENT_DATE - (g % 90) + TIME '10:00:00')::timestamp
|
||||
FROM generate_series(1, 40) AS g;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 重置序列(防止后续自动插入 ID 冲突)
|
||||
-- ============================================================
|
||||
SELECT setval('member_user_id_seq', (SELECT COALESCE(MAX(id), 1) FROM member_user));
|
||||
SELECT setval('group_course_booking_id_seq', (SELECT COALESCE(MAX(id), 1) FROM group_course_booking));
|
||||
SELECT setval('sign_in_record_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sign_in_record));
|
||||
@@ -106,6 +106,12 @@
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Fix commons-compress version for POI compatibility -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -213,6 +213,11 @@
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>${poi.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
<version>1.21</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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: '数据统计看板' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user