From 72bc97968f1d7185c62affd893e97b8869471556 Mon Sep 17 00:00:00 2001
From: “mitao” <“2763622819@qq.com”>
Date: 星期四, 26 三月 2026 22:02:56 +0800
Subject: [PATCH] feat:统计-用户数据
---
backend/sql/init.sql | 18
backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsOverviewRow.java | 19
backend/sql/update_user_statistics_schema.sql | 23
tcm-training-admin/src/pages/statistics/redirect.tsx | 4
backend/src/main/resources/mapper/UserStatisticsMapper.xml | 174 ++++++
backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsActiveTrendRow.java | 16
tcm-training-admin/src/pages/statistics/user-data/index.tsx | 359 +++++++++++++
backend/src/test/java/com/tcm/training/admin/statistics/service/UserStatisticsAppServiceTest.java | 154 +++++
backend/src/main/java/com/tcm/training/admin/statistics/mapper/AppUserEntryLogMapper.java | 9
backend/src/main/java/com/tcm/training/admin/statistics/controller/UserEntryLogController.java | 27 +
backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsRegisterTrendRow.java | 17
backend/src/main/java/com/tcm/training/admin/statistics/service/AppUserEntryLogAppService.java | 37 +
backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsOverviewVO.java | 20
backend/src/main/java/com/tcm/training/admin/statistics/entity/AppUserEntryLog.java | 44 +
tcm-training-admin/src/pages/statistics/user-data/service.ts | 8
backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsTrendItemVO.java | 16
backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java | 2
backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsCompositionRow.java | 15
tcm-training-admin/src/access.js | 3
tcm-training-admin/src/pages/statistics/user-data/style.less | 215 +++++++
backend/src/main/java/com/tcm/training/admin/statistics/controller/UserStatisticsController.java | 33 +
backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsVO.java | 17
backend/sql/mock_user_statistics_recent_week.sql | 98 +++
tcm-training-admin/config/routes.ts | 6
backend/src/main/java/com/tcm/training/admin/statistics/service/UserStatisticsAppService.java | 181 ++++++
backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsMemberCompositionVO.java | 15
backend/src/main/java/com/tcm/training/admin/statistics/mapper/UserStatisticsMapper.java | 31 +
27 files changed, 1,560 insertions(+), 1 deletions(-)
diff --git a/backend/sql/init.sql b/backend/sql/init.sql
index 39d0f09..dafc479 100644
--- a/backend/sql/init.sql
+++ b/backend/sql/init.sql
@@ -134,6 +134,7 @@
(1310, 1300, '积分设置', 'system_points_settings', 'C', '/system/settings/points', 'System/PointsSettings', NULL, 10, 1, 1, 'system:points-settings:view', '积分设置菜单', 0),
(1311, 1310, '编辑积分设置', 'system_points_settings_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:points-settings:edit', '积分设置编辑按钮', 0),
(5000, 0, '统计', 'statistics_root', 'M', '/statistics', NULL, 'barChart', 150, 1, 1, NULL, '统计模块', 0),
+(5010, 5000, '用户数据', 'statistics_user_data', 'C', '/statistics/user-data', 'Statistics/UserData', NULL, 10, 1, 1, 'statistics:user-data:view', '用户数据菜单', 0),
(5030, 5000, '营收数据', 'statistics_revenue', 'C', '/statistics/revenue', 'Statistics/Revenue', NULL, 30, 1, 1, 'statistics:revenue:view', '营收数据菜单', 0),
(5031, 5030, '导出营收报表', 'statistics_revenue_export', 'B', NULL, NULL, NULL, 1, 1, 1, 'statistics:revenue:export', '营收数据导出按钮', 0),
(2000, 0, '用户与会员', 'member_root', 'M', '/member', NULL, 'team', 200, 1, 1, NULL, '用户与会员模块', 0),
@@ -468,6 +469,23 @@
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付流水表';
-- ----------------------------
+-- APP用户进入系统记录
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `tcm_app_user_entry_log` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `app_user_id` BIGINT UNSIGNED NOT NULL COMMENT 'APP用户ID',
+ `entry_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '进入系统时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0,
+ `created_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '创建人',
+ `updated_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '更新人',
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_app_user_entry_log_user_time` (`app_user_id`, `entry_time`),
+ KEY `idx_app_user_entry_log_entry_time` (`entry_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='APP用户进入系统记录表';
+
+-- ----------------------------
-- 会员介绍配置
-- ----------------------------
CREATE TABLE IF NOT EXISTS `tcm_member_intro_config` (
diff --git a/backend/sql/mock_user_statistics_recent_week.sql b/backend/sql/mock_user_statistics_recent_week.sql
new file mode 100644
index 0000000..b5a4d77
--- /dev/null
+++ b/backend/sql/mock_user_statistics_recent_week.sql
@@ -0,0 +1,98 @@
+-- 最近一周用户统计 mock 数据(2026-03-20 ~ 2026-03-26)
+-- 用途:给「统计-用户数据」页面提供最近 7 天的注册、活跃、会员、购课、观看趋势数据
+-- 说明:本脚本会补充 APP 用户、进入系统日志、会员订单、课程订单、直播场次和观看记录
+
+INSERT IGNORE INTO `tcm_app_user`
+(`id`, `nickname`, `phone`, `password`, `avatar`, `wechat_openid`, `wechat_nickname`, `wechat_avatar`, `wechat_bound_at`,
+ `type`, `gender`, `age`, `intro`, `status`, `clinic_id`, `member_expire_at`, `deleted`, `created_at`, `updated_at`)
+VALUES
+(301, '李安然', '13800009001', NULL, 'https://example.com/avatar/app-user-301.png', 'wx_openid_301', '安然', 'https://example.com/avatar/wechat-301.png', '2026-03-20 09:10:00',
+ 0, 0, 28, '最近开始关注养生课程。', 1, NULL, NULL, 0, '2026-03-20 09:00:00', '2026-03-20 09:00:00'),
+(302, '陈思远', '13800009002', NULL, 'https://example.com/avatar/app-user-302.png', 'wx_openid_302', '思远', 'https://example.com/avatar/wechat-302.png', '2026-03-21 10:25:00',
+ 1, 1, 36, '诊所负责人,关注课程内容。', 1, 1, NULL, 0, '2026-03-21 10:00:00', '2026-03-21 10:00:00'),
+(303, '周雨桐', '13800009003', NULL, 'https://example.com/avatar/app-user-303.png', 'wx_openid_303', '雨桐', 'https://example.com/avatar/wechat-303.png', '2026-03-22 14:10:00',
+ 0, 0, 31, '已经开通过月卡会员。', 1, NULL, '2026-05-15 23:59:59', 0, '2026-03-22 13:30:00', '2026-03-22 13:30:00'),
+(304, '王知夏', '13800009004', NULL, 'https://example.com/avatar/app-user-304.png', 'wx_openid_304', '知夏', 'https://example.com/avatar/wechat-304.png', '2026-03-23 11:25:00',
+ 0, 1, 27, '偏好购买课程小节。', 1, NULL, '2026-06-30 23:59:59', 0, '2026-03-23 11:00:00', '2026-03-23 11:00:00'),
+(305, '赵景行', '13800009005', NULL, 'https://example.com/avatar/app-user-305.png', 'wx_openid_305', '景行', 'https://example.com/avatar/wechat-305.png', '2026-03-24 16:18:00',
+ 1, 1, 40, '新注册的诊所用户。', 1, 1, NULL, 0, '2026-03-24 16:00:00', '2026-03-24 16:00:00'),
+(306, '许清禾', '13800009006', NULL, 'https://example.com/avatar/app-user-306.png', 'wx_openid_306', '清禾', 'https://example.com/avatar/wechat-306.png', '2026-03-25 09:22:00',
+ 0, 0, 33, '季卡会员用户。', 1, NULL, '2026-07-31 23:59:59', 0, '2026-03-25 09:00:00', '2026-03-25 09:00:00'),
+(307, '孙书意', '13800009007', NULL, 'https://example.com/avatar/app-user-307.png', 'wx_openid_307', '书意', 'https://example.com/avatar/wechat-307.png', '2026-03-26 20:08:00',
+ 0, 0, 25, '刚注册,主要浏览直播回放。', 1, NULL, NULL, 0, '2026-03-26 19:30:00', '2026-03-26 19:30:00');
+
+INSERT IGNORE INTO `tcm_live_session`
+(`id`, `live_course_id`, `start_time`, `video_url`, `base_reserve_count`, `base_watch_count`, `real_reserve_count`, `real_watch_count`,
+ `reserve_convert_rate`, `completion_rate`, `effective_watch_rate`, `quiz_correct_count`, `points_issued`, `live_status`, `actual_start_time`, `actual_end_time`, `deleted`)
+VALUES
+(90001, 1, '2026-03-20 19:30:00', 'https://example.com/live/session-90001.mp4', 20, 30, 0, 0, 0.00, 0.00, 0.00, 0, 0, 2, '2026-03-20 19:32:00', '2026-03-20 20:25:00', 0),
+(90002, 1, '2026-03-21 19:30:00', 'https://example.com/live/session-90002.mp4', 18, 28, 0, 0, 0.00, 0.00, 0.00, 0, 0, 2, '2026-03-21 19:31:00', '2026-03-21 20:22:00', 0),
+(90003, 2, '2026-03-22 19:30:00', 'https://example.com/live/session-90003.mp4', 16, 24, 0, 0, 0.00, 0.00, 0.00, 0, 0, 2, '2026-03-22 19:35:00', '2026-03-22 20:28:00', 0),
+(90004, 2, '2026-03-23 19:30:00', 'https://example.com/live/session-90004.mp4', 14, 20, 0, 0, 0.00, 0.00, 0.00, 0, 0, 2, '2026-03-23 19:33:00', '2026-03-23 20:18:00', 0),
+(90005, 1, '2026-03-24 19:30:00', 'https://example.com/live/session-90005.mp4', 15, 21, 0, 0, 0.00, 0.00, 0.00, 0, 0, 2, '2026-03-24 19:30:00', '2026-03-24 20:20:00', 0),
+(90006, 2, '2026-03-25 19:30:00', 'https://example.com/live/session-90006.mp4', 13, 19, 0, 0, 0.00, 0.00, 0.00, 0, 0, 2, '2026-03-25 19:34:00', '2026-03-25 20:10:00', 0),
+(90007, 1, '2026-03-26 19:30:00', 'https://example.com/live/session-90007.mp4', 17, 26, 0, 0, 0.00, 0.00, 0.00, 0, 0, 2, '2026-03-26 19:31:00', '2026-03-26 20:26:00', 0);
+
+INSERT IGNORE INTO `tcm_app_user_entry_log`
+(`app_user_id`, `entry_time`, `deleted`)
+VALUES
+(301, '2026-03-20 09:05:00', 0),
+(301, '2026-03-20 21:10:00', 0),
+(302, '2026-03-21 10:20:00', 0),
+(303, '2026-03-22 09:00:00', 0),
+(303, '2026-03-22 20:10:00', 0),
+(304, '2026-03-23 11:20:00', 0),
+(304, '2026-03-23 21:30:00', 0),
+(305, '2026-03-24 16:12:00', 0),
+(303, '2026-03-24 20:08:00', 0),
+(306, '2026-03-25 09:18:00', 0),
+(304, '2026-03-25 19:45:00', 0),
+(307, '2026-03-26 19:35:00', 0),
+(306, '2026-03-26 20:40:00', 0),
+(303, '2026-03-26 21:15:00', 0);
+
+INSERT IGNORE INTO `tcm_member_purchase_order`
+(`order_no`, `app_user_id`, `user_nickname`, `user_phone`, `member_type`, `member_days`, `order_amount`,
+ `payment_method`, `refund_amount`, `refund_remark`, `status`, `paid_at`, `refunded_at`, `cancelled_at`, `deleted`)
+VALUES
+('MPO202603220101', 303, '周雨桐', '13800009003', 'MONTH_CARD', 30, 69.00,
+ 'WECHAT', NULL, NULL, 'PAID', '2026-03-22 10:15:00', NULL, NULL, 0),
+('MPO202603250101', 306, '许清禾', '13800009006', 'QUARTER_CARD', 90, 199.00,
+ 'ALIPAY', NULL, NULL, 'PAID', '2026-03-25 10:40:00', NULL, NULL, 0),
+('MPO202603260101', 304, '王知夏', '13800009004', 'MONTH_CARD', 30, 69.00,
+ 'WECHAT', NULL, NULL, 'PAID', '2026-03-26 11:05:00', NULL, NULL, 0);
+
+INSERT IGNORE INTO `tcm_course_purchase_order`
+(`order_no`, `app_user_id`, `user_nickname`, `user_phone`, `course_type`, `purchase_target_type`, `course_id`,
+ `course_video_id`, `product_name_snapshot`, `course_title_snapshot`, `chapter_name_snapshot`, `cover_img_snapshot`,
+ `order_amount`, `payment_method`, `refund_amount`, `refund_remark`, `status`, `paid_at`, `refunded_at`, `cancelled_at`, `deleted`)
+VALUES
+('CPO202603210101', 302, '陈思远', '13800009002', 'HEALTH_COURSE', 'COURSE', 1,
+ NULL, '中医针灸资格证', '中医针灸资格证', NULL, 'https://example.com/health/course-1-cover.png',
+ 299.00, 'ALIPAY', NULL, NULL, 'PAID', '2026-03-21 18:10:00', NULL, NULL, 0),
+('CPO202603230101', 304, '王知夏', '13800009004', 'HEALTH_COURSE', 'COURSE_CHAPTER', 1,
+ 1, '中医针灸资格证-方剂学', '中医针灸资格证', '方剂学', 'https://example.com/health/course-1-cover.png',
+ 159.00, 'WECHAT', NULL, NULL, 'PAID', '2026-03-23 14:50:00', NULL, NULL, 0),
+('CPO202603250101', 306, '许清禾', '13800009006', 'HEALTH_COURSE', 'COURSE', 1,
+ NULL, '中医针灸资格证', '中医针灸资格证', NULL, 'https://example.com/health/course-1-cover.png',
+ 259.00, 'ALIPAY', NULL, NULL, 'PAID', '2026-03-25 21:10:00', NULL, NULL, 0),
+('CPO202603260101', 303, '周雨桐', '13800009003', 'HEALTH_COURSE', 'COURSE_CHAPTER', 1,
+ 2, '中医针灸资格证-药剂学', '中医针灸资格证', '药剂学', 'https://example.com/health/course-1-cover.png',
+ 129.00, 'WECHAT', NULL, NULL, 'PAID', '2026-03-26 15:30:00', NULL, NULL, 0);
+
+INSERT IGNORE INTO `tcm_live_watch_record`
+(`live_course_id`, `session_id`, `app_user_id`, `watch_start`, `watch_end`, `duration_seconds`, `is_completed`, `is_effective`)
+VALUES
+(1, 90001, 301, '2026-03-20 19:40:00', '2026-03-20 20:05:00', 1500, 0, 1),
+(1, 90001, 301, '2026-03-20 20:10:00', '2026-03-20 20:18:00', 480, 0, 1),
+(1, 90002, 302, '2026-03-21 19:38:00', '2026-03-21 20:12:00', 2040, 1, 1),
+(2, 90003, 303, '2026-03-22 19:45:00', '2026-03-22 20:16:00', 1860, 1, 1),
+(2, 90003, 303, '2026-03-22 20:20:00', '2026-03-22 20:26:00', 360, 0, 0),
+(2, 90004, 304, '2026-03-23 19:36:00', '2026-03-23 19:55:00', 1140, 0, 1),
+(1, 90005, 305, '2026-03-24 19:42:00', '2026-03-24 20:06:00', 1440, 0, 1),
+(1, 90005, 303, '2026-03-24 20:10:00', '2026-03-24 20:19:00', 540, 0, 0),
+(2, 90006, 306, '2026-03-25 19:40:00', '2026-03-25 20:08:00', 1680, 1, 1),
+(2, 90006, 304, '2026-03-25 20:15:00', '2026-03-25 20:22:00', 420, 0, 0),
+(1, 90007, 307, '2026-03-26 19:40:00', '2026-03-26 20:06:00', 1560, 0, 1),
+(1, 90007, 306, '2026-03-26 20:12:00', '2026-03-26 20:24:00', 720, 0, 1),
+(1, 90007, 303, '2026-03-26 20:25:00', '2026-03-26 20:28:00', 180, 0, 0);
diff --git a/backend/sql/update_user_statistics_schema.sql b/backend/sql/update_user_statistics_schema.sql
new file mode 100644
index 0000000..11bf68e
--- /dev/null
+++ b/backend/sql/update_user_statistics_schema.sql
@@ -0,0 +1,23 @@
+CREATE TABLE IF NOT EXISTS `tcm_app_user_entry_log` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `app_user_id` BIGINT UNSIGNED NOT NULL COMMENT 'APP用户ID',
+ `entry_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '进入系统时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0,
+ `created_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '创建人',
+ `updated_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '更新人',
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_app_user_entry_log_user_time` (`app_user_id`, `entry_time`),
+ KEY `idx_app_user_entry_log_entry_time` (`entry_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='APP用户进入系统记录表';
+
+INSERT IGNORE INTO `sys_menu`
+(`id`, `parent_id`, `name`, `menu_code`, `type`, `path`, `component`, `icon`, `sort`, `status`, `visible`, `permission_key`, `remark`, `deleted`)
+VALUES
+(5000, 0, '统计', 'statistics_root', 'M', '/statistics', NULL, 'barChart', 150, 1, 1, NULL, '统计模块', 0),
+(5010, 5000, '用户数据', 'statistics_user_data', 'C', '/statistics/user-data', 'Statistics/UserData', NULL, 10, 1, 1, 'statistics:user-data:view', '用户数据菜单', 0);
+
+INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES
+(1, 5000), (1, 5010);
diff --git a/backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java b/backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
index 0831a6d..c4d191e 100644
--- a/backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
+++ b/backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
@@ -185,6 +185,8 @@
null, null, null, 1, "system:points-settings:edit", "积分设置编辑按钮"));
menuIds.add(saveMenu(5000L, 0L, "统计", "statistics_root", "M",
"/statistics", null, "barChart", 150, null, "统计模块"));
+ menuIds.add(saveMenu(5010L, 5000L, "用户数据", "statistics_user_data", "C",
+ "/statistics/user-data", "Statistics/UserData", null, 10, "statistics:user-data:view", "用户数据菜单"));
menuIds.add(saveMenu(5030L, 5000L, "营收数据", "statistics_revenue", "C",
"/statistics/revenue", "Statistics/Revenue", null, 30, "statistics:revenue:view", "营收数据菜单"));
menuIds.add(saveMenu(5031L, 5030L, "导出营收报表", "statistics_revenue_export", "B",
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/controller/UserEntryLogController.java b/backend/src/main/java/com/tcm/training/admin/statistics/controller/UserEntryLogController.java
new file mode 100644
index 0000000..d03504a
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/controller/UserEntryLogController.java
@@ -0,0 +1,27 @@
+package com.tcm.training.admin.statistics.controller;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.tcm.training.admin.common.ApiResponse;
+import com.tcm.training.admin.statistics.service.AppUserEntryLogAppService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Validated
+@RestController
+@RequestMapping({"/api/v1", "/api"})
+public class UserEntryLogController {
+
+ private final AppUserEntryLogAppService appUserEntryLogAppService;
+
+ public UserEntryLogController(AppUserEntryLogAppService appUserEntryLogAppService) {
+ this.appUserEntryLogAppService = appUserEntryLogAppService;
+ }
+
+ @PostMapping("/user-entry-logs")
+ public ApiResponse<Void> record() {
+ appUserEntryLogAppService.record(StpUtil.getLoginIdAsLong());
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/controller/UserStatisticsController.java b/backend/src/main/java/com/tcm/training/admin/statistics/controller/UserStatisticsController.java
new file mode 100644
index 0000000..69964fe
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/controller/UserStatisticsController.java
@@ -0,0 +1,33 @@
+package com.tcm.training.admin.statistics.controller;
+
+import com.tcm.training.admin.common.ApiResponse;
+import com.tcm.training.admin.statistics.service.UserStatisticsAppService;
+import com.tcm.training.admin.statistics.vo.UserStatisticsVO;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.LocalDate;
+
+@Validated
+@RestController
+@RequestMapping({"/api/v1", "/api"})
+public class UserStatisticsController {
+
+ private final UserStatisticsAppService userStatisticsAppService;
+
+ public UserStatisticsController(UserStatisticsAppService userStatisticsAppService) {
+ this.userStatisticsAppService = userStatisticsAppService;
+ }
+
+ @GetMapping("/user-statistics")
+ public ApiResponse<UserStatisticsVO> statistics(
+ @RequestParam(required = false) String type,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate dateFrom,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate dateTo) {
+ return ApiResponse.ok(userStatisticsAppService.statistics(type, dateFrom, dateTo));
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/entity/AppUserEntryLog.java b/backend/src/main/java/com/tcm/training/admin/statistics/entity/AppUserEntryLog.java
new file mode 100644
index 0000000..92c2eed
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/entity/AppUserEntryLog.java
@@ -0,0 +1,44 @@
+package com.tcm.training.admin.statistics.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+@TableName("tcm_app_user_entry_log")
+@Getter
+@Setter
+@NoArgsConstructor
+public class AppUserEntryLog {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ @TableField("app_user_id")
+ private Long appUserId;
+
+ @TableField("entry_time")
+ private LocalDateTime entryTime;
+
+ @TableLogic
+ private Integer deleted;
+
+ @TableField(value = "created_by", fill = FieldFill.INSERT)
+ private String createdBy;
+
+ @TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE)
+ private String updatedBy;
+
+ @TableField("created_at")
+ private LocalDateTime createdAt;
+
+ @TableField("updated_at")
+ private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/mapper/AppUserEntryLogMapper.java b/backend/src/main/java/com/tcm/training/admin/statistics/mapper/AppUserEntryLogMapper.java
new file mode 100644
index 0000000..3ac8375
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/mapper/AppUserEntryLogMapper.java
@@ -0,0 +1,9 @@
+package com.tcm.training.admin.statistics.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.tcm.training.admin.statistics.entity.AppUserEntryLog;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AppUserEntryLogMapper extends BaseMapper<AppUserEntryLog> {
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/mapper/UserStatisticsMapper.java b/backend/src/main/java/com/tcm/training/admin/statistics/mapper/UserStatisticsMapper.java
new file mode 100644
index 0000000..a33a316
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/mapper/UserStatisticsMapper.java
@@ -0,0 +1,31 @@
+package com.tcm.training.admin.statistics.mapper;
+
+import com.tcm.training.admin.statistics.row.UserStatisticsActiveTrendRow;
+import com.tcm.training.admin.statistics.row.UserStatisticsCompositionRow;
+import com.tcm.training.admin.statistics.row.UserStatisticsOverviewRow;
+import com.tcm.training.admin.statistics.row.UserStatisticsRegisterTrendRow;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Mapper
+public interface UserStatisticsMapper {
+
+ UserStatisticsOverviewRow selectOverview(@Param("userType") String userType,
+ @Param("startTime") LocalDateTime startTime,
+ @Param("endTime") LocalDateTime endTime);
+
+ List<UserStatisticsRegisterTrendRow> selectRegisterTrend(@Param("userType") String userType,
+ @Param("startTime") LocalDateTime startTime,
+ @Param("endTime") LocalDateTime endTime);
+
+ List<UserStatisticsActiveTrendRow> selectActiveTrend(@Param("userType") String userType,
+ @Param("startTime") LocalDateTime startTime,
+ @Param("endTime") LocalDateTime endTime);
+
+ UserStatisticsCompositionRow selectMemberComposition(@Param("userType") String userType,
+ @Param("startTime") LocalDateTime startTime,
+ @Param("endTime") LocalDateTime endTime);
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsActiveTrendRow.java b/backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsActiveTrendRow.java
new file mode 100644
index 0000000..dd66547
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsActiveTrendRow.java
@@ -0,0 +1,16 @@
+package com.tcm.training.admin.statistics.row;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDate;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class UserStatisticsActiveTrendRow {
+
+ private LocalDate statDate;
+ private Long activeEnterUserCount;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsCompositionRow.java b/backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsCompositionRow.java
new file mode 100644
index 0000000..bb8952d
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsCompositionRow.java
@@ -0,0 +1,15 @@
+package com.tcm.training.admin.statistics.row;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class UserStatisticsCompositionRow {
+
+ private Long monthMemberPurchaseCount;
+ private Long quarterMemberPurchaseCount;
+ private Long courseMemberUserCount;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsOverviewRow.java b/backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsOverviewRow.java
new file mode 100644
index 0000000..76a4ee2
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsOverviewRow.java
@@ -0,0 +1,19 @@
+package com.tcm.training.admin.statistics.row;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class UserStatisticsOverviewRow {
+
+ private Long registeredUserCount;
+ private Long doctorRegisteredCount;
+ private Long memberOpenCount;
+ private Long coursePurchaseCount;
+ private Long liveWatchClickCount;
+ private Long activeEnterUserCount;
+ private Long totalUserCount;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsRegisterTrendRow.java b/backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsRegisterTrendRow.java
new file mode 100644
index 0000000..83c1332
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsRegisterTrendRow.java
@@ -0,0 +1,17 @@
+package com.tcm.training.admin.statistics.row;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDate;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class UserStatisticsRegisterTrendRow {
+
+ private LocalDate statDate;
+ private Long registeredUserCount;
+ private Long doctorRegisteredCount;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/service/AppUserEntryLogAppService.java b/backend/src/main/java/com/tcm/training/admin/statistics/service/AppUserEntryLogAppService.java
new file mode 100644
index 0000000..323cda7
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/service/AppUserEntryLogAppService.java
@@ -0,0 +1,37 @@
+package com.tcm.training.admin.statistics.service;
+
+import com.tcm.training.admin.member.entity.AppUser;
+import com.tcm.training.admin.member.mapper.UserMapper;
+import com.tcm.training.admin.statistics.entity.AppUserEntryLog;
+import com.tcm.training.admin.statistics.mapper.AppUserEntryLogMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+
+@Service
+public class AppUserEntryLogAppService {
+
+ private final AppUserEntryLogMapper appUserEntryLogMapper;
+ private final UserMapper userMapper;
+
+ public AppUserEntryLogAppService(AppUserEntryLogMapper appUserEntryLogMapper,
+ UserMapper userMapper) {
+ this.appUserEntryLogMapper = appUserEntryLogMapper;
+ this.userMapper = userMapper;
+ }
+
+ @Transactional
+ public void record(Long appUserId) {
+ AppUser user = userMapper.selectById(appUserId);
+ if (user == null || (user.getDeleted() != null && user.getDeleted() == 1)) {
+ throw new IllegalArgumentException("用户不存在");
+ }
+
+ AppUserEntryLog entryLog = new AppUserEntryLog();
+ entryLog.setAppUserId(appUserId);
+ entryLog.setEntryTime(LocalDateTime.now());
+ entryLog.setDeleted(0);
+ appUserEntryLogMapper.insert(entryLog);
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/service/UserStatisticsAppService.java b/backend/src/main/java/com/tcm/training/admin/statistics/service/UserStatisticsAppService.java
new file mode 100644
index 0000000..0b81083
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/service/UserStatisticsAppService.java
@@ -0,0 +1,181 @@
+package com.tcm.training.admin.statistics.service;
+
+import com.tcm.training.admin.statistics.mapper.UserStatisticsMapper;
+import com.tcm.training.admin.statistics.row.UserStatisticsActiveTrendRow;
+import com.tcm.training.admin.statistics.row.UserStatisticsCompositionRow;
+import com.tcm.training.admin.statistics.row.UserStatisticsOverviewRow;
+import com.tcm.training.admin.statistics.row.UserStatisticsRegisterTrendRow;
+import com.tcm.training.admin.statistics.vo.UserStatisticsMemberCompositionVO;
+import com.tcm.training.admin.statistics.vo.UserStatisticsOverviewVO;
+import com.tcm.training.admin.statistics.vo.UserStatisticsTrendItemVO;
+import com.tcm.training.admin.statistics.vo.UserStatisticsVO;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class UserStatisticsAppService {
+
+ private static final String FILTER_ALL = "ALL";
+ private static final String FILTER_NORMAL = "NORMAL";
+ private static final String FILTER_MEMBER = "MEMBER";
+
+ private final UserStatisticsMapper userStatisticsMapper;
+
+ public UserStatisticsAppService(UserStatisticsMapper userStatisticsMapper) {
+ this.userStatisticsMapper = userStatisticsMapper;
+ }
+
+ public UserStatisticsVO statistics(String type, LocalDate dateFrom, LocalDate dateTo) {
+ DateRange range = normalizeRange(dateFrom, dateTo);
+ String normalizedType = normalizeType(type);
+ String mapperUserType = resolveUserType(normalizedType);
+ LocalDateTime startTime = range.getStart().atStartOfDay();
+ LocalDateTime endTime = LocalDateTime.of(range.getEnd(), LocalTime.MAX);
+
+ UserStatisticsOverviewRow overviewRow = userStatisticsMapper.selectOverview(mapperUserType, startTime, endTime);
+ List<UserStatisticsRegisterTrendRow> registerRows = userStatisticsMapper.selectRegisterTrend(mapperUserType, startTime, endTime);
+ List<UserStatisticsActiveTrendRow> activeRows = userStatisticsMapper.selectActiveTrend(mapperUserType, startTime, endTime);
+ UserStatisticsCompositionRow compositionRow = userStatisticsMapper.selectMemberComposition(mapperUserType, startTime, endTime);
+
+ UserStatisticsVO result = new UserStatisticsVO();
+ result.setOverview(buildOverview(overviewRow));
+ result.setTrend(buildTrend(registerRows, activeRows, range));
+ result.setMemberComposition(buildComposition(compositionRow));
+ return result;
+ }
+
+ private UserStatisticsOverviewVO buildOverview(UserStatisticsOverviewRow row) {
+ UserStatisticsOverviewVO overview = new UserStatisticsOverviewVO();
+ overview.setRegisteredUserCount(nvl(row == null ? null : row.getRegisteredUserCount()));
+ overview.setDoctorRegisteredCount(nvl(row == null ? null : row.getDoctorRegisteredCount()));
+ overview.setMemberOpenCount(nvl(row == null ? null : row.getMemberOpenCount()));
+ overview.setCoursePurchaseCount(nvl(row == null ? null : row.getCoursePurchaseCount()));
+ overview.setLiveWatchClickCount(nvl(row == null ? null : row.getLiveWatchClickCount()));
+ overview.setUserActivityRate(buildActivityRate(
+ nvl(row == null ? null : row.getActiveEnterUserCount()),
+ nvl(row == null ? null : row.getTotalUserCount())
+ ));
+ return overview;
+ }
+
+ private UserStatisticsMemberCompositionVO buildComposition(UserStatisticsCompositionRow row) {
+ UserStatisticsMemberCompositionVO composition = new UserStatisticsMemberCompositionVO();
+ composition.setMonthMemberPurchaseCount(nvl(row == null ? null : row.getMonthMemberPurchaseCount()));
+ composition.setQuarterMemberPurchaseCount(nvl(row == null ? null : row.getQuarterMemberPurchaseCount()));
+ composition.setCourseMemberUserCount(nvl(row == null ? null : row.getCourseMemberUserCount()));
+ return composition;
+ }
+
+ private List<UserStatisticsTrendItemVO> buildTrend(List<UserStatisticsRegisterTrendRow> registerRows,
+ List<UserStatisticsActiveTrendRow> activeRows,
+ DateRange range) {
+ Map<LocalDate, UserStatisticsRegisterTrendRow> registerRowMap = new HashMap<LocalDate, UserStatisticsRegisterTrendRow>();
+ for (UserStatisticsRegisterTrendRow row : registerRows) {
+ registerRowMap.put(row.getStatDate(), row);
+ }
+
+ Map<LocalDate, UserStatisticsActiveTrendRow> activeRowMap = new HashMap<LocalDate, UserStatisticsActiveTrendRow>();
+ for (UserStatisticsActiveTrendRow row : activeRows) {
+ activeRowMap.put(row.getStatDate(), row);
+ }
+
+ List<UserStatisticsTrendItemVO> result = new ArrayList<UserStatisticsTrendItemVO>();
+ LocalDate cursor = range.getStart();
+ while (!cursor.isAfter(range.getEnd())) {
+ UserStatisticsRegisterTrendRow registerRow = registerRowMap.get(cursor);
+ UserStatisticsActiveTrendRow activeRow = activeRowMap.get(cursor);
+
+ UserStatisticsTrendItemVO item = new UserStatisticsTrendItemVO();
+ item.setDate(cursor.toString());
+ item.setRegisteredUserCount(nvl(registerRow == null ? null : registerRow.getRegisteredUserCount()));
+ item.setDoctorRegisteredCount(nvl(registerRow == null ? null : registerRow.getDoctorRegisteredCount()));
+ item.setActiveEnterUserCount(nvl(activeRow == null ? null : activeRow.getActiveEnterUserCount()));
+ result.add(item);
+ cursor = cursor.plusDays(1);
+ }
+ return result;
+ }
+
+ private BigDecimal buildActivityRate(Long activeEnterUserCount, Long totalUserCount) {
+ if (totalUserCount == null || totalUserCount <= 0) {
+ return BigDecimal.ZERO;
+ }
+ return BigDecimal.valueOf(activeEnterUserCount == null ? 0L : activeEnterUserCount)
+ .multiply(BigDecimal.valueOf(100))
+ .divide(BigDecimal.valueOf(totalUserCount), 2, RoundingMode.HALF_UP);
+ }
+
+ private String normalizeType(String type) {
+ String normalized = trimToNull(type);
+ if (normalized == null) {
+ return FILTER_ALL;
+ }
+ if (FILTER_ALL.equals(normalized) || FILTER_NORMAL.equals(normalized) || FILTER_MEMBER.equals(normalized)) {
+ return normalized;
+ }
+ throw new IllegalArgumentException("用户类型不合法");
+ }
+
+ private String resolveUserType(String normalizedType) {
+ if (FILTER_ALL.equals(normalizedType)) {
+ return null;
+ }
+ return normalizedType;
+ }
+
+ private DateRange normalizeRange(LocalDate dateFrom, LocalDate dateTo) {
+ LocalDate start = dateFrom;
+ LocalDate end = dateTo;
+ if (start == null && end == null) {
+ start = LocalDate.now();
+ end = start;
+ } else if (start == null) {
+ start = end;
+ } else if (end == null) {
+ end = start;
+ }
+ if (start.isAfter(end)) {
+ throw new IllegalArgumentException("开始日期不能晚于结束日期");
+ }
+ return new DateRange(start, end);
+ }
+
+ private Long nvl(Long value) {
+ return value == null ? 0L : value;
+ }
+
+ private String trimToNull(String value) {
+ if (value == null) {
+ return null;
+ }
+ String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+
+ private static class DateRange {
+ private final LocalDate start;
+ private final LocalDate end;
+
+ private DateRange(LocalDate start, LocalDate end) {
+ this.start = start;
+ this.end = end;
+ }
+
+ private LocalDate getStart() {
+ return start;
+ }
+
+ private LocalDate getEnd() {
+ return end;
+ }
+ }
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsMemberCompositionVO.java b/backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsMemberCompositionVO.java
new file mode 100644
index 0000000..4f6c4ab
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsMemberCompositionVO.java
@@ -0,0 +1,15 @@
+package com.tcm.training.admin.statistics.vo;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class UserStatisticsMemberCompositionVO {
+
+ private Long monthMemberPurchaseCount;
+ private Long quarterMemberPurchaseCount;
+ private Long courseMemberUserCount;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsOverviewVO.java b/backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsOverviewVO.java
new file mode 100644
index 0000000..e02b379
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsOverviewVO.java
@@ -0,0 +1,20 @@
+package com.tcm.training.admin.statistics.vo;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class UserStatisticsOverviewVO {
+
+ private Long registeredUserCount;
+ private Long doctorRegisteredCount;
+ private Long memberOpenCount;
+ private Long coursePurchaseCount;
+ private Long liveWatchClickCount;
+ private BigDecimal userActivityRate;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsTrendItemVO.java b/backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsTrendItemVO.java
new file mode 100644
index 0000000..a5c14c9
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsTrendItemVO.java
@@ -0,0 +1,16 @@
+package com.tcm.training.admin.statistics.vo;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class UserStatisticsTrendItemVO {
+
+ private String date;
+ private Long registeredUserCount;
+ private Long doctorRegisteredCount;
+ private Long activeEnterUserCount;
+}
diff --git a/backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsVO.java b/backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsVO.java
new file mode 100644
index 0000000..02da565
--- /dev/null
+++ b/backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsVO.java
@@ -0,0 +1,17 @@
+package com.tcm.training.admin.statistics.vo;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.util.List;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class UserStatisticsVO {
+
+ private UserStatisticsOverviewVO overview;
+ private List<UserStatisticsTrendItemVO> trend;
+ private UserStatisticsMemberCompositionVO memberComposition;
+}
diff --git a/backend/src/main/resources/mapper/UserStatisticsMapper.xml b/backend/src/main/resources/mapper/UserStatisticsMapper.xml
new file mode 100644
index 0000000..1abaf27
--- /dev/null
+++ b/backend/src/main/resources/mapper/UserStatisticsMapper.xml
@@ -0,0 +1,174 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.tcm.training.admin.statistics.mapper.UserStatisticsMapper">
+
+ <sql id="AppUserTypeFilter">
+ <if test="userType != null and userType == 'NORMAL'">
+ AND (${alias}.member_expire_at IS NULL OR ${alias}.member_expire_at <= NOW())
+ </if>
+ <if test="userType != null and userType == 'MEMBER'">
+ AND ${alias}.member_expire_at IS NOT NULL AND ${alias}.member_expire_at > NOW()
+ </if>
+ </sql>
+
+ <select id="selectOverview" resultType="com.tcm.training.admin.statistics.row.UserStatisticsOverviewRow">
+ SELECT
+ COALESCE((
+ SELECT COUNT(1)
+ FROM tcm_app_user u
+ WHERE u.deleted = 0
+ AND u.created_at <![CDATA[>=]]> #{startTime}
+ AND u.created_at <![CDATA[<=]]> #{endTime}
+ <include refid="AppUserTypeFilter">
+ <property name="alias" value="u"/>
+ </include>
+ ), 0) AS registeredUserCount,
+ COALESCE((
+ SELECT COUNT(1)
+ FROM tcm_app_user u
+ WHERE u.deleted = 0
+ AND u.type = 1
+ AND u.created_at <![CDATA[>=]]> #{startTime}
+ AND u.created_at <![CDATA[<=]]> #{endTime}
+ <include refid="AppUserTypeFilter">
+ <property name="alias" value="u"/>
+ </include>
+ ), 0) AS doctorRegisteredCount,
+ COALESCE((
+ SELECT COUNT(1)
+ FROM tcm_member_purchase_order o
+ INNER JOIN tcm_app_user u ON u.id = o.app_user_id AND u.deleted = 0
+ WHERE o.deleted = 0
+ AND o.status IN ('PAID', 'REFUNDED')
+ AND o.paid_at IS NOT NULL
+ AND o.paid_at <![CDATA[>=]]> #{startTime}
+ AND o.paid_at <![CDATA[<=]]> #{endTime}
+ <include refid="AppUserTypeFilter">
+ <property name="alias" value="u"/>
+ </include>
+ ), 0) AS memberOpenCount,
+ COALESCE((
+ SELECT COUNT(1)
+ FROM tcm_course_purchase_order o
+ INNER JOIN tcm_app_user u ON u.id = o.app_user_id AND u.deleted = 0
+ WHERE o.deleted = 0
+ AND o.status IN ('PAID', 'REFUNDED')
+ AND o.paid_at IS NOT NULL
+ AND o.paid_at <![CDATA[>=]]> #{startTime}
+ AND o.paid_at <![CDATA[<=]]> #{endTime}
+ <include refid="AppUserTypeFilter">
+ <property name="alias" value="u"/>
+ </include>
+ ), 0) AS coursePurchaseCount,
+ COALESCE((
+ SELECT COUNT(1)
+ FROM tcm_live_watch_record w
+ INNER JOIN tcm_app_user u ON u.id = w.app_user_id AND u.deleted = 0
+ WHERE w.watch_start <![CDATA[>=]]> #{startTime}
+ AND w.watch_start <![CDATA[<=]]> #{endTime}
+ <include refid="AppUserTypeFilter">
+ <property name="alias" value="u"/>
+ </include>
+ ), 0) AS liveWatchClickCount,
+ COALESCE((
+ SELECT COUNT(DISTINCT l.app_user_id)
+ FROM tcm_app_user_entry_log l
+ INNER JOIN tcm_app_user u ON u.id = l.app_user_id AND u.deleted = 0
+ WHERE l.deleted = 0
+ AND l.entry_time <![CDATA[>=]]> #{startTime}
+ AND l.entry_time <![CDATA[<=]]> #{endTime}
+ <include refid="AppUserTypeFilter">
+ <property name="alias" value="u"/>
+ </include>
+ ), 0) AS activeEnterUserCount,
+ COALESCE((
+ SELECT COUNT(1)
+ FROM tcm_app_user u
+ WHERE u.deleted = 0
+ <include refid="AppUserTypeFilter">
+ <property name="alias" value="u"/>
+ </include>
+ ), 0) AS totalUserCount
+ FROM DUAL
+ </select>
+
+ <select id="selectRegisterTrend" resultType="com.tcm.training.admin.statistics.row.UserStatisticsRegisterTrendRow">
+ SELECT
+ DATE(u.created_at) AS statDate,
+ COUNT(1) AS registeredUserCount,
+ COALESCE(SUM(CASE WHEN u.type = 1 THEN 1 ELSE 0 END), 0) AS doctorRegisteredCount
+ FROM tcm_app_user u
+ WHERE u.deleted = 0
+ AND u.created_at <![CDATA[>=]]> #{startTime}
+ AND u.created_at <![CDATA[<=]]> #{endTime}
+ <include refid="AppUserTypeFilter">
+ <property name="alias" value="u"/>
+ </include>
+ GROUP BY DATE(u.created_at)
+ ORDER BY DATE(u.created_at) ASC
+ </select>
+
+ <select id="selectActiveTrend" resultType="com.tcm.training.admin.statistics.row.UserStatisticsActiveTrendRow">
+ SELECT
+ DATE(l.entry_time) AS statDate,
+ COUNT(DISTINCT l.app_user_id) AS activeEnterUserCount
+ FROM tcm_app_user_entry_log l
+ INNER JOIN tcm_app_user u ON u.id = l.app_user_id AND u.deleted = 0
+ WHERE l.deleted = 0
+ AND l.entry_time <![CDATA[>=]]> #{startTime}
+ AND l.entry_time <![CDATA[<=]]> #{endTime}
+ <include refid="AppUserTypeFilter">
+ <property name="alias" value="u"/>
+ </include>
+ GROUP BY DATE(l.entry_time)
+ ORDER BY DATE(l.entry_time) ASC
+ </select>
+
+ <select id="selectMemberComposition" resultType="com.tcm.training.admin.statistics.row.UserStatisticsCompositionRow">
+ SELECT
+ COALESCE((
+ SELECT COUNT(1)
+ FROM tcm_member_purchase_order o
+ INNER JOIN tcm_app_user u ON u.id = o.app_user_id AND u.deleted = 0
+ WHERE o.deleted = 0
+ AND o.status IN ('PAID', 'REFUNDED')
+ AND o.paid_at IS NOT NULL
+ AND o.paid_at <![CDATA[>=]]> #{startTime}
+ AND o.paid_at <![CDATA[<=]]> #{endTime}
+ AND o.member_type = 'MONTH_CARD'
+ <include refid="AppUserTypeFilter">
+ <property name="alias" value="u"/>
+ </include>
+ ), 0) AS monthMemberPurchaseCount,
+ COALESCE((
+ SELECT COUNT(1)
+ FROM tcm_member_purchase_order o
+ INNER JOIN tcm_app_user u ON u.id = o.app_user_id AND u.deleted = 0
+ WHERE o.deleted = 0
+ AND o.status IN ('PAID', 'REFUNDED')
+ AND o.paid_at IS NOT NULL
+ AND o.paid_at <![CDATA[>=]]> #{startTime}
+ AND o.paid_at <![CDATA[<=]]> #{endTime}
+ AND o.member_type = 'QUARTER_CARD'
+ <include refid="AppUserTypeFilter">
+ <property name="alias" value="u"/>
+ </include>
+ ), 0) AS quarterMemberPurchaseCount,
+ COALESCE((
+ SELECT COUNT(DISTINCT o.app_user_id)
+ FROM tcm_course_purchase_order o
+ INNER JOIN tcm_app_user u ON u.id = o.app_user_id AND u.deleted = 0
+ WHERE o.deleted = 0
+ AND o.status IN ('PAID', 'REFUNDED')
+ AND o.paid_at IS NOT NULL
+ AND o.paid_at <![CDATA[>=]]> #{startTime}
+ AND o.paid_at <![CDATA[<=]]> #{endTime}
+ <include refid="AppUserTypeFilter">
+ <property name="alias" value="u"/>
+ </include>
+ ), 0) AS courseMemberUserCount
+ FROM DUAL
+ </select>
+
+</mapper>
diff --git a/backend/src/test/java/com/tcm/training/admin/statistics/service/UserStatisticsAppServiceTest.java b/backend/src/test/java/com/tcm/training/admin/statistics/service/UserStatisticsAppServiceTest.java
new file mode 100644
index 0000000..3910c2b
--- /dev/null
+++ b/backend/src/test/java/com/tcm/training/admin/statistics/service/UserStatisticsAppServiceTest.java
@@ -0,0 +1,154 @@
+package com.tcm.training.admin.statistics.service;
+
+import com.tcm.training.admin.statistics.mapper.UserStatisticsMapper;
+import com.tcm.training.admin.statistics.row.UserStatisticsActiveTrendRow;
+import com.tcm.training.admin.statistics.row.UserStatisticsCompositionRow;
+import com.tcm.training.admin.statistics.row.UserStatisticsOverviewRow;
+import com.tcm.training.admin.statistics.row.UserStatisticsRegisterTrendRow;
+import com.tcm.training.admin.statistics.vo.UserStatisticsVO;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class UserStatisticsAppServiceTest {
+
+ @Mock
+ private UserStatisticsMapper userStatisticsMapper;
+
+ @InjectMocks
+ private UserStatisticsAppService userStatisticsAppService;
+
+ @Test
+ void statisticsShouldBuildOverviewTrendAndComposition() {
+ UserStatisticsOverviewRow overviewRow = new UserStatisticsOverviewRow();
+ overviewRow.setRegisteredUserCount(8L);
+ overviewRow.setDoctorRegisteredCount(2L);
+ overviewRow.setMemberOpenCount(5L);
+ overviewRow.setCoursePurchaseCount(7L);
+ overviewRow.setLiveWatchClickCount(11L);
+ overviewRow.setActiveEnterUserCount(4L);
+ overviewRow.setTotalUserCount(20L);
+
+ UserStatisticsRegisterTrendRow registerTrendRow = new UserStatisticsRegisterTrendRow();
+ registerTrendRow.setStatDate(LocalDate.of(2026, 3, 1));
+ registerTrendRow.setRegisteredUserCount(3L);
+ registerTrendRow.setDoctorRegisteredCount(1L);
+
+ UserStatisticsActiveTrendRow activeTrendRow = new UserStatisticsActiveTrendRow();
+ activeTrendRow.setStatDate(LocalDate.of(2026, 3, 2));
+ activeTrendRow.setActiveEnterUserCount(2L);
+
+ UserStatisticsCompositionRow compositionRow = new UserStatisticsCompositionRow();
+ compositionRow.setMonthMemberPurchaseCount(2L);
+ compositionRow.setQuarterMemberPurchaseCount(1L);
+ compositionRow.setCourseMemberUserCount(3L);
+
+ when(userStatisticsMapper.selectOverview(
+ eq(null),
+ eq(LocalDateTime.of(2026, 3, 1, 0, 0, 0)),
+ eq(LocalDateTime.of(2026, 3, 2, LocalTime.MAX.getHour(), LocalTime.MAX.getMinute(), LocalTime.MAX.getSecond(), LocalTime.MAX.getNano()))
+ )).thenReturn(overviewRow);
+ when(userStatisticsMapper.selectRegisterTrend(
+ eq(null),
+ eq(LocalDateTime.of(2026, 3, 1, 0, 0, 0)),
+ eq(LocalDateTime.of(2026, 3, 2, LocalTime.MAX.getHour(), LocalTime.MAX.getMinute(), LocalTime.MAX.getSecond(), LocalTime.MAX.getNano()))
+ )).thenReturn(Collections.singletonList(registerTrendRow));
+ when(userStatisticsMapper.selectActiveTrend(
+ eq(null),
+ eq(LocalDateTime.of(2026, 3, 1, 0, 0, 0)),
+ eq(LocalDateTime.of(2026, 3, 2, LocalTime.MAX.getHour(), LocalTime.MAX.getMinute(), LocalTime.MAX.getSecond(), LocalTime.MAX.getNano()))
+ )).thenReturn(Collections.singletonList(activeTrendRow));
+ when(userStatisticsMapper.selectMemberComposition(
+ eq(null),
+ eq(LocalDateTime.of(2026, 3, 1, 0, 0, 0)),
+ eq(LocalDateTime.of(2026, 3, 2, LocalTime.MAX.getHour(), LocalTime.MAX.getMinute(), LocalTime.MAX.getSecond(), LocalTime.MAX.getNano()))
+ )).thenReturn(compositionRow);
+
+ UserStatisticsVO result = userStatisticsAppService.statistics(
+ "ALL",
+ LocalDate.of(2026, 3, 1),
+ LocalDate.of(2026, 3, 2)
+ );
+
+ assertEquals(8L, result.getOverview().getRegisteredUserCount());
+ assertEquals(2L, result.getOverview().getDoctorRegisteredCount());
+ assertEquals(5L, result.getOverview().getMemberOpenCount());
+ assertEquals(7L, result.getOverview().getCoursePurchaseCount());
+ assertEquals(11L, result.getOverview().getLiveWatchClickCount());
+ assertEquals(new BigDecimal("20.00"), result.getOverview().getUserActivityRate());
+ assertEquals(2, result.getTrend().size());
+ assertEquals(3L, result.getTrend().get(0).getRegisteredUserCount());
+ assertEquals(1L, result.getTrend().get(0).getDoctorRegisteredCount());
+ assertEquals(0L, result.getTrend().get(0).getActiveEnterUserCount());
+ assertEquals(0L, result.getTrend().get(1).getRegisteredUserCount());
+ assertEquals(2L, result.getTrend().get(1).getActiveEnterUserCount());
+ assertEquals(2L, result.getMemberComposition().getMonthMemberPurchaseCount());
+ assertEquals(1L, result.getMemberComposition().getQuarterMemberPurchaseCount());
+ assertEquals(3L, result.getMemberComposition().getCourseMemberUserCount());
+ }
+
+ @Test
+ void statisticsShouldUseMemberTypeFilter() {
+ UserStatisticsOverviewRow overviewRow = new UserStatisticsOverviewRow();
+ overviewRow.setTotalUserCount(8L);
+ overviewRow.setActiveEnterUserCount(2L);
+ when(userStatisticsMapper.selectOverview(
+ eq("MEMBER"),
+ eq(LocalDateTime.of(2026, 3, 5, 0, 0, 0)),
+ eq(LocalDateTime.of(2026, 3, 5, LocalTime.MAX.getHour(), LocalTime.MAX.getMinute(), LocalTime.MAX.getSecond(), LocalTime.MAX.getNano()))
+ )).thenReturn(overviewRow);
+ when(userStatisticsMapper.selectRegisterTrend(
+ eq("MEMBER"),
+ eq(LocalDateTime.of(2026, 3, 5, 0, 0, 0)),
+ eq(LocalDateTime.of(2026, 3, 5, LocalTime.MAX.getHour(), LocalTime.MAX.getMinute(), LocalTime.MAX.getSecond(), LocalTime.MAX.getNano()))
+ )).thenReturn(Collections.<UserStatisticsRegisterTrendRow>emptyList());
+ when(userStatisticsMapper.selectActiveTrend(
+ eq("MEMBER"),
+ eq(LocalDateTime.of(2026, 3, 5, 0, 0, 0)),
+ eq(LocalDateTime.of(2026, 3, 5, LocalTime.MAX.getHour(), LocalTime.MAX.getMinute(), LocalTime.MAX.getSecond(), LocalTime.MAX.getNano()))
+ )).thenReturn(Collections.<UserStatisticsActiveTrendRow>emptyList());
+ when(userStatisticsMapper.selectMemberComposition(
+ eq("MEMBER"),
+ eq(LocalDateTime.of(2026, 3, 5, 0, 0, 0)),
+ eq(LocalDateTime.of(2026, 3, 5, LocalTime.MAX.getHour(), LocalTime.MAX.getMinute(), LocalTime.MAX.getSecond(), LocalTime.MAX.getNano()))
+ )).thenReturn(new UserStatisticsCompositionRow());
+
+ UserStatisticsVO result = userStatisticsAppService.statistics(
+ "MEMBER",
+ LocalDate.of(2026, 3, 5),
+ LocalDate.of(2026, 3, 5)
+ );
+
+ assertEquals(new BigDecimal("25.00"), result.getOverview().getUserActivityRate());
+ }
+
+ @Test
+ void statisticsShouldRejectInvalidType() {
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> userStatisticsAppService.statistics("OTHER", LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 1)));
+
+ assertEquals("用户类型不合法", exception.getMessage());
+ }
+
+ @Test
+ void statisticsShouldRejectInvalidDateRange() {
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> userStatisticsAppService.statistics("ALL", LocalDate.of(2026, 3, 10), LocalDate.of(2026, 3, 1)));
+
+ assertEquals("开始日期不能晚于结束日期", exception.getMessage());
+ }
+}
diff --git a/tcm-training-admin/config/routes.ts b/tcm-training-admin/config/routes.ts
index ee5c506..fab25d6 100644
--- a/tcm-training-admin/config/routes.ts
+++ b/tcm-training-admin/config/routes.ts
@@ -150,6 +150,12 @@
component: './statistics/redirect',
},
{
+ name: '用户数据',
+ path: '/statistics/user-data',
+ access: 'statistics:user-data:view',
+ component: './statistics/user-data',
+ },
+ {
name: '营收数据',
path: '/statistics/revenue',
access: 'statistics:revenue:view',
diff --git a/tcm-training-admin/src/access.js b/tcm-training-admin/src/access.js
index af0765c..cfa1bd6 100644
--- a/tcm-training-admin/src/access.js
+++ b/tcm-training-admin/src/access.js
@@ -11,7 +11,8 @@
!!permission?.['member:vip-price:view'] || !!permission?.['member:vip-intro:view'];
return {
...permission,
- canViewStatisticsModule: !!permission?.['statistics:revenue:view'],
+ canViewStatisticsModule:
+ !!permission?.['statistics:user-data:view'] || !!permission?.['statistics:revenue:view'],
canViewSystemSecurity:
!!permission?.['system:account:view'] ||
!!permission?.['system:role:view'] ||
diff --git a/tcm-training-admin/src/pages/statistics/redirect.tsx b/tcm-training-admin/src/pages/statistics/redirect.tsx
index 631de43..fdb33a8 100644
--- a/tcm-training-admin/src/pages/statistics/redirect.tsx
+++ b/tcm-training-admin/src/pages/statistics/redirect.tsx
@@ -5,6 +5,10 @@
const access = useAccess();
useEffect(() => {
+ if (access['statistics:user-data:view']) {
+ history.replace('/statistics/user-data');
+ return;
+ }
if (access['statistics:revenue:view']) {
history.replace('/statistics/revenue');
return;
diff --git a/tcm-training-admin/src/pages/statistics/user-data/index.tsx b/tcm-training-admin/src/pages/statistics/user-data/index.tsx
new file mode 100644
index 0000000..bab1c88
--- /dev/null
+++ b/tcm-training-admin/src/pages/statistics/user-data/index.tsx
@@ -0,0 +1,359 @@
+import { history, useAccess } from '@umijs/max';
+import { Line, Pie } from '@ant-design/plots';
+import { Button, DatePicker, Form, message } from 'antd';
+import dayjs, { type Dayjs } from 'dayjs';
+import React, { useEffect, useMemo, useState } from 'react';
+import { getUserStatistics } from './service';
+import './style.less';
+
+type QuickRangeType = 'TODAY' | 'LAST_7_DAYS' | 'LAST_30_DAYS' | 'THIS_MONTH' | 'CUSTOM';
+type UserType = 'ALL' | 'NORMAL' | 'MEMBER';
+
+type SearchValues = {
+ type?: UserType;
+ dateRange?: [Dayjs, Dayjs];
+};
+
+type UserStatisticsOverview = {
+ registeredUserCount?: number;
+ doctorRegisteredCount?: number;
+ memberOpenCount?: number;
+ coursePurchaseCount?: number;
+ liveWatchClickCount?: number;
+ userActivityRate?: number;
+};
+
+type UserStatisticsTrendItem = {
+ date: string;
+ registeredUserCount?: number;
+ doctorRegisteredCount?: number;
+ activeEnterUserCount?: number;
+};
+
+type UserStatisticsMemberComposition = {
+ monthMemberPurchaseCount?: number;
+ quarterMemberPurchaseCount?: number;
+ courseMemberUserCount?: number;
+};
+
+type UserStatisticsResult = {
+ overview?: UserStatisticsOverview;
+ trend?: UserStatisticsTrendItem[];
+ memberComposition?: UserStatisticsMemberComposition;
+};
+
+const quickRangeOptions: Array<{ label: string; value: QuickRangeType }> = [
+ { label: '今日', value: 'TODAY' },
+ { label: '近7天', value: 'LAST_7_DAYS' },
+ { label: '近30天', value: 'LAST_30_DAYS' },
+ { label: '本月', value: 'THIS_MONTH' },
+ { label: '自定义', value: 'CUSTOM' },
+];
+
+const userTypeOptions: Array<{ label: string; value: UserType }> = [
+ { label: '全部', value: 'ALL' },
+ { label: '普通用户', value: 'NORMAL' },
+ { label: '会员用户', value: 'MEMBER' },
+];
+
+const seriesColorMap: Record<string, string> = {
+ 用户注册: '#4f7cff',
+ 医生注册: '#ff7a9e',
+ 进入系统人数: '#52c41a',
+ 月会员: '#4f7cff',
+ 季会员: '#f5a623',
+ 购课会员: '#5bc0de',
+};
+
+const getRangeByPreset = (preset: QuickRangeType): [Dayjs, Dayjs] => {
+ const today = dayjs();
+ if (preset === 'LAST_7_DAYS') {
+ return [today.subtract(6, 'day').startOf('day'), today.endOf('day')];
+ }
+ if (preset === 'LAST_30_DAYS') {
+ return [today.subtract(29, 'day').startOf('day'), today.endOf('day')];
+ }
+ if (preset === 'THIS_MONTH') {
+ return [today.startOf('month'), today.endOf('day')];
+ }
+ return [today.startOf('day'), today.endOf('day')];
+};
+
+const buildParams = (values: SearchValues) => ({
+ type: values.type || 'ALL',
+ dateFrom: values.dateRange?.[0] ? dayjs(values.dateRange[0]).format('YYYY-MM-DD') : undefined,
+ dateTo: values.dateRange?.[1] ? dayjs(values.dateRange[1]).format('YYYY-MM-DD') : undefined,
+});
+
+const formatCount = (value?: number) => `${Number(value || 0)}`;
+
+const formatRate = (value?: number) => {
+ const amount = Number(value || 0);
+ const fixed = amount.toFixed(2);
+ return fixed.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
+};
+
+const renderChartValue = (value?: number) => Number(Number(value || 0).toFixed(2));
+
+const getSeriesColor = (datum: { type?: string }) => seriesColorMap[datum.type || ''] || '#4f7cff';
+
+const UserStatisticsPage: React.FC = () => {
+ const access = useAccess();
+ const [searchForm] = Form.useForm<SearchValues>();
+ const [loading, setLoading] = useState(false);
+ const [quickRange, setQuickRange] = useState<QuickRangeType>('TODAY');
+ const [selectedUserType, setSelectedUserType] = useState<UserType>('ALL');
+ const [overview, setOverview] = useState<UserStatisticsOverview>({});
+ const [trend, setTrend] = useState<UserStatisticsTrendItem[]>([]);
+ const [memberComposition, setMemberComposition] = useState<UserStatisticsMemberComposition>({});
+
+ const canView = access['statistics:user-data:view'];
+
+ const loadData = async (params = buildParams(searchForm.getFieldsValue())) => {
+ setLoading(true);
+ try {
+ const res = await getUserStatistics(params);
+ const data = (res.data || {}) as UserStatisticsResult;
+ setOverview(data.overview || {});
+ setTrend(data.trend || []);
+ setMemberComposition(data.memberComposition || {});
+ } catch (error) {
+ message.error((error as Error)?.message || '用户数据加载失败');
+ setOverview({});
+ setTrend([]);
+ setMemberComposition({});
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!canView) {
+ history.replace('/Welcome');
+ return;
+ }
+ const defaultRange = getRangeByPreset('TODAY');
+ searchForm.setFieldsValue({
+ type: 'ALL',
+ dateRange: defaultRange,
+ });
+ setSelectedUserType('ALL');
+ loadData(buildParams({ type: 'ALL', dateRange: defaultRange }));
+ }, [canView]);
+
+ const registerTrendData = useMemo(
+ () =>
+ trend.flatMap((item) => [
+ { date: item.date, type: '用户注册', value: renderChartValue(item.registeredUserCount) },
+ { date: item.date, type: '医生注册', value: renderChartValue(item.doctorRegisteredCount) },
+ ]),
+ [trend],
+ );
+
+ const activeTrendData = useMemo(
+ () =>
+ trend.map((item) => ({
+ date: item.date,
+ type: '进入系统人数',
+ value: renderChartValue(item.activeEnterUserCount),
+ })),
+ [trend],
+ );
+
+ const memberPieData = useMemo(
+ () => [
+ { type: '月会员', value: renderChartValue(memberComposition.monthMemberPurchaseCount) },
+ { type: '季会员', value: renderChartValue(memberComposition.quarterMemberPurchaseCount) },
+ { type: '购课会员', value: renderChartValue(memberComposition.courseMemberUserCount) },
+ ],
+ [memberComposition],
+ );
+
+ const handleQuickRangeChange = (preset: QuickRangeType) => {
+ setQuickRange(preset);
+ if (preset !== 'CUSTOM') {
+ searchForm.setFieldsValue({ dateRange: getRangeByPreset(preset) });
+ }
+ };
+
+ const handleUserTypeChange = (value: UserType) => {
+ setSelectedUserType(value);
+ searchForm.setFieldsValue({ type: value });
+ };
+
+ const handleReset = async () => {
+ const defaultRange = getRangeByPreset('TODAY');
+ setQuickRange('TODAY');
+ setSelectedUserType('ALL');
+ searchForm.setFieldsValue({
+ type: 'ALL',
+ dateRange: defaultRange,
+ });
+ await loadData(buildParams({ type: 'ALL', dateRange: defaultRange }));
+ };
+
+ return (
+ <div className="user-statistics-page">
+ <div className="user-statistics-page__panel">
+ <div className="user-statistics-page__title">用户数据统计</div>
+
+ <div className="user-statistics-search-card">
+ <Form form={searchForm} layout="horizontal" className="user-statistics-search-form">
+ <Form.Item label="日期范围">
+ <div className="user-statistics-search-form__segmented">
+ {quickRangeOptions.map((item) => (
+ <Button
+ key={item.value}
+ className={quickRange === item.value ? 'is-active' : undefined}
+ onClick={() => handleQuickRangeChange(item.value)}
+ >
+ {item.label}
+ </Button>
+ ))}
+ </div>
+ </Form.Item>
+ <Form.Item name="dateRange">
+ <DatePicker.RangePicker
+ className="user-statistics-search-form__range"
+ onChange={() => setQuickRange('CUSTOM')}
+ />
+ </Form.Item>
+ <Form.Item name="type" hidden>
+ <input type="hidden" />
+ </Form.Item>
+ <Form.Item label="用户类型">
+ <div className="user-statistics-search-form__segmented">
+ {userTypeOptions.map((item) => (
+ <Button
+ key={item.value}
+ className={selectedUserType === item.value ? 'is-active' : undefined}
+ onClick={() => handleUserTypeChange(item.value)}
+ >
+ {item.label}
+ </Button>
+ ))}
+ </div>
+ </Form.Item>
+ <div className="user-statistics-search-form__actions">
+ <Button onClick={handleReset}>重置</Button>
+ <Button type="primary" onClick={() => loadData()}>
+ 搜索
+ </Button>
+ </div>
+ </Form>
+ </div>
+
+ <div className="user-statistics-overview">
+ <div className="user-statistics-overview__card user-statistics-overview__card--register">
+ <div className="user-statistics-overview__label">用户注册数</div>
+ <div className="user-statistics-overview__value">
+ {formatCount(overview.registeredUserCount)}
+ <span className="user-statistics-overview__unit">人</span>
+ </div>
+ </div>
+ <div className="user-statistics-overview__card user-statistics-overview__card--doctor">
+ <div className="user-statistics-overview__label">医生注册数</div>
+ <div className="user-statistics-overview__value">
+ {formatCount(overview.doctorRegisteredCount)}
+ <span className="user-statistics-overview__unit">人</span>
+ </div>
+ </div>
+ <div className="user-statistics-overview__card user-statistics-overview__card--member">
+ <div className="user-statistics-overview__label">会员开通数</div>
+ <div className="user-statistics-overview__value">
+ {formatCount(overview.memberOpenCount)}
+ <span className="user-statistics-overview__unit">次</span>
+ </div>
+ </div>
+ <div className="user-statistics-overview__card user-statistics-overview__card--course">
+ <div className="user-statistics-overview__label">课程购买量</div>
+ <div className="user-statistics-overview__value">
+ {formatCount(overview.coursePurchaseCount)}
+ <span className="user-statistics-overview__unit">次</span>
+ </div>
+ </div>
+ <div className="user-statistics-overview__card user-statistics-overview__card--watch">
+ <div className="user-statistics-overview__label">直播观看次数</div>
+ <div className="user-statistics-overview__value">
+ {formatCount(overview.liveWatchClickCount)}
+ <span className="user-statistics-overview__unit">次</span>
+ </div>
+ </div>
+ <div className="user-statistics-overview__card user-statistics-overview__card--active">
+ <div className="user-statistics-overview__label">用户活跃度</div>
+ <div className="user-statistics-overview__value">
+ {formatRate(overview.userActivityRate)}
+ <span className="user-statistics-overview__unit">%</span>
+ </div>
+ </div>
+ </div>
+
+ <div className="user-statistics-chart-grid">
+ <div className="user-statistics-chart-card">
+ <div className="user-statistics-chart-card__title">用户注册趋势</div>
+ <div className="user-statistics-chart-card__unit">人数</div>
+ <div className="user-statistics-chart-card__plot">
+ <Line
+ loading={loading}
+ data={registerTrendData}
+ xField="date"
+ yField="value"
+ seriesField="type"
+ smooth
+ height={280}
+ color={getSeriesColor}
+ point={{ size: 3, shape: 'circle' }}
+ yAxis={{ label: { formatter: (value: string) => `${Number(value).toFixed(0)}` } }}
+ />
+ </div>
+ </div>
+
+ <div className="user-statistics-chart-card">
+ <div className="user-statistics-chart-card__title">用户活跃度趋势</div>
+ <div className="user-statistics-chart-card__unit">人数</div>
+ <div className="user-statistics-chart-card__plot">
+ <Line
+ loading={loading}
+ data={activeTrendData}
+ xField="date"
+ yField="value"
+ seriesField="type"
+ smooth
+ height={280}
+ color={getSeriesColor}
+ point={{ size: 3, shape: 'circle' }}
+ legend={false}
+ yAxis={{ label: { formatter: (value: string) => `${Number(value).toFixed(0)}` } }}
+ />
+ </div>
+ </div>
+
+ <div className="user-statistics-chart-card">
+ <div className="user-statistics-chart-card__title">会员开通趋势</div>
+ <div className="user-statistics-chart-card__unit">占比</div>
+ <div className="user-statistics-chart-card__plot">
+ <Pie
+ loading={loading}
+ data={memberPieData}
+ angleField="value"
+ colorField="type"
+ color={getSeriesColor}
+ height={280}
+ radius={0.9}
+ innerRadius={0.62}
+ label={{
+ type: 'outer',
+ content: '{name} {value}',
+ }}
+ legend={{ position: 'bottom' }}
+ interactions={[{ type: 'element-active' }]}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export default UserStatisticsPage;
diff --git a/tcm-training-admin/src/pages/statistics/user-data/service.ts b/tcm-training-admin/src/pages/statistics/user-data/service.ts
new file mode 100644
index 0000000..597e933
--- /dev/null
+++ b/tcm-training-admin/src/pages/statistics/user-data/service.ts
@@ -0,0 +1,8 @@
+import { request } from '@umijs/max';
+
+export async function getUserStatistics(params?: Record<string, unknown>) {
+ return request('/user-statistics', {
+ method: 'GET',
+ params,
+ });
+}
diff --git a/tcm-training-admin/src/pages/statistics/user-data/style.less b/tcm-training-admin/src/pages/statistics/user-data/style.less
new file mode 100644
index 0000000..4c52f9a
--- /dev/null
+++ b/tcm-training-admin/src/pages/statistics/user-data/style.less
@@ -0,0 +1,215 @@
+.user-statistics-page {
+ min-height: 100%;
+ padding: 24px;
+ background: #f5f7fb;
+}
+
+.user-statistics-page__panel {
+ padding: 24px;
+ background: #fff;
+ border-radius: 12px;
+ box-shadow: 0 8px 24px rgba(31, 56, 88, 0.06);
+}
+
+.user-statistics-page__title {
+ margin-bottom: 20px;
+ color: #1f1f1f;
+ font-weight: 600;
+ font-size: 20px;
+ line-height: 28px;
+}
+
+.user-statistics-search-card {
+ margin-bottom: 24px;
+ padding: 20px 20px 4px;
+ background: #fafbff;
+ border-radius: 12px;
+}
+
+.user-statistics-search-form {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ column-gap: 16px;
+}
+
+.user-statistics-search-form .ant-form-item {
+ margin-bottom: 16px;
+}
+
+.user-statistics-search-form__segmented {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.user-statistics-search-form__segmented button {
+ border-radius: 6px;
+}
+
+.user-statistics-search-form__segmented button.is-active {
+ color: #1677ff;
+ border-color: #1677ff;
+ background: rgba(22, 119, 255, 0.08);
+}
+
+.user-statistics-search-form__range {
+ width: 280px;
+}
+
+.user-statistics-search-form__actions {
+ display: flex;
+ gap: 12px;
+ margin-left: auto;
+ margin-bottom: 16px;
+}
+
+.user-statistics-overview {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 16px;
+ margin-bottom: 24px;
+}
+
+.user-statistics-overview__card {
+ padding: 20px 18px;
+ border-radius: 12px;
+}
+
+.user-statistics-overview__label {
+ margin-bottom: 12px;
+ color: #5f6b7a;
+ font-size: 14px;
+ line-height: 22px;
+}
+
+.user-statistics-overview__value {
+ color: #1f1f1f;
+ font-weight: 600;
+ font-size: 30px;
+ line-height: 38px;
+}
+
+.user-statistics-overview__unit {
+ margin-left: 4px;
+ font-size: 16px;
+}
+
+.user-statistics-overview__card--register {
+ background: rgba(255, 180, 0, 0.12);
+}
+
+.user-statistics-overview__card--register .user-statistics-overview__label,
+.user-statistics-overview__card--register .user-statistics-overview__value {
+ color: #fa8c16;
+}
+
+.user-statistics-overview__card--doctor {
+ background: rgba(255, 120, 117, 0.12);
+}
+
+.user-statistics-overview__card--doctor .user-statistics-overview__label,
+.user-statistics-overview__card--doctor .user-statistics-overview__value {
+ color: #ff4d4f;
+}
+
+.user-statistics-overview__card--member {
+ background: rgba(79, 124, 255, 0.12);
+}
+
+.user-statistics-overview__card--member .user-statistics-overview__label,
+.user-statistics-overview__card--member .user-statistics-overview__value {
+ color: #4f7cff;
+}
+
+.user-statistics-overview__card--course {
+ background: rgba(80, 197, 235, 0.12);
+}
+
+.user-statistics-overview__card--course .user-statistics-overview__label,
+.user-statistics-overview__card--course .user-statistics-overview__value {
+ color: #52c7ea;
+}
+
+.user-statistics-overview__card--watch {
+ background: rgba(45, 183, 245, 0.12);
+}
+
+.user-statistics-overview__card--watch .user-statistics-overview__label,
+.user-statistics-overview__card--watch .user-statistics-overview__value {
+ color: #1890ff;
+}
+
+.user-statistics-overview__card--active {
+ background: rgba(82, 196, 26, 0.12);
+}
+
+.user-statistics-overview__card--active .user-statistics-overview__label,
+.user-statistics-overview__card--active .user-statistics-overview__value {
+ color: #52c41a;
+}
+
+.user-statistics-chart-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.user-statistics-chart-card {
+ padding: 20px;
+ background: #fff;
+ border: 1px solid #f0f0f0;
+ border-radius: 12px;
+}
+
+.user-statistics-chart-card__title {
+ margin-bottom: 8px;
+ color: #1f1f1f;
+ font-weight: 600;
+ font-size: 16px;
+ line-height: 24px;
+}
+
+.user-statistics-chart-card__unit {
+ margin-bottom: 8px;
+ color: #8c8c8c;
+ font-size: 12px;
+ line-height: 20px;
+}
+
+.user-statistics-chart-card__plot {
+ min-height: 280px;
+}
+
+@media screen and (max-width: 1400px) {
+ .user-statistics-chart-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media screen and (max-width: 960px) {
+ .user-statistics-page {
+ padding: 16px;
+ }
+
+ .user-statistics-page__panel {
+ padding: 16px;
+ }
+
+ .user-statistics-search-form {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .user-statistics-search-form__range {
+ width: 100%;
+ }
+
+ .user-statistics-search-form__actions {
+ margin-left: 0;
+ }
+
+ .user-statistics-chart-grid {
+ grid-template-columns: 1fr;
+ }
+}
--
Gitblit v1.7.1