| | |
| | | (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), |
| | |
| | | ) 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` ( |
| New file |
| | |
| | | -- 最近一周用户统计 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); |
| New file |
| | |
| | | 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); |
| | |
| | | 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", |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | <?xml version="1.0" encoding="UTF-8"?> |
| | | <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" |
| | | "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> |
| | | <mapper namespace="com.tcm.training.admin.statistics.mapper.UserStatisticsMapper"> |
| | | |
| | | <sql id="AppUserTypeFilter"> |
| | | <if test="userType != null and userType == 'NORMAL'"> |
| | | AND (${alias}.member_expire_at IS NULL OR ${alias}.member_expire_at <= NOW()) |
| | | </if> |
| | | <if test="userType != null and userType == 'MEMBER'"> |
| | | AND ${alias}.member_expire_at IS NOT NULL AND ${alias}.member_expire_at > NOW() |
| | | </if> |
| | | </sql> |
| | | |
| | | <select id="selectOverview" resultType="com.tcm.training.admin.statistics.row.UserStatisticsOverviewRow"> |
| | | SELECT |
| | | COALESCE(( |
| | | SELECT COUNT(1) |
| | | FROM tcm_app_user u |
| | | WHERE u.deleted = 0 |
| | | AND u.created_at <![CDATA[>=]]> #{startTime} |
| | | AND u.created_at <![CDATA[<=]]> #{endTime} |
| | | <include refid="AppUserTypeFilter"> |
| | | <property name="alias" value="u"/> |
| | | </include> |
| | | ), 0) AS registeredUserCount, |
| | | COALESCE(( |
| | | SELECT COUNT(1) |
| | | FROM tcm_app_user u |
| | | WHERE u.deleted = 0 |
| | | AND u.type = 1 |
| | | AND u.created_at <![CDATA[>=]]> #{startTime} |
| | | AND u.created_at <![CDATA[<=]]> #{endTime} |
| | | <include refid="AppUserTypeFilter"> |
| | | <property name="alias" value="u"/> |
| | | </include> |
| | | ), 0) AS doctorRegisteredCount, |
| | | COALESCE(( |
| | | SELECT COUNT(1) |
| | | FROM tcm_member_purchase_order o |
| | | INNER JOIN tcm_app_user u ON u.id = o.app_user_id AND u.deleted = 0 |
| | | WHERE o.deleted = 0 |
| | | AND o.status IN ('PAID', 'REFUNDED') |
| | | AND o.paid_at IS NOT NULL |
| | | AND o.paid_at <![CDATA[>=]]> #{startTime} |
| | | AND o.paid_at <![CDATA[<=]]> #{endTime} |
| | | <include refid="AppUserTypeFilter"> |
| | | <property name="alias" value="u"/> |
| | | </include> |
| | | ), 0) AS memberOpenCount, |
| | | COALESCE(( |
| | | SELECT COUNT(1) |
| | | FROM tcm_course_purchase_order o |
| | | INNER JOIN tcm_app_user u ON u.id = o.app_user_id AND u.deleted = 0 |
| | | WHERE o.deleted = 0 |
| | | AND o.status IN ('PAID', 'REFUNDED') |
| | | AND o.paid_at IS NOT NULL |
| | | AND o.paid_at <![CDATA[>=]]> #{startTime} |
| | | AND o.paid_at <![CDATA[<=]]> #{endTime} |
| | | <include refid="AppUserTypeFilter"> |
| | | <property name="alias" value="u"/> |
| | | </include> |
| | | ), 0) AS coursePurchaseCount, |
| | | COALESCE(( |
| | | SELECT COUNT(1) |
| | | FROM tcm_live_watch_record w |
| | | INNER JOIN tcm_app_user u ON u.id = w.app_user_id AND u.deleted = 0 |
| | | WHERE w.watch_start <![CDATA[>=]]> #{startTime} |
| | | AND w.watch_start <![CDATA[<=]]> #{endTime} |
| | | <include refid="AppUserTypeFilter"> |
| | | <property name="alias" value="u"/> |
| | | </include> |
| | | ), 0) AS liveWatchClickCount, |
| | | COALESCE(( |
| | | SELECT COUNT(DISTINCT l.app_user_id) |
| | | FROM tcm_app_user_entry_log l |
| | | INNER JOIN tcm_app_user u ON u.id = l.app_user_id AND u.deleted = 0 |
| | | WHERE l.deleted = 0 |
| | | AND l.entry_time <![CDATA[>=]]> #{startTime} |
| | | AND l.entry_time <![CDATA[<=]]> #{endTime} |
| | | <include refid="AppUserTypeFilter"> |
| | | <property name="alias" value="u"/> |
| | | </include> |
| | | ), 0) AS activeEnterUserCount, |
| | | COALESCE(( |
| | | SELECT COUNT(1) |
| | | FROM tcm_app_user u |
| | | WHERE u.deleted = 0 |
| | | <include refid="AppUserTypeFilter"> |
| | | <property name="alias" value="u"/> |
| | | </include> |
| | | ), 0) AS totalUserCount |
| | | FROM DUAL |
| | | </select> |
| | | |
| | | <select id="selectRegisterTrend" resultType="com.tcm.training.admin.statistics.row.UserStatisticsRegisterTrendRow"> |
| | | SELECT |
| | | DATE(u.created_at) AS statDate, |
| | | COUNT(1) AS registeredUserCount, |
| | | COALESCE(SUM(CASE WHEN u.type = 1 THEN 1 ELSE 0 END), 0) AS doctorRegisteredCount |
| | | FROM tcm_app_user u |
| | | WHERE u.deleted = 0 |
| | | AND u.created_at <![CDATA[>=]]> #{startTime} |
| | | AND u.created_at <![CDATA[<=]]> #{endTime} |
| | | <include refid="AppUserTypeFilter"> |
| | | <property name="alias" value="u"/> |
| | | </include> |
| | | GROUP BY DATE(u.created_at) |
| | | ORDER BY DATE(u.created_at) ASC |
| | | </select> |
| | | |
| | | <select id="selectActiveTrend" resultType="com.tcm.training.admin.statistics.row.UserStatisticsActiveTrendRow"> |
| | | SELECT |
| | | DATE(l.entry_time) AS statDate, |
| | | COUNT(DISTINCT l.app_user_id) AS activeEnterUserCount |
| | | FROM tcm_app_user_entry_log l |
| | | INNER JOIN tcm_app_user u ON u.id = l.app_user_id AND u.deleted = 0 |
| | | WHERE l.deleted = 0 |
| | | AND l.entry_time <![CDATA[>=]]> #{startTime} |
| | | AND l.entry_time <![CDATA[<=]]> #{endTime} |
| | | <include refid="AppUserTypeFilter"> |
| | | <property name="alias" value="u"/> |
| | | </include> |
| | | GROUP BY DATE(l.entry_time) |
| | | ORDER BY DATE(l.entry_time) ASC |
| | | </select> |
| | | |
| | | <select id="selectMemberComposition" resultType="com.tcm.training.admin.statistics.row.UserStatisticsCompositionRow"> |
| | | SELECT |
| | | COALESCE(( |
| | | SELECT COUNT(1) |
| | | FROM tcm_member_purchase_order o |
| | | INNER JOIN tcm_app_user u ON u.id = o.app_user_id AND u.deleted = 0 |
| | | WHERE o.deleted = 0 |
| | | AND o.status IN ('PAID', 'REFUNDED') |
| | | AND o.paid_at IS NOT NULL |
| | | AND o.paid_at <![CDATA[>=]]> #{startTime} |
| | | AND o.paid_at <![CDATA[<=]]> #{endTime} |
| | | AND o.member_type = 'MONTH_CARD' |
| | | <include refid="AppUserTypeFilter"> |
| | | <property name="alias" value="u"/> |
| | | </include> |
| | | ), 0) AS monthMemberPurchaseCount, |
| | | COALESCE(( |
| | | SELECT COUNT(1) |
| | | FROM tcm_member_purchase_order o |
| | | INNER JOIN tcm_app_user u ON u.id = o.app_user_id AND u.deleted = 0 |
| | | WHERE o.deleted = 0 |
| | | AND o.status IN ('PAID', 'REFUNDED') |
| | | AND o.paid_at IS NOT NULL |
| | | AND o.paid_at <![CDATA[>=]]> #{startTime} |
| | | AND o.paid_at <![CDATA[<=]]> #{endTime} |
| | | AND o.member_type = 'QUARTER_CARD' |
| | | <include refid="AppUserTypeFilter"> |
| | | <property name="alias" value="u"/> |
| | | </include> |
| | | ), 0) AS quarterMemberPurchaseCount, |
| | | COALESCE(( |
| | | SELECT COUNT(DISTINCT o.app_user_id) |
| | | FROM tcm_course_purchase_order o |
| | | INNER JOIN tcm_app_user u ON u.id = o.app_user_id AND u.deleted = 0 |
| | | WHERE o.deleted = 0 |
| | | AND o.status IN ('PAID', 'REFUNDED') |
| | | AND o.paid_at IS NOT NULL |
| | | AND o.paid_at <![CDATA[>=]]> #{startTime} |
| | | AND o.paid_at <![CDATA[<=]]> #{endTime} |
| | | <include refid="AppUserTypeFilter"> |
| | | <property name="alias" value="u"/> |
| | | </include> |
| | | ), 0) AS courseMemberUserCount |
| | | FROM DUAL |
| | | </select> |
| | | |
| | | </mapper> |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | 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', |
| | |
| | | !!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'] || |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | import { request } from '@umijs/max'; |
| | | |
| | | export async function getUserStatistics(params?: Record<string, unknown>) { |
| | | return request('/user-statistics', { |
| | | method: 'GET', |
| | | params, |
| | | }); |
| | | } |
| New file |
| | |
| | | .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; |
| | | } |
| | | } |