“mitao”
2026-03-24 a803908789fe606fc35d36026f57559052130231
feat:课程分类管理、养身课程
21个文件已修改
903 ■■■■■ 已修改文件
backend/sql/init.sql 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/controller/LiveRecordController.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/mapper/LiveWatchRecordMapper.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/service/LiveRecordAppService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/mapper/LiveWatchRecordMapper.xml 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/config/routes.ts 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/access.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/broadcast/detail.tsx 153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/broadcast/style.less 270 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/reserve-records/index.tsx 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/watch-records/index.tsx 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/user/detail-page.tsx 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/user/list-page.tsx 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/user/modals.tsx 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/user/style.less 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/trade/points-exchange/index.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/trade/points-exchange/items.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/trade/points-exchange/style.less 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/trade/points-ledger/index.tsx 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/trade/points-ledger/style.less 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/sql/init.sql
@@ -136,12 +136,26 @@
(3077, 3060, '推送开播提醒', 'live_session_push_notification', 'B', NULL, NULL, NULL, 17, 1, 1, 'live:session:push-notification', '直播场次推送提醒按钮', 0),
(3080, 3050, '直播预约记录', 'live_reserve_view', 'C', '/live/course/reserve-records', 'Live/ReserveRecords', NULL, 20, 1, 1, 'live:reserve:view', '直播预约记录菜单', 0),
(3081, 3050, '直播观看记录', 'live_watch_view', 'C', '/live/course/watch-records', 'Live/WatchRecords', NULL, 21, 1, 1, 'live:watch:view', '直播观看记录菜单', 0),
(3090, 3050, '分类管理', 'live_category_manage', 'C', '/live/course/category', 'Live/CategoryList', NULL, 22, 1, 1, 'live:category:view', '分类管理菜单', 0),
(3091, 3090, '新增分类', 'live_category_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'live:category:add', '分类新增按钮', 0),
(3092, 3090, '编辑分类', 'live_category_edit', 'B', NULL, NULL, NULL, 2, 1, 1, 'live:category:edit', '分类编辑按钮', 0),
(3093, 3090, '删除分类', 'live_category_delete', 'B', NULL, NULL, NULL, 3, 1, 1, 'live:category:delete', '分类删除按钮', 0),
(3082, 3060, '答题记录', 'live_quiz_view', 'B', NULL, NULL, NULL, 22, 1, 1, 'live:quiz:view', '直播答题记录查看权限', 0),
(3100, 3050, '授课专家管理', 'live_expert_manage', 'C', '/live/course/expert', 'Live/ExpertList', NULL, 30, 1, 1, 'live:expert:view', '授课专家管理菜单', 0),
(3101, 3100, '新增授课专家', 'live_expert_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'live:expert:add', '授课专家新增按钮', 0),
(3102, 3100, '编辑授课专家', 'live_expert_edit', 'B', NULL, NULL, NULL, 2, 1, 1, 'live:expert:edit', '授课专家编辑按钮', 0),
(3103, 3100, '启用禁用授课专家', 'live_expert_status', 'B', NULL, NULL, NULL, 3, 1, 1, 'live:expert:status', '授课专家状态按钮', 0),
(3104, 3100, '删除授课专家', 'live_expert_delete', 'B', NULL, NULL, NULL, 4, 1, 1, 'live:expert:delete', '授课专家删除按钮', 0),
(3110, 3050, '养身课程', 'live_health_course_manage', 'C', '/live/course/health', 'Live/HealthCourseList', NULL, 40, 1, 1, 'live:health-course:view', '养身课程菜单', 0),
(3111, 3110, '养身课程详情', 'live_health_course_detail', 'B', NULL, NULL, NULL, 1, 1, 1, 'live:health-course:detail', '养身课程详情按钮', 0),
(3112, 3110, '新增养身课程', 'live_health_course_add', 'B', NULL, NULL, NULL, 2, 1, 1, 'live:health-course:add', '养身课程新增按钮', 0),
(3113, 3110, '编辑养身课程', 'live_health_course_edit', 'B', NULL, NULL, NULL, 3, 1, 1, 'live:health-course:edit', '养身课程编辑按钮', 0),
(3114, 3110, '删除养身课程', 'live_health_course_delete', 'B', NULL, NULL, NULL, 4, 1, 1, 'live:health-course:delete', '养身课程删除按钮', 0),
(3115, 3110, '启用禁用养身课程', 'live_health_course_status', 'B', NULL, NULL, NULL, 5, 1, 1, 'live:health-course:status', '养身课程状态按钮', 0),
(3116, 3110, '导出养身课程', 'live_health_course_export', 'B', NULL, NULL, NULL, 6, 1, 1, 'live:health-course:export', '养身课程导出按钮', 0),
(3117, 3110, '新增视频课程', 'live_health_course_video_add', 'B', NULL, NULL, NULL, 7, 1, 1, 'live:health-course:video:add', '养身课程视频新增按钮', 0),
(3118, 3110, '编辑视频课程', 'live_health_course_video_edit', 'B', NULL, NULL, NULL, 8, 1, 1, 'live:health-course:video:edit', '养身课程视频编辑按钮', 0),
(3119, 3110, '删除视频课程', 'live_health_course_video_delete', 'B', NULL, NULL, NULL, 9, 1, 1, 'live:health-course:video:delete', '养身课程视频删除按钮', 0),
(4000, 0, '交易与财务', 'trade_root', 'M', '/trade', NULL, 'moneyCollect', 400, 1, 1, NULL, '交易与财务模块', 0),
(4100, 4000, '订单', 'trade_order_group', 'M', '/trade/order', NULL, NULL, 10, 1, 1, NULL, '订单目录', 0),
(4110, 4100, '积分兑换', 'trade_points_exchange', 'C', '/trade/order/points-exchange', 'Trade/PointsExchange', NULL, 10, 1, 1, 'points:exchange:view', '积分兑换菜单', 0),
@@ -161,8 +175,9 @@
(1, 2200), (1, 2210), (1, 2211), (1, 2220), (1, 2221),
(1, 3000), (1, 3050), (1, 3060), (1, 3061), (1, 3062), (1, 3063),
(1, 3070), (1, 3071), (1, 3072), (1, 3073), (1, 3074), (1, 3075), (1, 3076), (1, 3077),
(1, 3080), (1, 3081), (1, 3082),
(1, 3080), (1, 3081), (1, 3082), (1, 3090), (1, 3091), (1, 3092), (1, 3093),
(1, 3100), (1, 3101), (1, 3102), (1, 3103), (1, 3104),
(1, 3110), (1, 3111), (1, 3112), (1, 3113), (1, 3114), (1, 3115), (1, 3116), (1, 3117), (1, 3118), (1, 3119),
(1, 4000), (1, 4100), (1, 4110), (1, 4111), (1, 4112), (1, 4113), (1, 4114),
(1, 4200), (1, 4210), (1, 4211);
@@ -319,6 +334,107 @@
-- ----------------------------
-- 养生直播:课程
-- ----------------------------
CREATE TABLE IF NOT EXISTS `tcm_live_course_category` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '分类ID',
  `type` VARCHAR(32) NOT NULL COMMENT '分类类型:LEVEL/DIFFICULTY',
  `name` VARCHAR(32) NOT NULL COMMENT '分类名称',
  `sort` INT NOT NULL DEFAULT 0 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_type_sort` (`type`, `sort`),
  KEY `idx_type_name` (`type`, `name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='直播课程分类表';
INSERT IGNORE INTO `tcm_live_course_category` (`id`, `type`, `name`, `sort`, `deleted`)
VALUES
(1, 'LEVEL', '初级', 10, 0),
(2, 'LEVEL', '中级', 20, 0),
(3, 'LEVEL', '高级', 30, 0),
(4, 'DIFFICULTY', '简单', 10, 0),
(5, 'DIFFICULTY', '普通', 20, 0),
(6, 'DIFFICULTY', '困难', 30, 0);
-- ----------------------------
-- 养身课程
-- ----------------------------
CREATE TABLE IF NOT EXISTS `tcm_health_course` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '养身课程ID',
  `title` VARCHAR(128) NOT NULL COMMENT '课程名称',
  `level_category_id` BIGINT UNSIGNED NOT NULL COMMENT '养身等级分类ID',
  `difficulty_category_id` BIGINT UNSIGNED NOT NULL COMMENT '难度分类ID',
  `expert_id` BIGINT UNSIGNED NOT NULL COMMENT '授课专家ID',
  `sale_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '课程售价',
  `summary` VARCHAR(500) DEFAULT NULL COMMENT '课程简介',
  `target_users` VARCHAR(255) DEFAULT NULL COMMENT '适用人群',
  `cover_img` VARCHAR(255) DEFAULT NULL COMMENT '课程封面图URL',
  `banner_imgs` JSON DEFAULT NULL COMMENT '课程宣传图URL列表(多图)',
  `content` LONGTEXT DEFAULT NULL COMMENT '课程介绍(富文本)',
  `base_sales_count` INT NOT NULL DEFAULT 0 COMMENT '基础销量',
  `real_sales_count` INT NOT NULL DEFAULT 0 COMMENT '真实销量',
  `learn_user_count` INT NOT NULL DEFAULT 0 COMMENT '累计学习人数',
  `favorite_count` INT NOT NULL DEFAULT 0 COMMENT '收藏量',
  `total_duration_seconds` INT NOT NULL DEFAULT 0 COMMENT '课程总时长(秒)',
  `video_count` INT NOT NULL DEFAULT 0 COMMENT '视频小节数',
  `status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态:1启用 0禁用',
  `is_home_recommend` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '首页推荐:1是 0否',
  `sort` INT NOT NULL DEFAULT 0 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_health_course_level` (`level_category_id`),
  KEY `idx_health_course_difficulty` (`difficulty_category_id`),
  KEY `idx_health_course_expert` (`expert_id`),
  KEY `idx_health_course_status` (`status`),
  KEY `idx_health_course_home_recommend` (`is_home_recommend`),
  KEY `idx_health_course_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='养身课程表';
INSERT IGNORE INTO `tcm_health_course`
(`id`, `title`, `level_category_id`, `difficulty_category_id`, `expert_id`, `sale_price`, `summary`, `target_users`, `cover_img`, `banner_imgs`, `content`, `base_sales_count`, `real_sales_count`, `learn_user_count`, `favorite_count`, `total_duration_seconds`, `video_count`, `status`, `is_home_recommend`, `sort`, `deleted`)
VALUES
(1, '中医针灸资格证', 1, 4, 10, 599.80, '面向针灸入门与基础提升的系统课程。', '针灸初学者、养身爱好者',
 'https://example.com/health/course-1-cover.png',
 JSON_ARRAY('https://example.com/health/course-1-banner-1.png', 'https://example.com/health/course-1-banner-2.png', 'https://example.com/health/course-1-banner-3.png'),
 '<p>课程介绍:覆盖针灸基础理论、常见穴位与实操思路。</p>',
 120, 33, 358, 35, 1118, 5, 1, 1, 10, 0);
CREATE TABLE IF NOT EXISTS `tcm_health_course_video` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '视频小节ID',
  `course_id` BIGINT UNSIGNED NOT NULL COMMENT '所属养身课程ID',
  `chapter_name` VARCHAR(128) NOT NULL COMMENT '小节名称',
  `chapter_remark` VARCHAR(500) DEFAULT NULL COMMENT '小节备注',
  `video_url` VARCHAR(512) DEFAULT NULL COMMENT '视频地址',
  `duration_seconds` INT NOT NULL DEFAULT 0 COMMENT '视频时长(秒)',
  `sale_type` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '售价类型:0免费 1收费',
  `sale_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '小节售价',
  `play_count` INT NOT NULL DEFAULT 0 COMMENT '播放次数',
  `sort` INT NOT NULL DEFAULT 0 COMMENT '排序值',
  `status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态:1启用 0禁用',
  `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_health_course_video_course_sort` (`course_id`, `sort`),
  KEY `idx_health_course_video_status` (`course_id`, `status`),
  KEY `idx_health_course_video_sale_type` (`sale_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='养身课程视频小节表';
INSERT IGNORE INTO `tcm_health_course_video`
(`id`, `course_id`, `chapter_name`, `chapter_remark`, `video_url`, `duration_seconds`, `sale_type`, `sale_price`, `play_count`, `sort`, `status`, `deleted`)
VALUES
(1, 1, '方剂学', '赠送添加视频课程页,需自动传入课程', 'https://example.com/health/course-1-video-1.mp4', 238, 0, 0.00, 358, 10, 1, 0),
(2, 1, '药剂学', '系统课程配套讲解', 'https://example.com/health/course-1-video-2.mp4', 238, 1, 5999.99, 358, 20, 1, 0),
(3, 1, '方剂学进阶', '面向基础学习后的专题补充', 'https://example.com/health/course-1-video-3.mp4', 642, 1, 5999.99, 358, 30, 1, 0);
CREATE TABLE IF NOT EXISTS `tcm_live_course` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '直播课程ID',
  `title` VARCHAR(128) NOT NULL COMMENT '课程名称',
backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
@@ -189,6 +189,14 @@
                "/live/course/reserve-records", "Live/ReserveRecords", null, 20, "live:reserve:view", "直播预约记录菜单"));
        menuIds.add(saveMenu(3081L, 3050L, "直播观看记录", "live_watch_view", "C",
                "/live/course/watch-records", "Live/WatchRecords", null, 21, "live:watch:view", "直播观看记录菜单"));
        menuIds.add(saveMenu(3090L, 3050L, "分类管理", "live_category_manage", "C",
                "/live/course/category", "Live/CategoryList", null, 22, "live:category:view", "分类管理菜单"));
        menuIds.add(saveMenu(3091L, 3090L, "新增分类", "live_category_add", "B",
                null, null, null, 1, "live:category:add", "分类新增按钮"));
        menuIds.add(saveMenu(3092L, 3090L, "编辑分类", "live_category_edit", "B",
                null, null, null, 2, "live:category:edit", "分类编辑按钮"));
        menuIds.add(saveMenu(3093L, 3090L, "删除分类", "live_category_delete", "B",
                null, null, null, 3, "live:category:delete", "分类删除按钮"));
        menuIds.add(saveMenu(3082L, 3060L, "答题记录", "live_quiz_view", "B",
                null, null, null, 22, "live:quiz:view", "直播答题记录查看权限"));
        menuIds.add(saveMenu(3100L, 3050L, "授课专家管理", "live_expert_manage", "C",
@@ -201,6 +209,26 @@
                null, null, null, 3, "live:expert:status", "授课专家状态按钮"));
        menuIds.add(saveMenu(3104L, 3100L, "删除授课专家", "live_expert_delete", "B",
                null, null, null, 4, "live:expert:delete", "授课专家删除按钮"));
        menuIds.add(saveMenu(3110L, 3050L, "养身课程", "live_health_course_manage", "C",
                "/live/course/health", "Live/HealthCourseList", null, 40, "live:health-course:view", "养身课程菜单"));
        menuIds.add(saveMenu(3111L, 3110L, "养身课程详情", "live_health_course_detail", "B",
                null, null, null, 1, "live:health-course:detail", "养身课程详情按钮"));
        menuIds.add(saveMenu(3112L, 3110L, "新增养身课程", "live_health_course_add", "B",
                null, null, null, 2, "live:health-course:add", "养身课程新增按钮"));
        menuIds.add(saveMenu(3113L, 3110L, "编辑养身课程", "live_health_course_edit", "B",
                null, null, null, 3, "live:health-course:edit", "养身课程编辑按钮"));
        menuIds.add(saveMenu(3114L, 3110L, "删除养身课程", "live_health_course_delete", "B",
                null, null, null, 4, "live:health-course:delete", "养身课程删除按钮"));
        menuIds.add(saveMenu(3115L, 3110L, "启用禁用养身课程", "live_health_course_status", "B",
                null, null, null, 5, "live:health-course:status", "养身课程状态按钮"));
        menuIds.add(saveMenu(3116L, 3110L, "导出养身课程", "live_health_course_export", "B",
                null, null, null, 6, "live:health-course:export", "养身课程导出按钮"));
        menuIds.add(saveMenu(3117L, 3110L, "新增视频课程", "live_health_course_video_add", "B",
                null, null, null, 7, "live:health-course:video:add", "养身课程视频新增按钮"));
        menuIds.add(saveMenu(3118L, 3110L, "编辑视频课程", "live_health_course_video_edit", "B",
                null, null, null, 8, "live:health-course:video:edit", "养身课程视频编辑按钮"));
        menuIds.add(saveMenu(3119L, 3110L, "删除视频课程", "live_health_course_video_delete", "B",
                null, null, null, 9, "live:health-course:video:delete", "养身课程视频删除按钮"));
        menuIds.add(saveMenu(4000L, 0L, "交易与财务", "trade_root", "M",
                "/trade", null, "moneyCollect", 400, null, "交易与财务模块"));
        menuIds.add(saveMenu(4100L, 4000L, "订单", "trade_order_group", "M",
backend/src/main/java/com/tcm/training/admin/live/controller/LiveRecordController.java
@@ -86,6 +86,7 @@
                                                           @RequestParam(required = false, defaultValue = "10") int pageSize,
                                                           @RequestParam(required = false) Long courseId,
                                                           @RequestParam(required = false) Long sessionId,
                                                           @RequestParam(required = false) String regionCode,
                                                           @RequestParam(required = false) String clinicName,
                                                           @RequestParam(required = false) String phone,
                                                           @RequestParam(required = false) String courseName,
@@ -95,7 +96,7 @@
                                                           @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime watchFrom,
                                                           @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime watchTo) {
        return ApiResponse.ok(liveRecordAppService.pageGlobalWatchRecords(
                page, pageSize, courseId, sessionId, clinicName, phone, courseName, hasReserve, isCompleted, isEffective, watchFrom, watchTo
                page, pageSize, courseId, sessionId, regionCode, clinicName, phone, courseName, hasReserve, isCompleted, isEffective, watchFrom, watchTo
        ));
    }
@@ -103,6 +104,7 @@
    public void exportWatchRecords(HttpServletResponse response,
                                   @RequestParam(required = false) Long courseId,
                                   @RequestParam(required = false) Long sessionId,
                                   @RequestParam(required = false) String regionCode,
                                   @RequestParam(required = false) String clinicName,
                                   @RequestParam(required = false) String phone,
                                   @RequestParam(required = false) String courseName,
@@ -111,7 +113,7 @@
                                   @RequestParam(required = false) Integer isEffective,
                                   @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime watchFrom,
                                   @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime watchTo) throws IOException {
        liveRecordAppService.exportGlobalWatchRecords(response, courseId, sessionId, clinicName, phone, courseName, hasReserve, isCompleted, isEffective, watchFrom, watchTo);
        liveRecordAppService.exportGlobalWatchRecords(response, courseId, sessionId, regionCode, clinicName, phone, courseName, hasReserve, isCompleted, isEffective, watchFrom, watchTo);
    }
    @GetMapping("/live-courses/{courseId}/sessions/{sessionId}/quiz-records")
backend/src/main/java/com/tcm/training/admin/live/mapper/LiveWatchRecordMapper.java
@@ -25,6 +25,7 @@
    IPage<LiveWatchRow> selectGlobalWatchPage(IPage<LiveWatchRow> page,
                                              @Param("courseId") Long courseId,
                                              @Param("sessionId") Long sessionId,
                                              @Param("regionCode") String regionCode,
                                              @Param("clinicName") String clinicName,
                                              @Param("phone") String phone,
                                              @Param("courseName") String courseName,
@@ -36,6 +37,7 @@
    List<LiveWatchRow> selectGlobalWatchList(@Param("courseId") Long courseId,
                                             @Param("sessionId") Long sessionId,
                                             @Param("regionCode") String regionCode,
                                             @Param("clinicName") String clinicName,
                                             @Param("phone") String phone,
                                             @Param("courseName") String courseName,
@@ -47,6 +49,7 @@
    LiveWatchSummaryRow selectGlobalWatchSummary(@Param("courseId") Long courseId,
                                                 @Param("sessionId") Long sessionId,
                                                 @Param("regionCode") String regionCode,
                                                 @Param("clinicName") String clinicName,
                                                 @Param("phone") String phone,
                                                 @Param("courseName") String courseName,
backend/src/main/java/com/tcm/training/admin/live/service/LiveRecordAppService.java
@@ -155,6 +155,7 @@
                                                  int pageSize,
                                                  Long courseId,
                                                  Long sessionId,
                                                  String regionCode,
                                                  String clinicName,
                                                  String phone,
                                                  String courseName,
@@ -167,6 +168,7 @@
                new Page<LiveWatchRow>(page, pageSize),
                courseId,
                sessionId,
                trimToNull(regionCode),
                trimToNull(clinicName),
                trimToNull(phone),
                trimToNull(courseName),
@@ -181,13 +183,14 @@
        vo.setTotal(result.getTotal());
        vo.setPage(result.getCurrent());
        vo.setPageSize(result.getSize());
        vo.setSummary(buildWatchSummary(courseId, sessionId, clinicName, phone, courseName, hasReserve, isCompleted, isEffective, watchFrom, watchTo));
        vo.setSummary(buildWatchSummary(courseId, sessionId, regionCode, clinicName, phone, courseName, hasReserve, isCompleted, isEffective, watchFrom, watchTo));
        return vo;
    }
    public void exportGlobalWatchRecords(HttpServletResponse response,
                                         Long courseId,
                                         Long sessionId,
                                         String regionCode,
                                         String clinicName,
                                         String phone,
                                         String courseName,
@@ -204,6 +207,7 @@
        List<LiveWatchRow> rows = liveWatchRecordMapper.selectGlobalWatchList(
                courseId,
                sessionId,
                trimToNull(regionCode),
                trimToNull(clinicName),
                trimToNull(phone),
                trimToNull(courseName),
@@ -345,6 +349,7 @@
    private LiveWatchSummaryVO buildWatchSummary(Long courseId,
                                                 Long sessionId,
                                                 String regionCode,
                                                 String clinicName,
                                                 String phone,
                                                 String courseName,
@@ -356,6 +361,7 @@
        LiveWatchSummaryRow row = liveWatchRecordMapper.selectGlobalWatchSummary(
                courseId,
                sessionId,
                trimToNull(regionCode),
                trimToNull(clinicName),
                trimToNull(phone),
                trimToNull(courseName),
backend/src/main/resources/mapper/LiveWatchRecordMapper.xml
@@ -23,7 +23,15 @@
        u.phone AS userPhone,
        u.avatar AS userAvatar,
        cl.name AS clinicName,
        NULL AS clinicArea,
        CASE
            WHEN cl.province_name IS NOT NULL AND cl.province_name != '' THEN
                CONCAT(
                    cl.province_name,
                    IFNULL(cl.city_name, ''),
                    IFNULL(cl.district_name, '')
                )
            ELSE NULL
        END AS clinicArea,
        c.title AS courseTitle,
        NULL AS categoryName,
        s.start_time AS startTime,
@@ -51,6 +59,13 @@
        <if test="sessionId != null">
            AND w.session_id = #{sessionId}
        </if>
        <if test="regionCode != null and regionCode.trim() != ''">
            AND (
                cl.province_code = #{regionCode}
                OR cl.city_code = #{regionCode}
                OR cl.district_code = #{regionCode}
            )
        </if>
        <if test="clinicName != null and clinicName.trim() != ''">
            AND cl.name LIKE CONCAT('%', #{clinicName}, '%')
        </if>
tcm-training-admin/config/routes.ts
@@ -226,6 +226,53 @@
            component: './live/watch-records',
          },
          {
            name: '分类管理',
            path: '/live/course/category',
            access: 'live:category:view',
            component: './live/category',
          },
          {
            name: '养身课程',
            path: '/live/course/health',
            access: 'live:health-course:view',
            component: './live/health',
          },
          {
            name: '新增养身课程',
            path: '/live/course/health/create',
            hideInMenu: true,
            access: 'live:health-course:add',
            component: './live/health/manage',
          },
          {
            name: '养身课程详情',
            path: '/live/course/health/:id',
            hideInMenu: true,
            access: 'live:health-course:detail',
            component: './live/health/detail',
          },
          {
            name: '编辑养身课程',
            path: '/live/course/health/:id/edit',
            hideInMenu: true,
            access: 'live:health-course:edit',
            component: './live/health/manage',
          },
          {
            name: '新增视频课程',
            path: '/live/course/health/:courseId/videos/create',
            hideInMenu: true,
            access: 'live:health-course:video:add',
            component: './live/health/video-manage',
          },
          {
            name: '编辑视频课程',
            path: '/live/course/health/:courseId/videos/:videoId/edit',
            hideInMenu: true,
            access: 'live:health-course:video:edit',
            component: './live/health/video-manage',
          },
          {
            name: '直播预约记录跳转',
            path: '/live/course/broadcast/:courseId/sessions/:sessionId/reserves',
            hideInMenu: true,
tcm-training-admin/src/access.js
@@ -20,6 +20,8 @@
    canViewMemberVipGroup,
    canViewLiveModule:
      !!permission?.['live:course:view'] ||
      !!permission?.['live:health-course:view'] ||
      !!permission?.['live:category:view'] ||
      !!permission?.['live:reserve:view'] ||
      !!permission?.['live:watch:view'] ||
      !!permission?.['live:expert:view'],
tcm-training-admin/src/pages/live/broadcast/detail.tsx
@@ -406,6 +406,7 @@
      <div className="live-broadcast-page__panel">
        <div className="live-broadcast-page__title">养身直播详情</div>
        <Tabs
          className="live-broadcast-detail-tabs"
          defaultActiveKey="info"
          items={[
            {
@@ -413,35 +414,32 @@
              label: '课程信息',
              children: (
                <div className="live-broadcast-detail">
                  <div className="live-broadcast-detail__hero">
                    <div className="live-broadcast-detail__cover">
                      {course?.coverImg ? (
                        <Image src={course.coverImg} width={220} height={132} preview={false} />
                      ) : null}
                    </div>
                    <div className="live-broadcast-detail__summary">
                      <div className="live-broadcast-detail__title-row">
                        <h2>{course?.title || '-'}</h2>
                        <Tag
                          color={(courseStatusMap[course?.status || 0] || courseStatusMap[0]).color}
                        >
                          {(courseStatusMap[course?.status || 0] || courseStatusMap[0]).text}
                        </Tag>
                  <div className="live-broadcast-detail__header-card">
                    <div className="live-broadcast-detail__hero">
                      <div className="live-broadcast-detail__cover">
                        {course?.coverImg ? (
                          <Image src={course.coverImg} width={220} height={132} preview={false} />
                        ) : (
                          <div className="live-broadcast-detail__cover-placeholder">暂无封面</div>
                        )}
                      </div>
                      <div className="live-broadcast-detail__stats">
                        <span>累计预约人数:{course?.reserveCount || 0}</span>
                        <span>累计观看人数:{course?.watchCount || 0}</span>
                      <div className="live-broadcast-detail__summary">
                        <div className="live-broadcast-detail__title-row">
                          <h2>{course?.title || '-'}</h2>
                        </div>
                        <div className="live-broadcast-detail__stats">
                          <span>累计预约人数:{course?.reserveCount || 0}人</span>
                          <span>累计观看人数:{course?.watchCount || 0}人</span>
                        </div>
                      </div>
                      <div className="live-broadcast-detail__actions">
                        {course?.liveStatus === 1 && course?.videoUrl ? (
                          <Button href={course.videoUrl} target="_blank">
                          <Button type="primary" href={course.videoUrl} target="_blank">
                            观看直播
                          </Button>
                        ) : null}
                        {canStatus ? (
                          <Button onClick={handleToggleCourseStatus}>
                            {course?.status === 1 ? '禁用' : '启用'}
                          </Button>
                          <Button onClick={handleToggleCourseStatus}>禁用/启用</Button>
                        ) : null}
                        {canEdit ? (
                          <Button
@@ -454,43 +452,79 @@
                      </div>
                    </div>
                  </div>
                  <Descriptions bordered column={2} className="live-broadcast-detail__desc">
                    <Descriptions.Item label="授课专家">
                      <div className="live-broadcast-detail__expert">
                        {course?.expert?.avatar ? (
                          <Image
                            src={course.expert.avatar}
                            width={40}
                            height={40}
                            preview={false}
                  <div className="live-broadcast-detail__content-card">
                    <div className="live-broadcast-detail__field">
                      <div className="live-broadcast-detail__label">授课专家:</div>
                      <div className="live-broadcast-detail__value">
                        <div className="live-broadcast-detail__expert">
                          {course?.expert?.avatar ? (
                            <Image
                              src={course.expert.avatar}
                              width={32}
                              height={32}
                              preview={false}
                            />
                          ) : (
                            <div className="live-broadcast-detail__expert-avatar-placeholder">讲</div>
                          )}
                          <span>{course?.expert?.name || '-'}</span>
                        </div>
                      </div>
                    </div>
                    <div className="live-broadcast-detail__field">
                      <div className="live-broadcast-detail__label">开播时间:</div>
                      <div className="live-broadcast-detail__value">{course?.startTime || '-'}</div>
                    </div>
                    <div className="live-broadcast-detail__field">
                      <div className="live-broadcast-detail__label">可答题时间:</div>
                      <div className="live-broadcast-detail__value">
                        {course?.quizDelayMinutes ? `直播后${course.quizDelayMinutes}分钟` : '-'}
                      </div>
                    </div>
                    <div className="live-broadcast-detail__field">
                      <div className="live-broadcast-detail__label">答题可得积分:</div>
                      <div className="live-broadcast-detail__value">
                        {course?.quizPoints ? `${course.quizPoints}积分` : '0积分'}
                      </div>
                    </div>
                    <div className="live-broadcast-detail__field">
                      <div className="live-broadcast-detail__label">适用人群:</div>
                      <div className="live-broadcast-detail__value">{course?.targetUsers || '-'}</div>
                    </div>
                    <div className="live-broadcast-detail__field">
                      <div className="live-broadcast-detail__label">状态:</div>
                      <div className="live-broadcast-detail__value">
                        {(courseStatusMap[course?.status || 0] || courseStatusMap[0]).text}
                      </div>
                    </div>
                    <div className="live-broadcast-detail__field live-broadcast-detail__field--full">
                      <div className="live-broadcast-detail__label">课程宣传图:</div>
                      <div className="live-broadcast-detail__value">
                        <div className="live-broadcast-detail__banners">
                          {(course?.bannerImgs || []).length ? (
                            (course?.bannerImgs || []).map((item) => (
                              <Image key={item} src={item} width={98} height={55} preview={false} />
                            ))
                          ) : (
                            <span>-</span>
                          )}
                        </div>
                      </div>
                    </div>
                    <div className="live-broadcast-detail__field live-broadcast-detail__field--full">
                      <div className="live-broadcast-detail__label">课程介绍:</div>
                      <div className="live-broadcast-detail__value">
                        {course?.intro ? (
                          <div
                            className="live-broadcast-detail__intro"
                            dangerouslySetInnerHTML={{ __html: course.intro }}
                          />
                        ) : null}
                        <span>{course?.expert?.name || '-'}</span>
                        ) : (
                          '-'
                        )}
                      </div>
                    </Descriptions.Item>
                    <Descriptions.Item label="开播时间">
                      {course?.startTime || '-'}
                    </Descriptions.Item>
                    <Descriptions.Item label="可答题时间">
                      {course?.quizDelayMinutes ? `直播后${course.quizDelayMinutes}分钟` : '-'}
                    </Descriptions.Item>
                    <Descriptions.Item label="答题可得积分">
                      {course?.quizPoints ? `${course.quizPoints}积分` : '0积分'}
                    </Descriptions.Item>
                    <Descriptions.Item label="适用人群" span={2}>
                      {course?.targetUsers || '-'}
                    </Descriptions.Item>
                    <Descriptions.Item label="课程介绍" span={2}>
                      <div dangerouslySetInnerHTML={{ __html: course?.intro || '' }} />
                    </Descriptions.Item>
                    <Descriptions.Item label="课程宣传图" span={2}>
                      <div className="live-broadcast-detail__banners">
                        {(course?.bannerImgs || []).map((item) => (
                          <Image key={item} src={item} width={180} height={96} preview={false} />
                        ))}
                      </div>
                    </Descriptions.Item>
                  </Descriptions>
                    </div>
                  </div>
                </div>
              ),
            },
@@ -522,7 +556,7 @@
                          ]}
                        />
                      </Form.Item>
                      <div className="live-broadcast-search-form__actions">
                      <div className="live-broadcast-search-form__actions live-broadcast-search-form__actions--right">
                        <Button
                          onClick={async () => {
                            filterForm.resetFields();
@@ -537,7 +571,7 @@
                      </div>
                    </Form>
                  </div>
                  <div className="live-broadcast-toolbar">
                  <div className="live-broadcast-toolbar live-broadcast-schedule__toolbar">
                    {canSessionAdd ? (
                      <Button type="primary" onClick={() => openCreateSessionModal()}>
                        添加场次
@@ -561,13 +595,14 @@
                      </Button>
                    ) : null}
                  </div>
                  <div className="live-broadcast-table-card">
                  <div className="live-broadcast-table-card live-broadcast-schedule__table-card">
                    <Table<SessionItem>
                      rowKey="id"
                      loading={sessionLoading}
                      columns={sessionColumns}
                      dataSource={sessions}
                      pagination={false}
                      scroll={{ x: 1360 }}
                    />
                    <div className="live-broadcast-pagination">
                      <Pagination
tcm-training-admin/src/pages/live/broadcast/style.less
@@ -15,7 +15,7 @@
  margin-bottom: 12px;
  color: #303133;
  font-weight: 500;
  font-size: 20px;
  font-size: 22px;
}
.live-broadcast-page__hint {
@@ -27,18 +27,58 @@
.live-broadcast-table-card,
.live-broadcast-search-card {
  background: #fff;
  border: 1px solid #e7edf4;
  border-radius: 6px;
  border: 1px solid #dfe6ee;
  border-radius: 8px;
}
.live-broadcast-table-card {
  overflow: hidden;
}
.live-broadcast-detail-tabs > .ant-tabs-nav {
  margin-bottom: 10px;
}
.live-broadcast-detail-tabs > .ant-tabs-nav::before {
  border-bottom-color: #dfe6ee;
}
.live-broadcast-detail-tabs .ant-tabs-tab {
  min-width: 112px;
  padding: 6px 24px;
  color: #4096ff;
  background: #fff;
  border: 1px solid #4096ff;
  border-radius: 2px 2px 0 0;
}
.live-broadcast-detail-tabs .ant-tabs-tab + .ant-tabs-tab {
  margin-left: 0;
}
.live-broadcast-detail-tabs .ant-tabs-tab-active {
  background: #1677ff;
}
.live-broadcast-detail-tabs .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
  color: #fff;
}
.live-broadcast-detail-tabs .ant-tabs-ink-bar {
  display: none;
}
.live-broadcast-table-card .ant-table-thead > tr > th {
  padding-top: 12px;
  padding-bottom: 12px;
  color: #303133;
  font-weight: 500;
  background: #f8fafc;
  white-space: nowrap;
  background: #f5f7fa;
}
.live-broadcast-table-card .ant-table-tbody > tr > td {
  white-space: nowrap;
}
.live-broadcast-table__cover-placeholder {
@@ -63,30 +103,24 @@
  }
}
.live-record-summary {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 12px;
  margin: 14px 0;
.live-record-metrics-bar {
  display: flex;
  gap: 14px;
  align-items: center;
  margin: 14px 0 16px;
}
.live-record-summary__card {
  padding: 14px 16px;
  background: linear-gradient(135deg, #f8fbff 0%, #ffffff 100%);
  border: 1px solid #e7edf4;
  border-radius: 8px;
.live-record-metrics-bar__items {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: center;
}
.live-record-summary__label {
  color: #64748b;
  font-size: 13px;
}
.live-record-summary__value {
  margin-top: 6px;
  color: #0f172a;
  font-weight: 600;
  font-size: 28px;
.live-record-metrics-bar__item {
  color: #606266;
  font-size: 14px;
  line-height: 22px;
}
.live-record-user {
@@ -113,7 +147,7 @@
}
.live-broadcast-search-card {
  padding: 16px 18px 10px;
  padding: 14px 18px;
}
.live-broadcast-search-form {
@@ -152,6 +186,11 @@
  min-width: 220px;
}
.live-broadcast-search-form__actions--right {
  grid-column: 3;
  justify-self: end;
}
.live-broadcast-toolbar {
  display: flex;
  gap: 12px;
@@ -167,38 +206,113 @@
.live-broadcast-detail__hero {
  display: grid;
  grid-template-columns: 220px minmax(0, 1fr);
  gap: 24px;
  margin-bottom: 20px;
  grid-template-columns: 88px minmax(0, 1fr) auto;
  gap: 18px;
  align-items: center;
}
.live-broadcast-detail__header-card,
.live-broadcast-detail__content-card {
  padding: 18px;
  background: #fff;
  border: 1px solid #e5e9f2;
  border-radius: 8px;
}
.live-broadcast-detail__content-card {
  margin-top: 8px;
}
.live-broadcast-detail__cover {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 88px;
  height: 52px;
  overflow: hidden;
  background: #f3f6fa;
  border: 1px solid #e5e9f2;
  border-radius: 4px;
}
.live-broadcast-detail__cover .ant-image,
.live-broadcast-detail__cover .ant-image-img {
  width: 100%;
  height: 100%;
}
.live-broadcast-detail__cover-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  color: #b7c0cd;
  font-size: 12px;
}
.live-broadcast-detail__title-row {
  display: flex;
  gap: 12px;
  align-items: center;
  margin-bottom: 14px;
  margin-bottom: 10px;
}
.live-broadcast-detail__title-row h2 {
  margin: 0;
  color: #303133;
  font-size: 24px;
  font-weight: 500;
  font-size: 28px;
}
.live-broadcast-detail__stats {
  display: flex;
  gap: 24px;
  margin-bottom: 18px;
  color: #606266;
  flex-wrap: wrap;
  gap: 28px;
  color: #909399;
  font-size: 14px;
}
.live-broadcast-detail__actions {
  display: flex;
  gap: 12px;
  align-items: center;
}
.live-broadcast-detail__desc {
  margin-top: 8px;
.live-broadcast-detail__actions .ant-btn {
  min-width: 78px;
  border-radius: 4px;
}
.live-broadcast-detail__actions .ant-btn-default {
  color: #1677ff;
  border-color: #91caff;
}
.live-broadcast-detail__field {
  display: flex;
  align-items: flex-start;
  margin-bottom: 14px;
}
.live-broadcast-detail__field:last-child {
  margin-bottom: 0;
}
.live-broadcast-detail__field--full {
  align-items: flex-start;
}
.live-broadcast-detail__label {
  flex: 0 0 108px;
  color: #606266;
  line-height: 32px;
}
.live-broadcast-detail__value {
  flex: 1;
  min-width: 0;
  color: #606266;
  line-height: 32px;
}
.live-broadcast-detail__expert {
@@ -207,10 +321,48 @@
  align-items: center;
}
.live-broadcast-detail__expert-avatar-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  color: #7d8da1;
  font-size: 14px;
  background: #eef2f6;
  border-radius: 50%;
}
.live-broadcast-detail__banners {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  gap: 16px;
  padding-top: 2px;
}
.live-broadcast-detail__banners .ant-image,
.live-broadcast-detail__banners .ant-image-img {
  width: 98px;
  height: 55px;
  border-radius: 2px;
}
.live-broadcast-detail__intro {
  color: #909399;
  line-height: 1.8;
}
.live-broadcast-detail__intro p {
  margin-bottom: 8px;
}
.live-broadcast-schedule__toolbar {
  justify-content: flex-start;
  margin: 16px 0 14px;
}
.live-broadcast-schedule__table-card {
  border-color: #dfe6ee;
}
.live-broadcast-edit-form__grid {
@@ -344,6 +496,24 @@
  .live-broadcast-search-form__actions {
    justify-content: flex-start;
  }
  .live-broadcast-search-form__actions--right {
    grid-column: auto;
    justify-self: stretch;
  }
  .live-broadcast-detail__hero {
    grid-template-columns: 1fr;
    align-items: flex-start;
  }
  .live-broadcast-detail__actions {
    flex-wrap: wrap;
  }
  .live-record-metrics-bar {
    align-items: flex-start;
  }
}
@media (max-width: 768px) {
@@ -358,4 +528,30 @@
  .live-broadcast-search-form .ant-form-item-row {
    grid-template-columns: 72px minmax(0, 1fr);
  }
  .live-broadcast-page__panel,
  .live-broadcast-detail__header-card,
  .live-broadcast-detail__content-card {
    padding: 14px;
  }
  .live-broadcast-detail__field {
    flex-direction: column;
    gap: 4px;
  }
  .live-broadcast-detail__label,
  .live-broadcast-detail__value {
    line-height: 1.7;
  }
  .live-broadcast-detail__label {
    flex-basis: auto;
  }
  .live-record-metrics-bar {
    flex-direction: column;
    gap: 10px;
    align-items: flex-start;
  }
}
tcm-training-admin/src/pages/live/reserve-records/index.tsx
@@ -259,7 +259,7 @@
            <Form.Item label="预约时间" name="reserveRange">
              <DatePicker.RangePicker showTime className="live-broadcast-search-form__range" />
            </Form.Item>
            <div className="live-broadcast-search-form__actions">
            <div className="live-broadcast-search-form__actions live-broadcast-search-form__actions--right">
              <Button
                onClick={async () => {
                  form.resetFields();
@@ -279,26 +279,15 @@
            </div>
          </Form>
        </div>
        <div className="live-record-summary">
          <div className="live-record-summary__card">
            <div className="live-record-summary__label">预约总数</div>
            <div className="live-record-summary__value">{summary.reserveTotal || 0}</div>
          </div>
          <div className="live-record-summary__card">
            <div className="live-record-summary__label">观看直播人数</div>
            <div className="live-record-summary__value">{summary.watchedTotal || 0}</div>
          </div>
          <div className="live-record-summary__card">
            <div className="live-record-summary__label">预约转化率</div>
            <div className="live-record-summary__value">
              {formatPercent(summary.reserveConversionRate)}
            </div>
          </div>
        </div>
        <div className="live-broadcast-toolbar">
        <div className="live-record-metrics-bar">
          <Button type="primary" onClick={handleExport}>
            导出
          </Button>
          <div className="live-record-metrics-bar__items">
            <span className="live-record-metrics-bar__item">
              预约转化率:{formatPercent(summary.reserveConversionRate)}
            </span>
          </div>
        </div>
        <div className="live-broadcast-table-card">
          <Table<ReserveItem>
tcm-training-admin/src/pages/live/watch-records/index.tsx
@@ -1,15 +1,16 @@
import { history, useAccess } from '@umijs/max';
import { Button, DatePicker, Form, Image, Input, Pagination, Select, Table } from 'antd';
import { Button, Cascader, DatePicker, Form, Image, Input, Pagination, Select, Table } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react';
import { clinicRegionOptions } from '../../member/clinic/regionOptions';
import { exportGlobalWatchRecords, getGlobalWatchRecords } from '../broadcast/service';
import '../broadcast/style.less';
type SearchValues = {
  courseId?: number;
  sessionId?: number;
  clinicAreaPlaceholder?: string;
  regionCode?: string[];
  clinicName?: string;
  phone?: string;
  courseName?: string;
@@ -45,6 +46,9 @@
  effectiveWatchRate?: number;
};
const getSelectedRegionCode = (regionCode?: string[]) =>
  regionCode && regionCode.length ? regionCode[regionCode.length - 1] : undefined;
const buildSearchParams = (values: SearchValues) => {
  const watchFrom = values.watchRange?.[0]
    ? dayjs(values.watchRange[0]).format('YYYY-MM-DD HH:mm:ss')
@@ -55,6 +59,7 @@
  return {
    courseId: values.courseId,
    sessionId: values.sessionId,
    regionCode: getSelectedRegionCode(values.regionCode),
    clinicName: values.clinicName?.trim() || undefined,
    phone: values.phone?.trim() || undefined,
    courseName: values.courseName?.trim() || undefined,
@@ -232,11 +237,12 @@
            <Form.Item name="sessionId" hidden>
              <Input />
            </Form.Item>
            <Form.Item label="诊所区域" name="clinicAreaPlaceholder">
              <Select
                disabled
                options={[{ label: '待数据源补齐', value: 'placeholder' }]}
                className="live-record-placeholder"
            <Form.Item label="诊所区域" name="regionCode">
              <Cascader
                placeholder="全部"
                allowClear
                changeOnSelect
                options={clinicRegionOptions}
              />
            </Form.Item>
            <Form.Item label="诊所" name="clinicName">
@@ -288,7 +294,7 @@
            <Form.Item label="观看时间" name="watchRange">
              <DatePicker.RangePicker showTime className="live-broadcast-search-form__range" />
            </Form.Item>
            <div className="live-broadcast-search-form__actions">
            <div className="live-broadcast-search-form__actions live-broadcast-search-form__actions--right">
              <Button
                onClick={async () => {
                  form.resetFields();
@@ -308,28 +314,18 @@
            </div>
          </Form>
        </div>
        <div className="live-record-summary">
          <div className="live-record-summary__card">
            <div className="live-record-summary__label">去重观看人数</div>
            <div className="live-record-summary__value">{summary.watchUserTotal || 0}</div>
          </div>
          <div className="live-record-summary__card">
            <div className="live-record-summary__label">完播率</div>
            <div className="live-record-summary__value">
              {formatPercent(summary.completionRate)}
            </div>
          </div>
          <div className="live-record-summary__card">
            <div className="live-record-summary__label">观看有效率</div>
            <div className="live-record-summary__value">
              {formatPercent(summary.effectiveWatchRate)}
            </div>
          </div>
        </div>
        <div className="live-broadcast-toolbar">
        <div className="live-record-metrics-bar">
          <Button type="primary" onClick={handleExport}>
            导出
          </Button>
          <div className="live-record-metrics-bar__items">
            <span className="live-record-metrics-bar__item">
              完播率:{formatPercent(summary.completionRate)}
            </span>
            <span className="live-record-metrics-bar__item">
              观看有效率:{formatPercent(summary.effectiveWatchRate)}
            </span>
          </div>
        </div>
        <div className="live-broadcast-table-card">
          <Table<WatchItem>
tcm-training-admin/src/pages/member/user/detail-page.tsx
@@ -3,7 +3,7 @@
import dayjs from 'dayjs';
import React, { useEffect, useState } from 'react';
import './detail.less';
import { fillEditForm, ResetPasswordModal, UserEditModal } from './modals';
import { ResetPasswordModal, UserEditModal } from './modals';
import {
  deleteUser,
  getClinicOptions,
@@ -98,7 +98,6 @@
      return;
    }
    editForm.resetFields();
    fillEditForm(editForm, detail);
    setEditOpen(true);
  };
@@ -275,6 +274,7 @@
        submitting={submitting}
        form={editForm}
        clinics={clinics}
        detail={detail}
        onCancel={() => setEditOpen(false)}
        onSubmit={handleSave}
      />
tcm-training-admin/src/pages/member/user/list-page.tsx
@@ -14,7 +14,7 @@
import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs';
import React, { useEffect, useState } from 'react';
import { fillEditForm, UserEditModal } from './modals';
import { UserEditModal } from './modals';
import { exportUsers, getClinicOptions, getUserDetail, getUserPage, updateUser } from './service';
import './style.less';
@@ -173,7 +173,6 @@
    const detail = res.data;
    setCurrentDetail(detail);
    editForm.resetFields();
    fillEditForm(editForm, detail);
    setEditOpen(true);
  };
@@ -291,7 +290,7 @@
              详情
            </button>
          ) : null}
          {canEdit ? (
          {canEdit && mode === 'all' ? (
            <button type="button" onClick={() => openEditModal(record.id)}>
              编辑
            </button>
@@ -395,6 +394,7 @@
        submitting={submitting}
        form={editForm}
        clinics={clinics}
        detail={currentDetail}
        onCancel={() => setEditOpen(false)}
        onSubmit={handleEditSubmit}
      />
tcm-training-admin/src/pages/member/user/modals.tsx
@@ -1,7 +1,7 @@
import { PlusOutlined } from '@ant-design/icons';
import { Button, DatePicker, Form, Image, Input, message, Modal, Radio, Select, Upload } from 'antd';
import { Button, DatePicker, Form, Input, message, Modal, Radio, Select, Upload } from 'antd';
import dayjs from 'dayjs';
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { uploadUserImage } from './service';
type ClinicOption = {
@@ -27,6 +27,7 @@
  submitting: boolean;
  form: any;
  clinics: ClinicOption[];
  detail?: UserDetail | null;
  onCancel: () => void;
  onSubmit: () => void;
};
@@ -53,10 +54,44 @@
  submitting,
  form,
  clinics,
  detail,
  onCancel,
  onSubmit,
}) => {
  const avatarUrl = Form.useWatch('avatar', form);
  const [avatarPreview, setAvatarPreview] = useState<string>('');
  const blobPreviewRef = useRef<string>('');
  const clearBlobPreview = () => {
    if (blobPreviewRef.current) {
      URL.revokeObjectURL(blobPreviewRef.current);
      blobPreviewRef.current = '';
    }
  };
  useEffect(() => {
    if (!open) {
      return;
    }
    if (!detail) {
      form.resetFields();
      clearBlobPreview();
      setAvatarPreview('');
      return;
    }
    fillEditForm(form, detail);
    clearBlobPreview();
    setAvatarPreview(detail.avatar || '');
  }, [detail, form, open]);
  useEffect(() => {
    if (!open) {
      clearBlobPreview();
      setAvatarPreview('');
    }
  }, [open]);
  useEffect(() => () => clearBlobPreview(), []);
  return (
    <Modal
@@ -70,29 +105,39 @@
      className="member-user-edit-modal"
    >
      <Form form={form} layout="vertical" className="member-user-edit-form">
        <Form.Item label="头像" name="avatar" className="member-user-edit-form__avatar">
        <Form.Item name="avatar" hidden>
          <Input />
        </Form.Item>
        <Form.Item label="头像" className="member-user-edit-form__avatar">
          <Upload
            accept="image/*"
            showUploadList={false}
            customRequest={async (options) => {
              try {
                const file = options.file as File;
                const localPreview = URL.createObjectURL(file);
                clearBlobPreview();
                blobPreviewRef.current = localPreview;
                setAvatarPreview(localPreview);
                const res = await uploadUserImage(file);
                const url = res.data?.url;
                if (!url) {
                  clearBlobPreview();
                  throw new Error('上传失败');
                }
                form.setFieldValue('avatar', url);
                options.onSuccess?.(res);
              } catch (error) {
                clearBlobPreview();
                setAvatarPreview(avatarUrl || detail?.avatar || '');
                message.error('头像上传失败');
                options.onError?.(error as Error);
              }
            }}
          >
            <div className="member-user-edit-form__avatar-box">
              {avatarUrl ? (
                <Image src={avatarUrl} preview={false} width={72} height={72} />
              {avatarPreview ? (
                <img key={avatarPreview} src={avatarPreview} alt="头像" />
              ) : (
                <PlusOutlined />
              )}
@@ -204,6 +249,7 @@
export const fillEditForm = (form: any, detail: UserDetail | null) => {
  if (!detail) {
    form.resetFields();
    return;
  }
  form.setFieldsValue({
tcm-training-admin/src/pages/member/user/style.less
@@ -158,7 +158,8 @@
}
.member-user-edit-form__avatar-box .ant-image,
.member-user-edit-form__avatar-box .ant-image-img {
.member-user-edit-form__avatar-box .ant-image-img,
.member-user-edit-form__avatar-box img {
  width: 72px !important;
  height: 72px !important;
  object-fit: cover;
tcm-training-admin/src/pages/trade/points-exchange/index.tsx
@@ -228,7 +228,7 @@
            <Form.Item label="下单时间" name="createdRange">
              <DatePicker.RangePicker className="points-exchange-search-form__range" />
            </Form.Item>
            <div className="points-exchange-search-form__actions">
            <div className="points-exchange-search-form__actions points-exchange-search-form__actions--right">
              <Button
                onClick={async () => {
                  searchForm.resetFields();
tcm-training-admin/src/pages/trade/points-exchange/items.tsx
@@ -238,7 +238,7 @@
            <Form.Item label="下单时间" name="createdRange">
              <DatePicker.RangePicker className="points-exchange-search-form__range" />
            </Form.Item>
            <div className="points-exchange-search-form__actions">
            <div className="points-exchange-search-form__actions points-exchange-search-form__actions--right">
              <Button
                onClick={async () => {
                  searchForm.resetFields();
tcm-training-admin/src/pages/trade/points-exchange/style.less
@@ -80,6 +80,11 @@
  padding-bottom: 8px;
}
.points-exchange-search-form__actions--right {
  grid-column: 4;
  justify-self: end;
}
.points-exchange-toolbar {
  display: flex;
  justify-content: flex-start;
@@ -236,6 +241,11 @@
  .points-exchange-detail-card__columns {
    grid-template-columns: 1fr;
  }
  .points-exchange-search-form__actions--right {
    grid-column: auto;
    justify-self: stretch;
  }
}
@media (max-width: 768px) {
@@ -269,6 +279,11 @@
    justify-content: flex-start;
  }
  .points-exchange-search-form__actions--right {
    grid-column: auto;
    justify-self: stretch;
  }
  .points-exchange-detail-card__gift {
    flex-direction: column;
  }
tcm-training-admin/src/pages/trade/points-ledger/index.tsx
@@ -39,13 +39,6 @@
  canJump?: boolean;
};
type SummaryData = {
  incomeTotal?: number;
  expenseTotal?: number;
  liveWatchIncomeTotal?: number;
  exchangeExpenseTotal?: number;
};
const incomeExpenseOptions = [
  { label: '收入', value: 'INCOME' },
  { label: '支出', value: 'EXPENSE' },
@@ -80,11 +73,6 @@
  return `${points}`;
};
const summarySigned = (value?: number, positive = true) => {
  const amount = Number(value || 0);
  return `${positive ? '+' : '-'}${Math.abs(amount)}`;
};
const PointsLedgerPage: React.FC = () => {
  const access = useAccess();
  const [searchForm] = Form.useForm<SearchValues>();
@@ -92,7 +80,6 @@
  const [regions, setRegions] = useState<RegionOption[]>([]);
  const [loading, setLoading] = useState(false);
  const [pagination, setPagination] = useState({ page: 1, pageSize: 10, total: 0 });
  const [summary, setSummary] = useState<SummaryData>({});
  const canView = access['points:ledger:view'];
  const canExport = access['points:ledger:export'];
@@ -111,7 +98,6 @@
        ...buildParams(searchForm.getFieldsValue()),
      });
      setList(res.data.list || []);
      setSummary(res.data.summary || {});
      setPagination({
        page: Number(res.data.page || page),
        pageSize: Number(res.data.pageSize || pageSize),
@@ -253,7 +239,7 @@
            <Form.Item label="操作时间" name="operatedRange">
              <DatePicker.RangePicker className="points-ledger-search-form__range" />
            </Form.Item>
            <div className="points-ledger-search-form__actions">
            <div className="points-ledger-search-form__actions points-ledger-search-form__actions--right">
              <Button
                onClick={async () => {
                  searchForm.resetFields();
@@ -267,33 +253,6 @@
              </Button>
            </div>
          </Form>
        </div>
        <div className="points-ledger-summary">
          <div className="points-ledger-summary__card">
            <div className="points-ledger-summary__label">总计收入</div>
            <div className="points-ledger-summary__value points-ledger-summary__value--income">
              {summarySigned(summary.incomeTotal, true)}
            </div>
          </div>
          <div className="points-ledger-summary__card">
            <div className="points-ledger-summary__label">总计支出</div>
            <div className="points-ledger-summary__value points-ledger-summary__value--expense">
              {summarySigned(summary.expenseTotal, false)}
            </div>
          </div>
          <div className="points-ledger-summary__card">
            <div className="points-ledger-summary__label">观看直播</div>
            <div className="points-ledger-summary__value points-ledger-summary__value--income">
              {summarySigned(summary.liveWatchIncomeTotal, true)}
            </div>
          </div>
          <div className="points-ledger-summary__card">
            <div className="points-ledger-summary__label">积分兑换</div>
            <div className="points-ledger-summary__value points-ledger-summary__value--expense">
              {summarySigned(summary.exchangeExpenseTotal, false)}
            </div>
          </div>
        </div>
        <div className="points-ledger-toolbar">
tcm-training-admin/src/pages/trade/points-ledger/style.less
@@ -69,37 +69,9 @@
  padding-bottom: 8px;
}
.points-ledger-summary {
  display: grid;
  grid-template-columns: repeat(4, minmax(180px, 1fr));
  gap: 14px;
  margin: 18px 0 14px;
}
.points-ledger-summary__card {
  padding: 16px 18px;
  border-radius: 10px;
  background: linear-gradient(135deg, #f8fbff 0%, #eef5ff 100%);
  border: 1px solid #dbe7ff;
}
.points-ledger-summary__label {
  color: #606266;
  font-size: 13px;
}
.points-ledger-summary__value {
  margin-top: 8px;
  font-size: 24px;
  font-weight: 600;
}
.points-ledger-summary__value--income {
  color: #16a34a;
}
.points-ledger-summary__value--expense {
  color: #dc2626;
.points-ledger-search-form__actions--right {
  grid-column: 4;
  justify-self: end;
}
.points-ledger-toolbar {
@@ -146,8 +118,7 @@
}
@media (max-width: 1200px) {
  .points-ledger-search-form,
  .points-ledger-summary {
  .points-ledger-search-form {
    grid-template-columns: repeat(2, minmax(220px, 1fr));
  }
}
@@ -161,12 +132,16 @@
    padding: 16px;
  }
  .points-ledger-search-form,
  .points-ledger-summary {
  .points-ledger-search-form {
    grid-template-columns: 1fr;
  }
  .points-ledger-search-form__actions {
    justify-content: flex-start;
  }
  .points-ledger-search-form__actions--right {
    grid-column: auto;
    justify-self: stretch;
  }
}