“mitao”
2026-03-26 72bc97968f1d7185c62affd893e97b8869471556
feat:统计-用户数据
22个文件已添加
5个文件已修改
1561 ■■■■■ 已修改文件
backend/sql/init.sql 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/sql/mock_user_statistics_recent_week.sql 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/sql/update_user_statistics_schema.sql 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/controller/UserEntryLogController.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/controller/UserStatisticsController.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/entity/AppUserEntryLog.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/mapper/AppUserEntryLogMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/mapper/UserStatisticsMapper.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsActiveTrendRow.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsCompositionRow.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsOverviewRow.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsRegisterTrendRow.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/service/AppUserEntryLogAppService.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/service/UserStatisticsAppService.java 181 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsMemberCompositionVO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsOverviewVO.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsTrendItemVO.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsVO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/mapper/UserStatisticsMapper.xml 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/tcm/training/admin/statistics/service/UserStatisticsAppServiceTest.java 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/config/routes.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/access.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/statistics/redirect.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/statistics/user-data/index.tsx 359 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/statistics/user-data/service.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/statistics/user-data/style.less 215 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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` (
backend/sql/mock_user_statistics_recent_week.sql
New file
@@ -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);
backend/sql/update_user_statistics_schema.sql
New file
@@ -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);
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",
backend/src/main/java/com/tcm/training/admin/statistics/controller/UserEntryLogController.java
New file
@@ -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);
    }
}
backend/src/main/java/com/tcm/training/admin/statistics/controller/UserStatisticsController.java
New file
@@ -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));
    }
}
backend/src/main/java/com/tcm/training/admin/statistics/entity/AppUserEntryLog.java
New file
@@ -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;
}
backend/src/main/java/com/tcm/training/admin/statistics/mapper/AppUserEntryLogMapper.java
New file
@@ -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> {
}
backend/src/main/java/com/tcm/training/admin/statistics/mapper/UserStatisticsMapper.java
New file
@@ -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);
}
backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsActiveTrendRow.java
New file
@@ -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;
}
backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsCompositionRow.java
New file
@@ -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;
}
backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsOverviewRow.java
New file
@@ -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;
}
backend/src/main/java/com/tcm/training/admin/statistics/row/UserStatisticsRegisterTrendRow.java
New file
@@ -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;
}
backend/src/main/java/com/tcm/training/admin/statistics/service/AppUserEntryLogAppService.java
New file
@@ -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);
    }
}
backend/src/main/java/com/tcm/training/admin/statistics/service/UserStatisticsAppService.java
New file
@@ -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;
        }
    }
}
backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsMemberCompositionVO.java
New file
@@ -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;
}
backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsOverviewVO.java
New file
@@ -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;
}
backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsTrendItemVO.java
New file
@@ -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;
}
backend/src/main/java/com/tcm/training/admin/statistics/vo/UserStatisticsVO.java
New file
@@ -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;
}
backend/src/main/resources/mapper/UserStatisticsMapper.xml
New file
@@ -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>
backend/src/test/java/com/tcm/training/admin/statistics/service/UserStatisticsAppServiceTest.java
New file
@@ -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());
    }
}
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',
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'] ||
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;
tcm-training-admin/src/pages/statistics/user-data/index.tsx
New file
@@ -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;
tcm-training-admin/src/pages/statistics/user-data/service.ts
New file
@@ -0,0 +1,8 @@
import { request } from '@umijs/max';
export async function getUserStatistics(params?: Record<string, unknown>) {
  return request('/user-statistics', {
    method: 'GET',
    params,
  });
}
tcm-training-admin/src/pages/statistics/user-data/style.less
New file
@@ -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;
  }
}