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 &lt;= NOW())
+        </if>
+        <if test="userType != null and userType == 'MEMBER'">
+            AND ${alias}.member_expire_at IS NOT NULL AND ${alias}.member_expire_at &gt; 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