| | |
| | | (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), |
| | |
| | | (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); |
| | | |
| | |
| | | -- ---------------------------- |
| | | -- 养生直播:课程 |
| | | -- ---------------------------- |
| | | 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 '课程名称', |
| | |
| | | "/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", |
| | |
| | | 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", |
| | |
| | | @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, |
| | |
| | | @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 |
| | | )); |
| | | } |
| | | |
| | |
| | | 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, |
| | |
| | | @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") |
| | |
| | | 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, |
| | |
| | | |
| | | 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, |
| | |
| | | |
| | | 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, |
| | |
| | | int pageSize, |
| | | Long courseId, |
| | | Long sessionId, |
| | | String regionCode, |
| | | String clinicName, |
| | | String phone, |
| | | String courseName, |
| | |
| | | new Page<LiveWatchRow>(page, pageSize), |
| | | courseId, |
| | | sessionId, |
| | | trimToNull(regionCode), |
| | | trimToNull(clinicName), |
| | | trimToNull(phone), |
| | | trimToNull(courseName), |
| | |
| | | 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, |
| | |
| | | List<LiveWatchRow> rows = liveWatchRecordMapper.selectGlobalWatchList( |
| | | courseId, |
| | | sessionId, |
| | | trimToNull(regionCode), |
| | | trimToNull(clinicName), |
| | | trimToNull(phone), |
| | | trimToNull(courseName), |
| | |
| | | |
| | | private LiveWatchSummaryVO buildWatchSummary(Long courseId, |
| | | Long sessionId, |
| | | String regionCode, |
| | | String clinicName, |
| | | String phone, |
| | | String courseName, |
| | |
| | | LiveWatchSummaryRow row = liveWatchRecordMapper.selectGlobalWatchSummary( |
| | | courseId, |
| | | sessionId, |
| | | trimToNull(regionCode), |
| | | trimToNull(clinicName), |
| | | trimToNull(phone), |
| | | trimToNull(courseName), |
| | |
| | | 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, |
| | |
| | | <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> |
| | |
| | | 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, |
| | |
| | | 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'], |
| | |
| | | <div className="live-broadcast-page__panel"> |
| | | <div className="live-broadcast-page__title">养身直播详情</div> |
| | | <Tabs |
| | | className="live-broadcast-detail-tabs" |
| | | defaultActiveKey="info" |
| | | items={[ |
| | | { |
| | |
| | | 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 |
| | |
| | | </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> |
| | | ), |
| | | }, |
| | |
| | | ]} |
| | | /> |
| | | </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(); |
| | |
| | | </div> |
| | | </Form> |
| | | </div> |
| | | <div className="live-broadcast-toolbar"> |
| | | <div className="live-broadcast-toolbar live-broadcast-schedule__toolbar"> |
| | | {canSessionAdd ? ( |
| | | <Button type="primary" onClick={() => openCreateSessionModal()}> |
| | | 添加场次 |
| | |
| | | </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 |
| | |
| | | margin-bottom: 12px; |
| | | color: #303133; |
| | | font-weight: 500; |
| | | font-size: 20px; |
| | | font-size: 22px; |
| | | } |
| | | |
| | | .live-broadcast-page__hint { |
| | |
| | | .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 { |
| | |
| | | } |
| | | } |
| | | |
| | | .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 { |
| | |
| | | } |
| | | |
| | | .live-broadcast-search-card { |
| | | padding: 16px 18px 10px; |
| | | padding: 14px 18px; |
| | | } |
| | | |
| | | .live-broadcast-search-form { |
| | |
| | | min-width: 220px; |
| | | } |
| | | |
| | | .live-broadcast-search-form__actions--right { |
| | | grid-column: 3; |
| | | justify-self: end; |
| | | } |
| | | |
| | | .live-broadcast-toolbar { |
| | | display: flex; |
| | | gap: 12px; |
| | |
| | | |
| | | .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 { |
| | |
| | | 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 { |
| | |
| | | .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) { |
| | |
| | | .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; |
| | | } |
| | | } |
| | |
| | | <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(); |
| | |
| | | </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> |
| | |
| | | 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; |
| | |
| | | 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') |
| | |
| | | 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, |
| | |
| | | <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"> |
| | |
| | | <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(); |
| | |
| | | </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> |
| | |
| | | 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, |
| | |
| | | return; |
| | | } |
| | | editForm.resetFields(); |
| | | fillEditForm(editForm, detail); |
| | | setEditOpen(true); |
| | | }; |
| | | |
| | |
| | | submitting={submitting} |
| | | form={editForm} |
| | | clinics={clinics} |
| | | detail={detail} |
| | | onCancel={() => setEditOpen(false)} |
| | | onSubmit={handleSave} |
| | | /> |
| | |
| | | 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'; |
| | | |
| | |
| | | const detail = res.data; |
| | | setCurrentDetail(detail); |
| | | editForm.resetFields(); |
| | | fillEditForm(editForm, detail); |
| | | setEditOpen(true); |
| | | }; |
| | | |
| | |
| | | 详情 |
| | | </button> |
| | | ) : null} |
| | | {canEdit ? ( |
| | | {canEdit && mode === 'all' ? ( |
| | | <button type="button" onClick={() => openEditModal(record.id)}> |
| | | 编辑 |
| | | </button> |
| | |
| | | submitting={submitting} |
| | | form={editForm} |
| | | clinics={clinics} |
| | | detail={currentDetail} |
| | | onCancel={() => setEditOpen(false)} |
| | | onSubmit={handleEditSubmit} |
| | | /> |
| | |
| | | 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 = { |
| | |
| | | submitting: boolean; |
| | | form: any; |
| | | clinics: ClinicOption[]; |
| | | detail?: UserDetail | null; |
| | | onCancel: () => void; |
| | | onSubmit: () => void; |
| | | }; |
| | |
| | | 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 |
| | |
| | | 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 /> |
| | | )} |
| | |
| | | |
| | | export const fillEditForm = (form: any, detail: UserDetail | null) => { |
| | | if (!detail) { |
| | | form.resetFields(); |
| | | return; |
| | | } |
| | | form.setFieldsValue({ |
| | |
| | | } |
| | | |
| | | .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; |
| | |
| | | <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(); |
| | |
| | | <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(); |
| | |
| | | padding-bottom: 8px; |
| | | } |
| | | |
| | | .points-exchange-search-form__actions--right { |
| | | grid-column: 4; |
| | | justify-self: end; |
| | | } |
| | | |
| | | .points-exchange-toolbar { |
| | | display: flex; |
| | | justify-content: flex-start; |
| | |
| | | .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) { |
| | |
| | | justify-content: flex-start; |
| | | } |
| | | |
| | | .points-exchange-search-form__actions--right { |
| | | grid-column: auto; |
| | | justify-self: stretch; |
| | | } |
| | | |
| | | .points-exchange-detail-card__gift { |
| | | flex-direction: column; |
| | | } |
| | |
| | | canJump?: boolean; |
| | | }; |
| | | |
| | | type SummaryData = { |
| | | incomeTotal?: number; |
| | | expenseTotal?: number; |
| | | liveWatchIncomeTotal?: number; |
| | | exchangeExpenseTotal?: number; |
| | | }; |
| | | |
| | | const incomeExpenseOptions = [ |
| | | { label: '收入', value: 'INCOME' }, |
| | | { label: '支出', value: 'EXPENSE' }, |
| | |
| | | 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>(); |
| | |
| | | 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']; |
| | |
| | | ...buildParams(searchForm.getFieldsValue()), |
| | | }); |
| | | setList(res.data.list || []); |
| | | setSummary(res.data.summary || {}); |
| | | setPagination({ |
| | | page: Number(res.data.page || page), |
| | | pageSize: Number(res.data.pageSize || pageSize), |
| | |
| | | <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(); |
| | |
| | | </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"> |
| | |
| | | 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 { |
| | |
| | | } |
| | | |
| | | @media (max-width: 1200px) { |
| | | .points-ledger-search-form, |
| | | .points-ledger-summary { |
| | | .points-ledger-search-form { |
| | | grid-template-columns: repeat(2, minmax(220px, 1fr)); |
| | | } |
| | | } |
| | |
| | | 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; |
| | | } |
| | | } |