From 5ad8d714d43ecf06aa9d900a69e7b22018ce7ed6 Mon Sep 17 00:00:00 2001
From: “mitao” <“2763622819@qq.com”>
Date: 星期三, 15 四月 2026 23:09:44 +0800
Subject: [PATCH] feat:APP个人中心
---
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseMemberJoinRecordMapper.java | 8
tcm-training-admin-backend/src/main/resources/mapper/PointsLedgerMapper.xml | 12
traditionalApp/pages/courseDetail/courseDetail.vue | 123
traditionalApp/pages/myCourseDetail/myCourseDetail.vue | 1085 ++++++
traditionalApp/pages/point/service.js | 8
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCourseFavoriteBatchCancelRequest.java | 21
tcm-training-admin-backend/src/main/resources/mapper/HealthCourseFavoriteRecordMapper.xml | 38
tcm-training-admin-backend/src/main/resources/mapper/HealthCourseDownloadRecordMapper.xml | 23
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCoursePlayRecordUploadRequest.java | 32
traditionalApp/pages/courseRecordDetail/courseRecordDetail.vue | 500 ++-
traditionalApp/pages/courseRecordDetail/service.js | 8
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/service/AppUserCenterService.java | 154 +
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/vo/AppPointsLedgerItemVO.java | 20
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseFavoriteRecordMapper.java | 6
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/CoursePurchaseOrderAppService.java | 122
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/HealthCoursePlayRecord.java | 48
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/points/mapper/PointsLedgerMapper.java | 4
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/controller/AppPointsLedgerController.java | 40
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppHealthCourseController.java | 87
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppMyHealthCourseItemVO.java | 22
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java | 732 ++++
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/vo/AppUserCenterVO.java | 25
tcm-training-admin-backend/src/main/resources/mapper/CoursePurchaseOrderMapper.xml | 104
traditionalApp/pages/courseRecord/courseRecord.vue | 602 ++-
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppHealthCourseFavoriteRow.java | 26
traditionalApp/pages/favorites/service.js | 22
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/member/vo/AppMemberPurchaseOrderItemVO.java | 27
traditionalApp/pages/pointDetail/service.js | 8
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCoursePlayRecordUploadResultVO.java | 19
traditionalApp/pages/mine/service.js | 19
tcm-training-admin-backend/src/main/resources/mapper/HealthCourseMemberJoinRecordMapper.xml | 14
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/member/controller/AppMemberOpenPageController.java | 18
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/vo/AppPointsOverviewVO.java | 25
traditionalApp/pages/courseDetail/service.js | 7
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppCoursePurchaseOrderDetailVO.java | 33
traditionalApp/pages/mine/mine.vue | 780 +++-
tcm-training-admin-backend/sql/tcm_training_admin.sql | 71
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCourseDownloadBatchDeleteRequest.java | 21
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppMyHealthCourseProgressRow.java | 18
tcm-training-admin-backend/src/main/resources/mapper/HealthCoursePlayRecordMapper.xml | 52
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppCoursePurchaseOrderController.java | 50
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/MemberPurchaseOrderAppService.java | 84
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCourseDownloadReportRequest.java | 22
traditionalApp/pages/pointDetail/pointDetail.vue | 268 +
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppMyHealthCourseVideoProgressRow.java | 21
traditionalApp/pages/vipRecord/service.js | 8
tcm-training-admin-backend/sql/init.sql | 72
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppMyHealthCourseDetailVO.java | 25
tcm-training-admin-backend/sql/update_app_my_course_schema.sql | 87
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/service/AppPointsLedgerService.java | 113
tcm-training-admin-backend/src/main/resources/mapper/MemberPurchaseOrderMapper.xml | 23
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/CoursePurchaseOrderListRow.java | 7
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/HealthCourseDownloadRecord.java | 65
traditionalApp/pages/myCourses/myCourses.vue | 361 +
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppMyHealthCourseVideoItemVO.java | 22
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/CoursePurchaseOrderMapper.java | 9
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppHealthCourseDownloadRow.java | 24
traditionalApp/pages/point/point.vue | 184 +
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/mapper/MemberPurchaseOrderMapper.java | 3
traditionalApp/pages/downloads/service.js | 15
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppCoursePurchaseOrderItemVO.java | 30
traditionalApp/pages/vipRecord/vipRecord.vue | 421 +-
traditionalApp/pages/courseRecord/service.js | 8
traditionalApp/pages/favorites/favorites.vue | 833 +++--
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseDownloadItemVO.java | 25
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/service/AppPointsOverviewService.java | 138
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseDownloadRecordMapper.java | 21
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCoursePlayRecordMapper.java | 29
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseFavoriteItemVO.java | 24
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/controller/AppUserCenterController.java | 35
traditionalApp/pages/downloads/downloads.vue | 965 ++++--
traditionalApp/utils/download-task-storage.js | 142
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/controller/AppPointsOverviewController.java | 35
traditionalApp/pages/myCourses/service.js | 15
traditionalApp/pages/myCourseDetail/service.js | 15
75 files changed, 7,261 insertions(+), 1,922 deletions(-)
diff --git a/tcm-training-admin-backend/sql/init.sql b/tcm-training-admin-backend/sql/init.sql
index f99ebc8..2984d09 100644
--- a/tcm-training-admin-backend/sql/init.sql
+++ b/tcm-training-admin-backend/sql/init.sql
@@ -646,6 +646,7 @@
`play_end` DATETIME DEFAULT NULL COMMENT '结束播放时间',
`duration_seconds` INT NOT NULL DEFAULT 0 COMMENT '累计播放时长(秒)',
`is_completed` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否完播:1是 0否',
+ `last_play_position_seconds` INT NOT NULL DEFAULT 0 COMMENT '本次结束时播放位置(秒)',
PRIMARY KEY (`id`),
KEY `idx_health_course_play_course` (`course_id`),
KEY `idx_health_course_play_video` (`video_id`),
@@ -667,6 +668,29 @@
KEY `idx_health_course_favorite_user` (`app_user_id`),
KEY `idx_health_course_favorite_time` (`action_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='养生课程收藏记录表';
+
+-- ----------------------------
+-- 养生课程:下载记录
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS `tcm_health_course_download_record` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `app_user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
+ `course_id` BIGINT UNSIGNED NOT NULL COMMENT '养生课程ID',
+ `course_video_id` BIGINT UNSIGNED NOT NULL COMMENT '视频小节ID',
+ `course_title_snapshot` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '课程标题快照',
+ `chapter_name_snapshot` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '章节标题快照',
+ `cover_img_snapshot` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '封面图快照',
+ `video_url_snapshot` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '视频地址快照',
+ `duration_seconds_snapshot` 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_download_user_deleted_created` (`app_user_id`, `deleted`, `created_at`),
+ KEY `idx_health_course_download_user_video_deleted` (`app_user_id`, `course_video_id`, `deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='养生课程下载记录表';
-- ----------------------------
-- 养生课程:会员加课记录
@@ -963,31 +987,31 @@
(5034, 1, 107, 200, TIMESTAMP(CURDATE(), '20:40:00'), TIMESTAMP(CURDATE(), '20:46:00'), 360, 0, 1);
INSERT IGNORE INTO `tcm_health_course_play_record`
-(`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`)
+(`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`)
VALUES
-(5101, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 6 DAY), '10:00:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 6 DAY), '10:18:00'), 1080, 1),
-(5102, 1, 2, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 6 DAY), '15:10:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 6 DAY), '15:18:00'), 480, 0),
-(5103, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 5 DAY), '10:02:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 5 DAY), '10:20:00'), 1080, 1),
-(5104, 1, 3, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 5 DAY), '14:30:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 5 DAY), '14:38:00'), 480, 0),
-(5105, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 4 DAY), '09:55:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 4 DAY), '10:13:00'), 1080, 1),
-(5106, 1, 2, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 4 DAY), '13:20:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 4 DAY), '13:28:00'), 480, 0),
-(5107, 1, 3, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 4 DAY), '19:10:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 4 DAY), '19:18:00'), 480, 0),
-(5108, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 3 DAY), '10:05:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 3 DAY), '10:23:00'), 1080, 1),
-(5109, 1, 2, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 3 DAY), '12:40:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 3 DAY), '12:48:00'), 480, 0),
-(5110, 1, 3, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 3 DAY), '18:25:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 3 DAY), '18:33:00'), 480, 0),
-(5111, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '10:08:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '10:26:00'), 1080, 1),
-(5112, 1, 2, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '13:10:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '13:18:00'), 480, 0),
-(5113, 1, 3, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '18:00:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '18:08:00'), 480, 0),
-(5114, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '20:10:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '20:18:00'), 480, 0),
-(5115, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '09:58:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '10:16:00'), 1080, 1),
-(5116, 1, 2, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '12:20:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '12:28:00'), 480, 0),
-(5117, 1, 3, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '17:42:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '17:50:00'), 480, 0),
-(5118, 1, 2, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '21:00:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '21:08:00'), 480, 0),
-(5119, 1, 1, 200, TIMESTAMP(CURDATE(), '09:50:00'), TIMESTAMP(CURDATE(), '10:08:00'), 1080, 1),
-(5120, 1, 2, 200, TIMESTAMP(CURDATE(), '12:15:00'), TIMESTAMP(CURDATE(), '12:23:00'), 480, 0),
-(5121, 1, 3, 200, TIMESTAMP(CURDATE(), '15:30:00'), TIMESTAMP(CURDATE(), '15:38:00'), 480, 0),
-(5122, 1, 1, 200, TIMESTAMP(CURDATE(), '18:20:00'), TIMESTAMP(CURDATE(), '18:28:00'), 480, 0),
-(5123, 1, 2, 200, TIMESTAMP(CURDATE(), '21:10:00'), TIMESTAMP(CURDATE(), '21:18:00'), 480, 0);
+(5101, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 6 DAY), '10:00:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 6 DAY), '10:18:00'), 1080, 1, 0),
+(5102, 1, 2, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 6 DAY), '15:10:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 6 DAY), '15:18:00'), 480, 0, 480),
+(5103, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 5 DAY), '10:02:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 5 DAY), '10:20:00'), 1080, 1, 0),
+(5104, 1, 3, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 5 DAY), '14:30:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 5 DAY), '14:38:00'), 480, 0, 480),
+(5105, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 4 DAY), '09:55:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 4 DAY), '10:13:00'), 1080, 1, 0),
+(5106, 1, 2, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 4 DAY), '13:20:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 4 DAY), '13:28:00'), 480, 0, 480),
+(5107, 1, 3, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 4 DAY), '19:10:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 4 DAY), '19:18:00'), 480, 0, 480),
+(5108, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 3 DAY), '10:05:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 3 DAY), '10:23:00'), 1080, 1, 0),
+(5109, 1, 2, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 3 DAY), '12:40:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 3 DAY), '12:48:00'), 480, 0, 480),
+(5110, 1, 3, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 3 DAY), '18:25:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 3 DAY), '18:33:00'), 480, 0, 480),
+(5111, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '10:08:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '10:26:00'), 1080, 1, 0),
+(5112, 1, 2, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '13:10:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '13:18:00'), 480, 0, 480),
+(5113, 1, 3, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '18:00:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '18:08:00'), 480, 0, 480),
+(5114, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '20:10:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 2 DAY), '20:18:00'), 480, 0, 480),
+(5115, 1, 1, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '09:58:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '10:16:00'), 1080, 1, 0),
+(5116, 1, 2, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '12:20:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '12:28:00'), 480, 0, 480),
+(5117, 1, 3, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '17:42:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '17:50:00'), 480, 0, 480),
+(5118, 1, 2, 200, TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '21:00:00'), TIMESTAMP(DATE_SUB(CURDATE(), INTERVAL 1 DAY), '21:08:00'), 480, 0, 480),
+(5119, 1, 1, 200, TIMESTAMP(CURDATE(), '09:50:00'), TIMESTAMP(CURDATE(), '10:08:00'), 1080, 1, 0),
+(5120, 1, 2, 200, TIMESTAMP(CURDATE(), '12:15:00'), TIMESTAMP(CURDATE(), '12:23:00'), 480, 0, 480),
+(5121, 1, 3, 200, TIMESTAMP(CURDATE(), '15:30:00'), TIMESTAMP(CURDATE(), '15:38:00'), 480, 0, 480),
+(5122, 1, 1, 200, TIMESTAMP(CURDATE(), '18:20:00'), TIMESTAMP(CURDATE(), '18:28:00'), 480, 0, 480),
+(5123, 1, 2, 200, TIMESTAMP(CURDATE(), '21:10:00'), TIMESTAMP(CURDATE(), '21:18:00'), 480, 0, 480);
INSERT IGNORE INTO `tcm_health_course_favorite_record`
(`id`, `course_id`, `app_user_id`, `action_type`, `action_time`)
diff --git a/tcm-training-admin-backend/sql/tcm_training_admin.sql b/tcm-training-admin-backend/sql/tcm_training_admin.sql
index 45a4fb3..0accdb4 100644
--- a/tcm-training-admin-backend/sql/tcm_training_admin.sql
+++ b/tcm-training-admin-backend/sql/tcm_training_admin.sql
@@ -4457,6 +4457,30 @@
COMMIT;
-- ----------------------------
+-- Table structure for tcm_health_course_download_record
+-- ----------------------------
+DROP TABLE IF EXISTS `tcm_health_course_download_record`;
+CREATE TABLE `tcm_health_course_download_record` (
+ `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+ `app_user_id` bigint unsigned NOT NULL COMMENT '用户ID',
+ `course_id` bigint unsigned NOT NULL COMMENT '养生课程ID',
+ `course_video_id` bigint unsigned NOT NULL COMMENT '视频小节ID',
+ `course_title_snapshot` varchar(128) NOT NULL DEFAULT '' COMMENT '课程标题快照',
+ `chapter_name_snapshot` varchar(128) NOT NULL DEFAULT '' COMMENT '章节标题快照',
+ `cover_img_snapshot` varchar(255) NOT NULL DEFAULT '' COMMENT '封面图快照',
+ `video_url_snapshot` varchar(512) NOT NULL DEFAULT '' COMMENT '视频地址快照',
+ `duration_seconds_snapshot` 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_download_user_deleted_created` (`app_user_id`,`deleted`,`created_at`),
+ KEY `idx_health_course_download_user_video_deleted` (`app_user_id`,`course_video_id`,`deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='养生课程下载记录表';
+
+-- ----------------------------
-- Table structure for tcm_health_course_favorite_record
-- ----------------------------
DROP TABLE IF EXISTS `tcm_health_course_favorite_record`;
@@ -4510,6 +4534,7 @@
`play_end` datetime DEFAULT NULL COMMENT '结束播放时间',
`duration_seconds` int NOT NULL DEFAULT '0' COMMENT '累计播放时长(秒)',
`is_completed` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否完播:1是 0否',
+ `last_play_position_seconds` int NOT NULL DEFAULT '0' COMMENT '本次结束时播放位置(秒)',
PRIMARY KEY (`id`),
KEY `idx_health_course_play_course` (`course_id`),
KEY `idx_health_course_play_video` (`video_id`),
@@ -4521,29 +4546,29 @@
-- Records of tcm_health_course_play_record
-- ----------------------------
BEGIN;
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5101, 1, 1, 200, '2026-03-20 10:00:00', '2026-03-20 10:18:00', 1080, 1);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5102, 1, 2, 200, '2026-03-20 15:10:00', '2026-03-20 15:18:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5103, 1, 1, 200, '2026-03-21 10:02:00', '2026-03-21 10:20:00', 1080, 1);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5104, 1, 3, 200, '2026-03-21 14:30:00', '2026-03-21 14:38:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5105, 1, 1, 200, '2026-03-22 09:55:00', '2026-03-22 10:13:00', 1080, 1);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5106, 1, 2, 200, '2026-03-22 13:20:00', '2026-03-22 13:28:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5107, 1, 3, 200, '2026-03-22 19:10:00', '2026-03-22 19:18:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5108, 1, 1, 200, '2026-03-23 10:05:00', '2026-03-23 10:23:00', 1080, 1);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5109, 1, 2, 200, '2026-03-23 12:40:00', '2026-03-23 12:48:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5110, 1, 3, 200, '2026-03-23 18:25:00', '2026-03-23 18:33:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5111, 1, 1, 200, '2026-03-24 10:08:00', '2026-03-24 10:26:00', 1080, 1);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5112, 1, 2, 200, '2026-03-24 13:10:00', '2026-03-24 13:18:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5113, 1, 3, 200, '2026-03-24 18:00:00', '2026-03-24 18:08:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5114, 1, 1, 200, '2026-03-24 20:10:00', '2026-03-24 20:18:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5115, 1, 1, 200, '2026-03-25 09:58:00', '2026-03-25 10:16:00', 1080, 1);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5116, 1, 2, 200, '2026-03-25 12:20:00', '2026-03-25 12:28:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5117, 1, 3, 200, '2026-03-25 17:42:00', '2026-03-25 17:50:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5118, 1, 2, 200, '2026-03-25 21:00:00', '2026-03-25 21:08:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5119, 1, 1, 200, '2026-03-26 09:50:00', '2026-03-26 10:08:00', 1080, 1);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5120, 1, 2, 200, '2026-03-26 12:15:00', '2026-03-26 12:23:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5121, 1, 3, 200, '2026-03-26 15:30:00', '2026-03-26 15:38:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5122, 1, 1, 200, '2026-03-26 18:20:00', '2026-03-26 18:28:00', 480, 0);
-INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`) VALUES (5123, 1, 2, 200, '2026-03-26 21:10:00', '2026-03-26 21:18:00', 480, 0);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5101, 1, 1, 200, '2026-03-20 10:00:00', '2026-03-20 10:18:00', 1080, 1, 0);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5102, 1, 2, 200, '2026-03-20 15:10:00', '2026-03-20 15:18:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5103, 1, 1, 200, '2026-03-21 10:02:00', '2026-03-21 10:20:00', 1080, 1, 0);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5104, 1, 3, 200, '2026-03-21 14:30:00', '2026-03-21 14:38:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5105, 1, 1, 200, '2026-03-22 09:55:00', '2026-03-22 10:13:00', 1080, 1, 0);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5106, 1, 2, 200, '2026-03-22 13:20:00', '2026-03-22 13:28:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5107, 1, 3, 200, '2026-03-22 19:10:00', '2026-03-22 19:18:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5108, 1, 1, 200, '2026-03-23 10:05:00', '2026-03-23 10:23:00', 1080, 1, 0);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5109, 1, 2, 200, '2026-03-23 12:40:00', '2026-03-23 12:48:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5110, 1, 3, 200, '2026-03-23 18:25:00', '2026-03-23 18:33:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5111, 1, 1, 200, '2026-03-24 10:08:00', '2026-03-24 10:26:00', 1080, 1, 0);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5112, 1, 2, 200, '2026-03-24 13:10:00', '2026-03-24 13:18:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5113, 1, 3, 200, '2026-03-24 18:00:00', '2026-03-24 18:08:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5114, 1, 1, 200, '2026-03-24 20:10:00', '2026-03-24 20:18:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5115, 1, 1, 200, '2026-03-25 09:58:00', '2026-03-25 10:16:00', 1080, 1, 0);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5116, 1, 2, 200, '2026-03-25 12:20:00', '2026-03-25 12:28:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5117, 1, 3, 200, '2026-03-25 17:42:00', '2026-03-25 17:50:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5118, 1, 2, 200, '2026-03-25 21:00:00', '2026-03-25 21:08:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5119, 1, 1, 200, '2026-03-26 09:50:00', '2026-03-26 10:08:00', 1080, 1, 0);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5120, 1, 2, 200, '2026-03-26 12:15:00', '2026-03-26 12:23:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5121, 1, 3, 200, '2026-03-26 15:30:00', '2026-03-26 15:38:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5122, 1, 1, 200, '2026-03-26 18:20:00', '2026-03-26 18:28:00', 480, 0, 480);
+INSERT INTO `tcm_health_course_play_record` (`id`, `course_id`, `video_id`, `app_user_id`, `play_start`, `play_end`, `duration_seconds`, `is_completed`, `last_play_position_seconds`) VALUES (5123, 1, 2, 200, '2026-03-26 21:10:00', '2026-03-26 21:18:00', 480, 0, 480);
COMMIT;
-- ----------------------------
diff --git a/tcm-training-admin-backend/sql/update_app_my_course_schema.sql b/tcm-training-admin-backend/sql/update_app_my_course_schema.sql
new file mode 100644
index 0000000..062a9b4
--- /dev/null
+++ b/tcm-training-admin-backend/sql/update_app_my_course_schema.sql
@@ -0,0 +1,87 @@
+SET @db_name = DATABASE();
+
+-- ----------------------------
+-- APP 我的课程相关表结构升级
+-- 目标:
+-- 1. 养生课程播放记录表新增续播位置字段 last_play_position_seconds
+-- 2. 新增养生课程下载记录表 tcm_health_course_download_record
+-- ----------------------------
+
+SET @play_record_table_exists = (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db_name
+ AND TABLE_NAME = 'tcm_health_course_play_record'
+);
+
+SET @sql_assert_play_record_table = IF(
+ @play_record_table_exists = 0,
+ 'SELECT ''skip: tcm_health_course_play_record not exists''',
+ 'SELECT 1'
+);
+PREPARE stmt_assert_play_record_table FROM @sql_assert_play_record_table;
+EXECUTE stmt_assert_play_record_table;
+DEALLOCATE PREPARE stmt_assert_play_record_table;
+
+SET @has_last_play_position_seconds = (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db_name
+ AND TABLE_NAME = 'tcm_health_course_play_record'
+ AND COLUMN_NAME = 'last_play_position_seconds'
+);
+
+SET @sql_add_last_play_position_seconds = IF(
+ @play_record_table_exists = 1 AND @has_last_play_position_seconds = 0,
+ 'ALTER TABLE `tcm_health_course_play_record`
+ ADD COLUMN `last_play_position_seconds` INT NOT NULL DEFAULT 0 COMMENT ''本次结束时播放位置(秒)'' AFTER `is_completed`',
+ 'SELECT 1'
+);
+PREPARE stmt_add_last_play_position_seconds FROM @sql_add_last_play_position_seconds;
+EXECUTE stmt_add_last_play_position_seconds;
+DEALLOCATE PREPARE stmt_add_last_play_position_seconds;
+
+SET @sql_fill_last_play_position_seconds = IF(
+ @play_record_table_exists = 1,
+ 'UPDATE `tcm_health_course_play_record`
+ SET `last_play_position_seconds` = 0
+ WHERE `last_play_position_seconds` IS NULL',
+ 'SELECT 1'
+);
+PREPARE stmt_fill_last_play_position_seconds FROM @sql_fill_last_play_position_seconds;
+EXECUTE stmt_fill_last_play_position_seconds;
+DEALLOCATE PREPARE stmt_fill_last_play_position_seconds;
+
+SET @download_record_table_exists = (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db_name
+ AND TABLE_NAME = 'tcm_health_course_download_record'
+);
+
+SET @sql_create_download_record_table = IF(
+ @download_record_table_exists = 0,
+ 'CREATE TABLE `tcm_health_course_download_record` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `app_user_id` BIGINT UNSIGNED NOT NULL COMMENT ''用户ID'',
+ `course_id` BIGINT UNSIGNED NOT NULL COMMENT ''养生课程ID'',
+ `course_video_id` BIGINT UNSIGNED NOT NULL COMMENT ''视频小节ID'',
+ `course_title_snapshot` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''课程标题快照'',
+ `chapter_name_snapshot` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''章节标题快照'',
+ `cover_img_snapshot` VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''封面图快照'',
+ `video_url_snapshot` VARCHAR(512) NOT NULL DEFAULT '''' COMMENT ''视频地址快照'',
+ `duration_seconds_snapshot` 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_download_user_deleted_created` (`app_user_id`, `deleted`, `created_at`),
+ KEY `idx_health_course_download_user_video_deleted` (`app_user_id`, `course_video_id`, `deleted`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''养生课程下载记录表''',
+ 'SELECT 1'
+);
+PREPARE stmt_create_download_record_table FROM @sql_create_download_record_table;
+EXECUTE stmt_create_download_record_table;
+DEALLOCATE PREPARE stmt_create_download_record_table;
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppCoursePurchaseOrderController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppCoursePurchaseOrderController.java
new file mode 100644
index 0000000..d892834
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppCoursePurchaseOrderController.java
@@ -0,0 +1,50 @@
+package com.tcm.training.admin.app.course.controller;
+
+import com.tcm.training.admin.app.course.vo.AppCoursePurchaseOrderDetailVO;
+import com.tcm.training.admin.app.course.vo.AppCoursePurchaseOrderItemVO;
+import com.tcm.training.admin.common.ApiResponse;
+import com.tcm.training.admin.common.PageResponse;
+import com.tcm.training.admin.live.service.CoursePurchaseOrderAppService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * APP 购课记录接口。
+ */
+@Validated
+@RestController
+@RequestMapping({"/api/app", "/api/v1/app"})
+public class AppCoursePurchaseOrderController {
+
+ private final CoursePurchaseOrderAppService coursePurchaseOrderAppService;
+
+ /**
+ * 注入 APP 购课记录服务。
+ */
+ public AppCoursePurchaseOrderController(CoursePurchaseOrderAppService coursePurchaseOrderAppService) {
+ this.coursePurchaseOrderAppService = coursePurchaseOrderAppService;
+ }
+
+ /**
+ * 获取当前登录用户的购课记录列表。
+ */
+ @GetMapping("/course-purchase-orders")
+ public ApiResponse<PageResponse<AppCoursePurchaseOrderItemVO>> page(
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int pageSize,
+ @RequestParam(value = "productName", required = false) String productName) {
+ return ApiResponse.ok(coursePurchaseOrderAppService.appUserPage(page, pageSize, productName));
+ }
+
+ /**
+ * 获取当前登录用户的购课记录详情。
+ */
+ @GetMapping("/course-purchase-orders/{orderNo}")
+ public ApiResponse<AppCoursePurchaseOrderDetailVO> detail(@PathVariable("orderNo") String orderNo) {
+ return ApiResponse.ok(coursePurchaseOrderAppService.appUserDetail(orderNo));
+ }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppHealthCourseController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppHealthCourseController.java
index 7afa6a9..fe7bf53 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppHealthCourseController.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppHealthCourseController.java
@@ -1,12 +1,21 @@
package com.tcm.training.admin.app.course.controller;
+import com.tcm.training.admin.app.course.dto.AppHealthCourseDownloadBatchDeleteRequest;
+import com.tcm.training.admin.app.course.dto.AppHealthCourseDownloadReportRequest;
+import com.tcm.training.admin.app.course.dto.AppHealthCourseFavoriteBatchCancelRequest;
+import com.tcm.training.admin.app.course.dto.AppHealthCoursePlayRecordUploadRequest;
import com.tcm.training.admin.app.course.dto.AppHealthCoursePurchaseRequest;
+import com.tcm.training.admin.app.course.vo.AppHealthCourseDownloadItemVO;
import com.tcm.training.admin.app.course.service.AppHealthCourseService;
import com.tcm.training.admin.app.course.vo.AppHealthCourseCategoryOptionVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseDetailVO;
+import com.tcm.training.admin.app.course.vo.AppHealthCourseFavoriteItemVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseFavoriteResultVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseListItemVO;
+import com.tcm.training.admin.app.course.vo.AppHealthCoursePlayRecordUploadResultVO;
import com.tcm.training.admin.app.course.vo.AppHealthCoursePurchaseResultVO;
+import com.tcm.training.admin.app.course.vo.AppMyHealthCourseDetailVO;
+import com.tcm.training.admin.app.course.vo.AppMyHealthCourseItemVO;
import com.tcm.training.admin.common.ApiResponse;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.validation.annotation.Validated;
@@ -56,6 +65,75 @@
}
/**
+ * 获取当前登录用户的收藏课程列表。
+ */
+ @GetMapping("/health-courses/favorites")
+ public ApiResponse<List<AppHealthCourseFavoriteItemVO>> favorites() {
+ return ApiResponse.ok(appHealthCourseService.favorites());
+ }
+
+ /**
+ * 获取当前登录用户的下载记录列表。
+ */
+ @GetMapping("/health-course-downloads")
+ public ApiResponse<List<AppHealthCourseDownloadItemVO>> downloads() {
+ return ApiResponse.ok(appHealthCourseService.downloads());
+ }
+
+ /**
+ * 获取当前登录用户的我的课程列表。
+ */
+ @GetMapping("/my-health-courses")
+ public ApiResponse<List<AppMyHealthCourseItemVO>> myCourses() {
+ return ApiResponse.ok(appHealthCourseService.myCourses());
+ }
+
+ /**
+ * 删除当前登录用户的会员加课课程。
+ */
+ @DeleteMapping("/my-health-courses/{courseId}")
+ public ApiResponse<Void> removeMyCourse(@PathVariable("courseId") Long courseId) {
+ appHealthCourseService.removeMyCourse(courseId);
+ return ApiResponse.ok(null);
+ }
+
+ /**
+ * 获取当前登录用户的我的课程详情。
+ */
+ @GetMapping("/my-health-courses/{courseId}")
+ public ApiResponse<AppMyHealthCourseDetailVO> myCourseDetail(@PathVariable("courseId") Long courseId) {
+ return ApiResponse.ok(appHealthCourseService.myCourseDetail(courseId));
+ }
+
+ /**
+ * 上报当前登录用户的我的课程播放记录。
+ */
+ @PostMapping("/my-health-courses/{courseId}/videos/{videoId}/play-records")
+ public ApiResponse<AppHealthCoursePlayRecordUploadResultVO> uploadMyCoursePlayRecord(@PathVariable("courseId") Long courseId,
+ @PathVariable("videoId") Long videoId,
+ @Valid @RequestBody AppHealthCoursePlayRecordUploadRequest request) {
+ return ApiResponse.ok(appHealthCourseService.uploadMyCoursePlayRecord(courseId, videoId, request));
+ }
+
+ /**
+ * 上报当前登录用户完成下载的课程小节。
+ */
+ @PostMapping("/health-course-downloads")
+ public ApiResponse<Void> reportDownload(@Valid @RequestBody AppHealthCourseDownloadReportRequest request) {
+ appHealthCourseService.reportDownload(request);
+ return ApiResponse.ok(null);
+ }
+
+ /**
+ * 批量删除当前登录用户的下载记录。
+ */
+ @DeleteMapping("/health-course-downloads")
+ public ApiResponse<Void> batchDeleteDownloads(@Valid @RequestBody AppHealthCourseDownloadBatchDeleteRequest request) {
+ appHealthCourseService.batchDeleteDownloads(request);
+ return ApiResponse.ok(null);
+ }
+
+ /**
* 收藏 APP 养生课程。
*/
@PostMapping("/health-courses/{courseId}/favorite")
@@ -72,6 +150,15 @@
}
/**
+ * 批量取消 APP 养生课程收藏。
+ */
+ @DeleteMapping("/health-courses/favorites")
+ public ApiResponse<Void> batchCancelFavorite(@Valid @RequestBody AppHealthCourseFavoriteBatchCancelRequest request) {
+ appHealthCourseService.batchCancelFavorite(request);
+ return ApiResponse.ok(null);
+ }
+
+ /**
* 购买 APP 养生课程整课。
*/
@PostMapping("/health-courses/{courseId}/purchase")
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCourseDownloadBatchDeleteRequest.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCourseDownloadBatchDeleteRequest.java
new file mode 100644
index 0000000..d288851
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCourseDownloadBatchDeleteRequest.java
@@ -0,0 +1,21 @@
+package com.tcm.training.admin.app.course.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+/**
+ * APP 养生课程批量删除下载记录请求。
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class AppHealthCourseDownloadBatchDeleteRequest {
+
+ @NotEmpty(message = "请选择要删除的下载记录")
+ private List<@NotNull(message = "下载记录标识不能为空") Long> ids;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCourseDownloadReportRequest.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCourseDownloadReportRequest.java
new file mode 100644
index 0000000..1978613
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCourseDownloadReportRequest.java
@@ -0,0 +1,22 @@
+package com.tcm.training.admin.app.course.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.validation.constraints.NotNull;
+
+/**
+ * APP 养生课程下载完成上报请求。
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class AppHealthCourseDownloadReportRequest {
+
+ @NotNull(message = "课程标识不能为空")
+ private Long courseId;
+
+ @NotNull(message = "课程小节标识不能为空")
+ private Long courseVideoId;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCourseFavoriteBatchCancelRequest.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCourseFavoriteBatchCancelRequest.java
new file mode 100644
index 0000000..29082d7
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCourseFavoriteBatchCancelRequest.java
@@ -0,0 +1,21 @@
+package com.tcm.training.admin.app.course.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+/**
+ * APP 养生课程批量取消收藏请求。
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class AppHealthCourseFavoriteBatchCancelRequest {
+
+ @NotEmpty(message = "请选择要取消收藏的课程")
+ private List<@NotNull(message = "课程标识不能为空") Long> courseIds;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCoursePlayRecordUploadRequest.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCoursePlayRecordUploadRequest.java
new file mode 100644
index 0000000..d6531b0
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/dto/AppHealthCoursePlayRecordUploadRequest.java
@@ -0,0 +1,32 @@
+package com.tcm.training.admin.app.course.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+
+/**
+ * APP 养生课程播放记录上报请求。
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class AppHealthCoursePlayRecordUploadRequest {
+
+ @NotNull(message = "播放开始时间不能为空")
+ private LocalDateTime playStart;
+
+ @NotNull(message = "播放结束时间不能为空")
+ private LocalDateTime playEnd;
+
+ @NotNull(message = "播放时长不能为空")
+ @Min(value = 0, message = "播放时长不能小于0")
+ private Integer durationSeconds;
+
+ @NotNull(message = "播放位置不能为空")
+ @Min(value = 0, message = "播放位置不能小于0")
+ private Integer lastPlayPositionSeconds;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java
index 4d4f298..5d4fb5e 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java
@@ -3,32 +3,51 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
+import com.tcm.training.admin.app.course.dto.AppHealthCourseDownloadBatchDeleteRequest;
+import com.tcm.training.admin.app.course.dto.AppHealthCourseDownloadReportRequest;
+import com.tcm.training.admin.app.course.dto.AppHealthCourseFavoriteBatchCancelRequest;
+import com.tcm.training.admin.app.course.dto.AppHealthCoursePlayRecordUploadRequest;
import com.tcm.training.admin.app.course.dto.AppHealthCoursePurchaseRequest;
import com.tcm.training.admin.app.course.vo.AppHealthCourseCategoryOptionVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseDetailVO;
+import com.tcm.training.admin.app.course.vo.AppHealthCourseDownloadItemVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseExpertVO;
+import com.tcm.training.admin.app.course.vo.AppHealthCourseFavoriteItemVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseFavoriteResultVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseListItemVO;
+import com.tcm.training.admin.app.course.vo.AppHealthCoursePlayRecordUploadResultVO;
import com.tcm.training.admin.app.course.vo.AppHealthCoursePurchaseResultVO;
import com.tcm.training.admin.app.course.vo.AppHealthCourseVideoVO;
+import com.tcm.training.admin.app.course.vo.AppMyHealthCourseDetailVO;
+import com.tcm.training.admin.app.course.vo.AppMyHealthCourseItemVO;
+import com.tcm.training.admin.app.course.vo.AppMyHealthCourseVideoItemVO;
import com.tcm.training.admin.live.entity.CoursePurchaseOrder;
import com.tcm.training.admin.live.entity.Expert;
import com.tcm.training.admin.live.entity.HealthCourse;
+import com.tcm.training.admin.live.entity.HealthCourseDownloadRecord;
import com.tcm.training.admin.live.entity.HealthCourseFavoriteRecord;
import com.tcm.training.admin.live.entity.HealthCourseMemberJoinRecord;
+import com.tcm.training.admin.live.entity.HealthCoursePlayRecord;
import com.tcm.training.admin.live.entity.HealthCourseVideo;
import com.tcm.training.admin.live.entity.LiveCourseCategory;
import com.tcm.training.admin.live.mapper.CoursePurchaseOrderMapper;
import com.tcm.training.admin.live.mapper.ExpertMapper;
+import com.tcm.training.admin.live.mapper.HealthCourseDownloadRecordMapper;
import com.tcm.training.admin.live.mapper.HealthCourseFavoriteRecordMapper;
import com.tcm.training.admin.live.mapper.HealthCourseMapper;
import com.tcm.training.admin.live.mapper.HealthCourseMemberJoinRecordMapper;
+import com.tcm.training.admin.live.mapper.HealthCoursePlayRecordMapper;
import com.tcm.training.admin.live.mapper.HealthCourseVideoMapper;
import com.tcm.training.admin.live.mapper.LiveCourseCategoryMapper;
+import com.tcm.training.admin.live.row.AppHealthCourseDownloadRow;
+import com.tcm.training.admin.live.row.AppHealthCourseFavoriteRow;
+import com.tcm.training.admin.live.row.AppMyHealthCourseProgressRow;
+import com.tcm.training.admin.live.row.AppMyHealthCourseVideoProgressRow;
import com.tcm.training.admin.live.service.HealthCourseAppService;
import com.tcm.training.admin.live.service.LiveCourseCategoryAppService;
import com.tcm.training.admin.live.vo.HealthCourseDetailVO;
import com.tcm.training.admin.live.vo.HealthCourseListRow;
+import com.tcm.training.admin.live.vo.CoursePurchaseOrderListRow;
import com.tcm.training.admin.member.entity.AppUser;
import com.tcm.training.admin.member.mapper.UserMapper;
import com.tcm.training.admin.system.service.SystemSettingsAppService;
@@ -40,8 +59,11 @@
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.time.format.DateTimeFormatter;
@@ -68,8 +90,10 @@
private final HealthCourseVideoMapper healthCourseVideoMapper;
private final LiveCourseCategoryMapper liveCourseCategoryMapper;
private final ExpertMapper expertMapper;
+ private final HealthCourseDownloadRecordMapper healthCourseDownloadRecordMapper;
private final HealthCourseFavoriteRecordMapper healthCourseFavoriteRecordMapper;
private final HealthCourseMemberJoinRecordMapper healthCourseMemberJoinRecordMapper;
+ private final HealthCoursePlayRecordMapper healthCoursePlayRecordMapper;
private final AppAuthSessionService appAuthSessionService;
private final CoursePurchaseOrderMapper coursePurchaseOrderMapper;
private final UserMapper userMapper;
@@ -83,8 +107,10 @@
HealthCourseVideoMapper healthCourseVideoMapper,
LiveCourseCategoryMapper liveCourseCategoryMapper,
ExpertMapper expertMapper,
+ HealthCourseDownloadRecordMapper healthCourseDownloadRecordMapper,
HealthCourseFavoriteRecordMapper healthCourseFavoriteRecordMapper,
HealthCourseMemberJoinRecordMapper healthCourseMemberJoinRecordMapper,
+ HealthCoursePlayRecordMapper healthCoursePlayRecordMapper,
AppAuthSessionService appAuthSessionService,
CoursePurchaseOrderMapper coursePurchaseOrderMapper,
UserMapper userMapper,
@@ -94,8 +120,10 @@
this.healthCourseVideoMapper = healthCourseVideoMapper;
this.liveCourseCategoryMapper = liveCourseCategoryMapper;
this.expertMapper = expertMapper;
+ this.healthCourseDownloadRecordMapper = healthCourseDownloadRecordMapper;
this.healthCourseFavoriteRecordMapper = healthCourseFavoriteRecordMapper;
this.healthCourseMemberJoinRecordMapper = healthCourseMemberJoinRecordMapper;
+ this.healthCoursePlayRecordMapper = healthCoursePlayRecordMapper;
this.appAuthSessionService = appAuthSessionService;
this.coursePurchaseOrderMapper = coursePurchaseOrderMapper;
this.userMapper = userMapper;
@@ -168,6 +196,275 @@
detailVO.setCourseOwned(Boolean.valueOf(purchaseState.isCourseOwned()));
detailVO.setCoursePurchased(Boolean.valueOf(purchaseState.isCoursePurchased()));
return detailVO;
+ }
+
+ /**
+ * 获取当前登录用户的收藏课程列表。
+ */
+ public List<AppHealthCourseFavoriteItemVO> favorites() {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ List<AppHealthCourseFavoriteRow> rows = healthCourseFavoriteRecordMapper.selectAppFavoriteCourses(userId);
+ if (rows == null || rows.isEmpty()) {
+ return Collections.emptyList();
+ }
+ List<AppHealthCourseFavoriteItemVO> result = new ArrayList<AppHealthCourseFavoriteItemVO>(rows.size());
+ for (AppHealthCourseFavoriteRow row : rows) {
+ AppHealthCourseFavoriteItemVO itemVO = new AppHealthCourseFavoriteItemVO();
+ itemVO.setCourseId(row.getCourseId());
+ itemVO.setTitle(trimToEmpty(row.getTitle()));
+ itemVO.setCoverImage(trimToEmpty(row.getCoverImage()));
+ itemVO.setLevelName(trimToEmpty(row.getLevelName()));
+ itemVO.setDifficultyName(trimToEmpty(row.getDifficultyName()));
+ itemVO.setTeacherName(trimToEmpty(row.getTeacherName()));
+ itemVO.setTeacherAvatar(trimToEmpty(row.getTeacherAvatar()));
+ itemVO.setLearnUserCount(defaultNumber(row.getLearnUserCount()));
+ itemVO.setFavoritedAt(row.getFavoritedAt());
+ result.add(itemVO);
+ }
+ return result;
+ }
+
+ /**
+ * 获取当前登录用户的已完成下载列表。
+ */
+ public List<AppHealthCourseDownloadItemVO> downloads() {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ List<AppHealthCourseDownloadRow> rows = healthCourseDownloadRecordMapper.selectAppDownloads(userId);
+ if (rows == null || rows.isEmpty()) {
+ return Collections.emptyList();
+ }
+ List<AppHealthCourseDownloadItemVO> result = new ArrayList<AppHealthCourseDownloadItemVO>(rows.size());
+ for (AppHealthCourseDownloadRow row : rows) {
+ AppHealthCourseDownloadItemVO itemVO = new AppHealthCourseDownloadItemVO();
+ itemVO.setId(row.getId());
+ itemVO.setCourseId(row.getCourseId());
+ itemVO.setCourseVideoId(row.getCourseVideoId());
+ itemVO.setTitle(trimToEmpty(row.getTitle()));
+ itemVO.setSubtitle(trimToEmpty(row.getSubtitle()));
+ itemVO.setCoverImage(trimToEmpty(row.getCoverImage()));
+ itemVO.setDurationSeconds(defaultNumber(row.getDurationSeconds()));
+ itemVO.setDurationText(formatVideoDuration(itemVO.getDurationSeconds()));
+ itemVO.setVideoUrl(trimToEmpty(row.getVideoUrl()));
+ itemVO.setDownloadedAt(row.getDownloadedAt());
+ result.add(itemVO);
+ }
+ return result;
+ }
+
+ /**
+ * 获取当前登录用户的我的课程列表。
+ */
+ public List<AppMyHealthCourseItemVO> myCourses() {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ List<HealthCourseOwnershipSnapshot> snapshots = listMyCourseSnapshots(userId);
+ if (snapshots.isEmpty()) {
+ return Collections.emptyList();
+ }
+ Map<Long, CourseProgressSnapshot> progressSnapshotMap = buildCourseProgressSnapshotMap(userId, extractCourseIds(snapshots));
+ List<AppMyHealthCourseItemVO> result = new ArrayList<AppMyHealthCourseItemVO>(snapshots.size());
+ for (HealthCourseOwnershipSnapshot snapshot : snapshots) {
+ AppMyHealthCourseItemVO itemVO = new AppMyHealthCourseItemVO();
+ itemVO.setCourseId(snapshot.getCourseId());
+ itemVO.setTitle(trimToEmpty(snapshot.getTitle()));
+ itemVO.setCoverImage(trimToEmpty(snapshot.getCoverImage()));
+ itemVO.setHasHealthTag(Boolean.TRUE);
+ CourseProgressSnapshot progressSnapshot = progressSnapshotMap.get(snapshot.getCourseId());
+ int completedLessons = progressSnapshot == null ? 0 : progressSnapshot.getCompletedLessons();
+ int totalLessons = snapshot.getTotalLessons();
+ itemVO.setCompletedLessons(Integer.valueOf(completedLessons));
+ itemVO.setTotalLessons(Integer.valueOf(totalLessons));
+ itemVO.setProgressPercent(Integer.valueOf(calculateProgressPercent(completedLessons, totalLessons)));
+ itemVO.setSourceType(snapshot.getSourceType());
+ itemVO.setRemovable(Boolean.valueOf(snapshot.isRemovable()));
+ result.add(itemVO);
+ }
+ return result;
+ }
+
+ /**
+ * 删除当前登录用户的会员加课课程。
+ */
+ @Transactional
+ public void removeMyCourse(Long courseId) {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ requireEnabledCourse(courseId);
+ if (findLatestActivePaidCourseOrder(userId, courseId) != null) {
+ throw new IllegalArgumentException("已购买课程不支持移除");
+ }
+ HealthCourseMemberJoinRecord joinRecord = findMemberJoinRecord(courseId, userId);
+ if (joinRecord == null) {
+ throw new IllegalArgumentException("课程不存在或已移除");
+ }
+ healthCourseMemberJoinRecordMapper.update(null, new LambdaUpdateWrapper<HealthCourseMemberJoinRecord>()
+ .eq(HealthCourseMemberJoinRecord::getId, joinRecord.getId())
+ .eq(HealthCourseMemberJoinRecord::getAppUserId, userId)
+ .eq(HealthCourseMemberJoinRecord::getDeleted, 0)
+ .set(HealthCourseMemberJoinRecord::getDeleted, 1));
+ }
+
+ /**
+ * 获取当前登录用户的我的课程详情。
+ */
+ public AppMyHealthCourseDetailVO myCourseDetail(Long courseId) {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ HealthCourseOwnershipSnapshot ownershipSnapshot = requireOwnedCourse(userId, courseId);
+ HealthCourse course = requireEnabledCourse(courseId);
+ List<HealthCourseVideo> enabledVideos = listEnabledVideos(courseId);
+ Map<Long, VideoProgressSnapshot> videoProgressSnapshotMap = buildVideoProgressSnapshotMap(userId, Collections.singletonList(courseId));
+
+ List<AppMyHealthCourseVideoItemVO> videos = new ArrayList<AppMyHealthCourseVideoItemVO>(enabledVideos.size());
+ Long currentVideoId = null;
+ LocalDateTime latestLearnAt = null;
+ for (HealthCourseVideo video : enabledVideos) {
+ VideoProgressSnapshot snapshot = videoProgressSnapshotMap.get(video.getId());
+ AppMyHealthCourseVideoItemVO itemVO = new AppMyHealthCourseVideoItemVO();
+ itemVO.setVideoId(video.getId());
+ itemVO.setChapterName(trimToEmpty(video.getChapterName()));
+ itemVO.setDurationSeconds(defaultNumber(video.getDurationSeconds()));
+ itemVO.setDurationText(formatVideoDuration(itemVO.getDurationSeconds()));
+ itemVO.setVideoUrl(trimToEmpty(video.getVideoUrl()));
+ itemVO.setCompleted(Boolean.valueOf(snapshot != null && snapshot.isCompleted()));
+ itemVO.setProgressPercent(Integer.valueOf(calculateVideoDisplayProgressPercent(
+ snapshot == null ? 0 : snapshot.getCumulativeWatchSeconds(),
+ itemVO.getDurationSeconds().intValue(),
+ snapshot != null && snapshot.isCompleted()
+ )));
+ itemVO.setLastPlayPositionSeconds(Integer.valueOf(resolveVideoResumePosition(snapshot)));
+ itemVO.setDownloadable(Boolean.valueOf(hasVideoDownloadAccess(userId, courseId, video.getId())));
+ videos.add(itemVO);
+ if (snapshot != null && snapshot.getLatestLearnAt() != null
+ && (latestLearnAt == null || snapshot.getLatestLearnAt().isAfter(latestLearnAt))) {
+ latestLearnAt = snapshot.getLatestLearnAt();
+ currentVideoId = video.getId();
+ }
+ }
+ if (currentVideoId == null && !enabledVideos.isEmpty()) {
+ currentVideoId = enabledVideos.get(0).getId();
+ }
+
+ AppMyHealthCourseDetailVO detailVO = new AppMyHealthCourseDetailVO();
+ detailVO.setCourseId(course.getId());
+ detailVO.setTitle(trimToEmpty(course.getTitle()));
+ detailVO.setCourseTitle(trimToEmpty(course.getTitle()));
+ detailVO.setCoverImage(trimToEmpty(course.getCoverImg()));
+ detailVO.setTotalLessons(Integer.valueOf(enabledVideos.size()));
+ detailVO.setTotalDurationText(formatCourseDuration(resolveTotalDurationSecondsByEntity(course, enabledVideos)));
+ detailVO.setCurrentVideoId(currentVideoId);
+ detailVO.setServicePhone(resolveServicePhone());
+ detailVO.setIntroAvailable(Boolean.TRUE);
+ detailVO.setVideos(videos);
+ return detailVO;
+ }
+
+ /**
+ * 上报当前登录用户的我的课程播放记录。
+ */
+ @Transactional
+ public AppHealthCoursePlayRecordUploadResultVO uploadMyCoursePlayRecord(Long courseId,
+ Long videoId,
+ AppHealthCoursePlayRecordUploadRequest request) {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ requireOwnedCourse(userId, courseId);
+ HealthCourseVideo video = requireEnabledVideo(courseId, videoId);
+ validatePlayRecordRequest(request);
+
+ int videoDurationSeconds = defaultNumber(video.getDurationSeconds());
+ int cumulativeWatchSeconds = resolveExistingCumulativeWatchSeconds(userId, courseId, videoId)
+ + defaultNumber(request.getDurationSeconds());
+ boolean completed = videoDurationSeconds > 0 && cumulativeWatchSeconds >= videoDurationSeconds;
+ int lastPlayPositionSeconds = completed ? 0 : normalizePlayPosition(request.getLastPlayPositionSeconds(), videoDurationSeconds);
+
+ HealthCoursePlayRecord playRecord = new HealthCoursePlayRecord();
+ playRecord.setCourseId(courseId);
+ playRecord.setVideoId(videoId);
+ playRecord.setAppUserId(userId);
+ playRecord.setPlayStart(request.getPlayStart());
+ playRecord.setPlayEnd(request.getPlayEnd());
+ playRecord.setDurationSeconds(defaultNumber(request.getDurationSeconds()));
+ playRecord.setIsCompleted(completed ? 1 : 0);
+ playRecord.setLastPlayPositionSeconds(lastPlayPositionSeconds);
+ healthCoursePlayRecordMapper.insert(playRecord);
+
+ Map<Long, VideoProgressSnapshot> videoProgressSnapshotMap = buildVideoProgressSnapshotMap(userId, Collections.singletonList(courseId));
+ VideoProgressSnapshot videoProgressSnapshot = videoProgressSnapshotMap.get(videoId);
+ CourseProgressSnapshot courseProgressSnapshot = buildCourseProgressSnapshotMap(userId, Collections.singletonList(courseId)).get(courseId);
+
+ AppHealthCoursePlayRecordUploadResultVO resultVO = new AppHealthCoursePlayRecordUploadResultVO();
+ resultVO.setLastPlayPositionSeconds(Integer.valueOf(resolveVideoResumePosition(videoProgressSnapshot)));
+ resultVO.setCompleted(Boolean.valueOf(videoProgressSnapshot != null && videoProgressSnapshot.isCompleted()));
+ resultVO.setProgressPercent(Integer.valueOf(calculateVideoDisplayProgressPercent(
+ videoProgressSnapshot == null ? 0 : videoProgressSnapshot.getCumulativeWatchSeconds(),
+ videoDurationSeconds,
+ videoProgressSnapshot != null && videoProgressSnapshot.isCompleted()
+ )));
+ int totalLessons = listEnabledVideos(courseId).size();
+ int completedLessons = courseProgressSnapshot == null ? 0 : courseProgressSnapshot.getCompletedLessons();
+ resultVO.setCompletedLessons(Integer.valueOf(completedLessons));
+ resultVO.setTotalLessons(Integer.valueOf(totalLessons));
+ resultVO.setCourseProgressPercent(Integer.valueOf(calculateProgressPercent(completedLessons, totalLessons)));
+ return resultVO;
+ }
+
+ /**
+ * 记录当前登录用户的课程小节下载完成状态。
+ */
+ @Transactional
+ public void reportDownload(AppHealthCourseDownloadReportRequest request) {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ Long courseId = request == null ? null : request.getCourseId();
+ Long courseVideoId = request == null ? null : request.getCourseVideoId();
+ HealthCourse course = requireEnabledCourse(courseId);
+ HealthCourseVideo video = requireEnabledVideo(courseId, courseVideoId);
+ if (trimToEmpty(video.getVideoUrl()).isEmpty()) {
+ throw new IllegalArgumentException("当前课程小节暂未配置视频地址");
+ }
+ if (!hasVideoDownloadAccess(userId, courseId, courseVideoId)) {
+ throw new IllegalArgumentException("当前课程小节未购买,无法下载");
+ }
+ HealthCourseDownloadRecord existingRecord = findActiveDownloadRecord(userId, courseVideoId);
+ if (existingRecord == null) {
+ HealthCourseDownloadRecord record = buildDownloadRecord(userId, course, video);
+ healthCourseDownloadRecordMapper.insert(record);
+ return;
+ }
+ // 重复下载同一小节时只刷新快照与更新时间,避免列表产生重复项。
+ HealthCourseDownloadRecord refreshRecord = buildDownloadRecord(userId, course, video);
+ refreshRecord.setId(existingRecord.getId());
+ healthCourseDownloadRecordMapper.updateById(refreshRecord);
+ }
+
+ /**
+ * 批量删除当前登录用户的下载记录。
+ */
+ @Transactional
+ public void batchDeleteDownloads(AppHealthCourseDownloadBatchDeleteRequest request) {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ Set<Long> recordIds = normalizeDownloadIds(request);
+ if (recordIds.isEmpty()) {
+ throw new IllegalArgumentException("请选择要删除的下载记录");
+ }
+ for (Long recordId : recordIds) {
+ healthCourseDownloadRecordMapper.update(null, new LambdaUpdateWrapper<HealthCourseDownloadRecord>()
+ .eq(HealthCourseDownloadRecord::getId, recordId)
+ .eq(HealthCourseDownloadRecord::getAppUserId, userId)
+ .eq(HealthCourseDownloadRecord::getDeleted, 0)
+ .set(HealthCourseDownloadRecord::getDeleted, 1));
+ }
+ }
+
+ /**
+ * 批量取消当前登录用户的养生课程收藏。
+ */
+ @Transactional
+ public void batchCancelFavorite(AppHealthCourseFavoriteBatchCancelRequest request) {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ Set<Long> courseIds = normalizeCourseIds(request);
+ if (courseIds.isEmpty()) {
+ throw new IllegalArgumentException("请选择要取消收藏的课程");
+ }
+ for (Long courseId : courseIds) {
+ cancelFavoriteIfNeeded(courseId, userId);
+ }
}
/**
@@ -317,6 +614,242 @@
}
/**
+ * 汇总我的课程来源,已购课程优先级高于会员加课。
+ */
+ private List<HealthCourseOwnershipSnapshot> listMyCourseSnapshots(Long userId) {
+ Map<Long, HealthCourseOwnershipSnapshot> snapshotMap = new HashMap<Long, HealthCourseOwnershipSnapshot>();
+ List<CoursePurchaseOrderListRow> paidCourseRows = coursePurchaseOrderMapper.selectPaidCourseSnapshots(userId);
+ if (paidCourseRows != null) {
+ for (CoursePurchaseOrderListRow row : paidCourseRows) {
+ Long courseId = row.getCourseId();
+ if (courseId == null || snapshotMap.containsKey(courseId)) {
+ continue;
+ }
+ HealthCourse course = healthCourseMapper.selectById(courseId);
+ if (!isEnabledCourse(course)) {
+ continue;
+ }
+ snapshotMap.put(courseId, buildOwnershipSnapshot(course, "PURCHASED", false));
+ }
+ }
+ List<Long> memberCourseIds = healthCourseMemberJoinRecordMapper.selectActiveCourseIdsByUserId(userId);
+ if (memberCourseIds != null) {
+ for (Long courseId : memberCourseIds) {
+ if (courseId == null || snapshotMap.containsKey(courseId)) {
+ continue;
+ }
+ HealthCourse course = healthCourseMapper.selectById(courseId);
+ if (!isEnabledCourse(course)) {
+ continue;
+ }
+ snapshotMap.put(courseId, buildOwnershipSnapshot(course, "MEMBER_JOIN", true));
+ }
+ }
+ List<HealthCourseOwnershipSnapshot> snapshots = new ArrayList<HealthCourseOwnershipSnapshot>(snapshotMap.values());
+ Collections.sort(snapshots, Comparator.comparing(HealthCourseOwnershipSnapshot::getCourseId).reversed());
+ return snapshots;
+ }
+
+ /**
+ * 校验课程是否属于当前登录用户的我的课程。
+ */
+ private HealthCourseOwnershipSnapshot requireOwnedCourse(Long userId, Long courseId) {
+ List<HealthCourseOwnershipSnapshot> snapshots = listMyCourseSnapshots(userId);
+ for (HealthCourseOwnershipSnapshot snapshot : snapshots) {
+ if (snapshot.getCourseId().equals(courseId)) {
+ return snapshot;
+ }
+ }
+ throw new IllegalArgumentException("课程不存在或未加入我的课程");
+ }
+
+ /**
+ * 构建课程归属快照,供我的课程相关接口复用。
+ */
+ private HealthCourseOwnershipSnapshot buildOwnershipSnapshot(HealthCourse course, String sourceType, boolean removable) {
+ return new HealthCourseOwnershipSnapshot(
+ course.getId(),
+ trimToEmpty(course.getTitle()),
+ trimToEmpty(course.getCoverImg()),
+ countEnabledVideos(course.getId()),
+ sourceType,
+ removable
+ );
+ }
+
+ /**
+ * 批量构建课程整体进度快照。
+ */
+ private Map<Long, CourseProgressSnapshot> buildCourseProgressSnapshotMap(Long userId, List<Long> courseIds) {
+ if (courseIds == null || courseIds.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ List<AppMyHealthCourseProgressRow> rows = healthCoursePlayRecordMapper.selectCourseProgressByCourseIds(userId, courseIds);
+ Map<Long, CourseProgressSnapshot> snapshotMap = new HashMap<Long, CourseProgressSnapshot>();
+ if (rows == null) {
+ return snapshotMap;
+ }
+ for (AppMyHealthCourseProgressRow row : rows) {
+ if (row.getCourseId() == null) {
+ continue;
+ }
+ snapshotMap.put(row.getCourseId(), new CourseProgressSnapshot(
+ defaultNumber(row.getCompletedLessons()),
+ row.getLatestLearnAt()
+ ));
+ }
+ return snapshotMap;
+ }
+
+ /**
+ * 批量构建章节进度快照,并以章节ID作为索引键。
+ */
+ private Map<Long, VideoProgressSnapshot> buildVideoProgressSnapshotMap(Long userId, List<Long> courseIds) {
+ if (courseIds == null || courseIds.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ List<AppMyHealthCourseVideoProgressRow> rows = healthCoursePlayRecordMapper.selectVideoProgressByCourseIds(userId, courseIds);
+ Map<Long, VideoProgressSnapshot> snapshotMap = new HashMap<Long, VideoProgressSnapshot>();
+ if (rows == null) {
+ return snapshotMap;
+ }
+ for (AppMyHealthCourseVideoProgressRow row : rows) {
+ if (row.getVideoId() == null) {
+ continue;
+ }
+ snapshotMap.put(row.getVideoId(), new VideoProgressSnapshot(
+ defaultNumber(row.getCumulativeWatchSeconds()),
+ row.getCompleted() != null && row.getCompleted().intValue() == 1,
+ defaultNumber(row.getLastPlayPositionSeconds()),
+ row.getLatestLearnAt()
+ ));
+ }
+ return snapshotMap;
+ }
+
+ /**
+ * 提取课程主键集合,便于批量查询进度。
+ */
+ private List<Long> extractCourseIds(List<HealthCourseOwnershipSnapshot> snapshots) {
+ List<Long> courseIds = new ArrayList<Long>(snapshots.size());
+ for (HealthCourseOwnershipSnapshot snapshot : snapshots) {
+ courseIds.add(snapshot.getCourseId());
+ }
+ return courseIds;
+ }
+
+ /**
+ * 统计课程启用章节数量。
+ */
+ private int countEnabledVideos(Long courseId) {
+ return listEnabledVideos(courseId).size();
+ }
+
+ /**
+ * 计算百分比进度。
+ */
+ private int calculateProgressPercent(int current, int total) {
+ if (total <= 0 || current <= 0) {
+ return 0;
+ }
+ if (current >= total) {
+ return 100;
+ }
+ return Math.max(Math.min((int) Math.round((current * 100.0D) / total), 100), 0);
+ }
+
+ /**
+ * 计算章节播放进度展示值,未完播章节最高仅展示到99%,避免与完播态混淆。
+ */
+ private int calculateVideoDisplayProgressPercent(int current, int total, boolean completed) {
+ int progressPercent = calculateProgressPercent(current, total);
+ if (!completed && progressPercent >= 100) {
+ return 99;
+ }
+ return progressPercent;
+ }
+
+ /**
+ * 解析章节续播位置,已完播章节再次进入时从头播放。
+ */
+ private int resolveVideoResumePosition(VideoProgressSnapshot snapshot) {
+ if (snapshot == null || snapshot.isCompleted()) {
+ return 0;
+ }
+ return Math.max(snapshot.getLastPlayPositionSeconds(), 0);
+ }
+
+ /**
+ * 校验播放记录上报请求。
+ */
+ private void validatePlayRecordRequest(AppHealthCoursePlayRecordUploadRequest request) {
+ if (request == null) {
+ throw new IllegalArgumentException("播放记录不能为空");
+ }
+ if (request.getPlayStart() == null || request.getPlayEnd() == null) {
+ throw new IllegalArgumentException("播放时间不能为空");
+ }
+ if (request.getPlayEnd().isBefore(request.getPlayStart())) {
+ throw new IllegalArgumentException("播放结束时间不能早于开始时间");
+ }
+ }
+
+ /**
+ * 查询当前章节已累计观看时长。
+ */
+ private int resolveExistingCumulativeWatchSeconds(Long userId, Long courseId, Long videoId) {
+ List<HealthCoursePlayRecord> records = healthCoursePlayRecordMapper.selectList(new LambdaQueryWrapper<HealthCoursePlayRecord>()
+ .eq(HealthCoursePlayRecord::getAppUserId, userId)
+ .eq(HealthCoursePlayRecord::getCourseId, courseId)
+ .eq(HealthCoursePlayRecord::getVideoId, videoId));
+ if (records == null || records.isEmpty()) {
+ return 0;
+ }
+ int sum = 0;
+ for (HealthCoursePlayRecord record : records) {
+ sum += defaultNumber(record.getDurationSeconds());
+ }
+ return sum;
+ }
+
+ /**
+ * 归一化播放位置,避免超出视频时长。
+ */
+ private int normalizePlayPosition(Integer lastPlayPositionSeconds, int videoDurationSeconds) {
+ int position = defaultNumber(lastPlayPositionSeconds);
+ if (videoDurationSeconds <= 0) {
+ return Math.max(position, 0);
+ }
+ return Math.max(Math.min(position, videoDurationSeconds), 0);
+ }
+
+ /**
+ * 使用实体数据解析课程总时长。
+ */
+ private Integer resolveTotalDurationSecondsByEntity(HealthCourse course, List<HealthCourseVideo> videos) {
+ Integer totalDurationSeconds = course.getTotalDurationSeconds();
+ if (totalDurationSeconds != null && totalDurationSeconds.intValue() > 0) {
+ return totalDurationSeconds;
+ }
+ int sum = 0;
+ for (HealthCourseVideo video : videos) {
+ sum += defaultNumber(video.getDurationSeconds());
+ }
+ return Integer.valueOf(sum);
+ }
+
+ /**
+ * 判断课程是否为启用状态。
+ */
+ private boolean isEnabledCourse(HealthCourse course) {
+ return course != null
+ && course.getDeleted() != null
+ && course.getDeleted().intValue() == 0
+ && course.getStatus() != null
+ && course.getStatus().intValue() == COURSE_STATUS_ENABLED;
+ }
+
+ /**
* 判断当前请求用户是否已收藏课程;游客统一视为未收藏。
*/
private boolean resolveFavorited(Long courseId) {
@@ -328,6 +861,54 @@
return false;
}
return isFavorited(findLatestFavoriteRecord(courseId, userId));
+ }
+
+ /**
+ * 按课程集合规范化批量取消入参。
+ */
+ private Set<Long> normalizeCourseIds(AppHealthCourseFavoriteBatchCancelRequest request) {
+ if (request == null || request.getCourseIds() == null || request.getCourseIds().isEmpty()) {
+ return Collections.emptySet();
+ }
+ Set<Long> courseIds = new LinkedHashSet<Long>();
+ for (Long courseId : request.getCourseIds()) {
+ if (courseId != null) {
+ courseIds.add(courseId);
+ }
+ }
+ return courseIds;
+ }
+
+ /**
+ * 按下载记录集合规范化批量删除入参。
+ */
+ private Set<Long> normalizeDownloadIds(AppHealthCourseDownloadBatchDeleteRequest request) {
+ if (request == null || request.getIds() == null || request.getIds().isEmpty()) {
+ return Collections.emptySet();
+ }
+ Set<Long> recordIds = new LinkedHashSet<Long>();
+ for (Long id : request.getIds()) {
+ if (id != null) {
+ recordIds.add(id);
+ }
+ }
+ return recordIds;
+ }
+
+ /**
+ * 在当前课程仍为收藏态时写入取消流水并回收收藏数。
+ */
+ private void cancelFavoriteIfNeeded(Long courseId, Long userId) {
+ HealthCourse course = healthCourseMapper.selectById(courseId);
+ if (course == null || course.getDeleted() == null || course.getDeleted().intValue() != 0) {
+ return;
+ }
+ HealthCourseFavoriteRecord latestRecord = findLatestFavoriteRecord(courseId, userId);
+ if (!isFavorited(latestRecord)) {
+ return;
+ }
+ insertFavoriteRecord(courseId, userId, ACTION_TYPE_CANCEL);
+ updateFavoriteCount(courseId, false);
}
/**
@@ -495,6 +1076,17 @@
}
/**
+ * 判断当前用户是否拥有指定小节的下载权限。
+ */
+ private boolean hasVideoDownloadAccess(Long userId, Long courseId, Long courseVideoId) {
+ PurchaseStateSnapshot purchaseState = resolvePurchaseState(courseId);
+ if (purchaseState.isCourseOwned()) {
+ return true;
+ }
+ return purchaseState.getPurchasedVideoIds().contains(courseVideoId);
+ }
+
+ /**
* 读取当前用户对课程仍有效的已购小节集合。
*/
private List<Long> resolvePurchasedVideoIds(Long userId, Long courseId, boolean courseOwned) {
@@ -562,6 +1154,36 @@
.orderByDesc(CoursePurchaseOrder::getPaidAt)
.orderByDesc(CoursePurchaseOrder::getId)
.last("LIMIT 1"));
+ }
+
+ /**
+ * 查询当前用户该小节最近一条未删除下载记录。
+ */
+ private HealthCourseDownloadRecord findActiveDownloadRecord(Long userId, Long courseVideoId) {
+ return healthCourseDownloadRecordMapper.selectOne(new LambdaQueryWrapper<HealthCourseDownloadRecord>()
+ .eq(HealthCourseDownloadRecord::getAppUserId, userId)
+ .eq(HealthCourseDownloadRecord::getCourseVideoId, courseVideoId)
+ .eq(HealthCourseDownloadRecord::getDeleted, 0)
+ .orderByDesc(HealthCourseDownloadRecord::getUpdatedAt)
+ .orderByDesc(HealthCourseDownloadRecord::getId)
+ .last("LIMIT 1"));
+ }
+
+ /**
+ * 组装课程小节下载记录快照。
+ */
+ private HealthCourseDownloadRecord buildDownloadRecord(Long userId, HealthCourse course, HealthCourseVideo video) {
+ HealthCourseDownloadRecord record = new HealthCourseDownloadRecord();
+ record.setAppUserId(userId);
+ record.setCourseId(course.getId());
+ record.setCourseVideoId(video.getId());
+ record.setCourseTitleSnapshot(trimToEmpty(course.getTitle()));
+ record.setChapterNameSnapshot(trimToEmpty(video.getChapterName()));
+ record.setCoverImgSnapshot(trimToEmpty(course.getCoverImg()));
+ record.setVideoUrlSnapshot(trimToEmpty(video.getVideoUrl()));
+ record.setDurationSecondsSnapshot(defaultNumber(video.getDurationSeconds()));
+ record.setDeleted(0);
+ return record;
}
/**
@@ -913,4 +1535,114 @@
return purchasedVideoIds;
}
}
+
+ /**
+ * 我的课程来源快照。
+ */
+ private static final class HealthCourseOwnershipSnapshot {
+
+ private final Long courseId;
+ private final String title;
+ private final String coverImage;
+ private final int totalLessons;
+ private final String sourceType;
+ private final boolean removable;
+
+ private HealthCourseOwnershipSnapshot(Long courseId,
+ String title,
+ String coverImage,
+ int totalLessons,
+ String sourceType,
+ boolean removable) {
+ this.courseId = courseId;
+ this.title = title;
+ this.coverImage = coverImage;
+ this.totalLessons = totalLessons;
+ this.sourceType = sourceType;
+ this.removable = removable;
+ }
+
+ private Long getCourseId() {
+ return courseId;
+ }
+
+ private String getTitle() {
+ return title;
+ }
+
+ private String getCoverImage() {
+ return coverImage;
+ }
+
+ private int getTotalLessons() {
+ return totalLessons;
+ }
+
+ private String getSourceType() {
+ return sourceType;
+ }
+
+ private boolean isRemovable() {
+ return removable;
+ }
+ }
+
+ /**
+ * 我的课程整体进度快照。
+ */
+ private static final class CourseProgressSnapshot {
+
+ private final int completedLessons;
+ private final LocalDateTime latestLearnAt;
+
+ private CourseProgressSnapshot(int completedLessons, LocalDateTime latestLearnAt) {
+ this.completedLessons = completedLessons;
+ this.latestLearnAt = latestLearnAt;
+ }
+
+ private int getCompletedLessons() {
+ return completedLessons;
+ }
+
+ private LocalDateTime getLatestLearnAt() {
+ return latestLearnAt;
+ }
+ }
+
+ /**
+ * 我的课程章节进度快照。
+ */
+ private static final class VideoProgressSnapshot {
+
+ private final int cumulativeWatchSeconds;
+ private final boolean completed;
+ private final int lastPlayPositionSeconds;
+ private final LocalDateTime latestLearnAt;
+
+ private VideoProgressSnapshot(int cumulativeWatchSeconds,
+ boolean completed,
+ int lastPlayPositionSeconds,
+ LocalDateTime latestLearnAt) {
+ this.cumulativeWatchSeconds = cumulativeWatchSeconds;
+ this.completed = completed;
+ this.lastPlayPositionSeconds = lastPlayPositionSeconds;
+ this.latestLearnAt = latestLearnAt;
+ }
+
+ private int getCumulativeWatchSeconds() {
+ return cumulativeWatchSeconds;
+ }
+
+ private boolean isCompleted() {
+ return completed;
+ }
+
+ private int getLastPlayPositionSeconds() {
+ return lastPlayPositionSeconds;
+ }
+
+ private LocalDateTime getLatestLearnAt() {
+ return latestLearnAt;
+ }
+ }
}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppCoursePurchaseOrderDetailVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppCoursePurchaseOrderDetailVO.java
new file mode 100644
index 0000000..c284d26
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppCoursePurchaseOrderDetailVO.java
@@ -0,0 +1,33 @@
+package com.tcm.training.admin.app.course.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * APP 购课记录详情。
+ */
+@Getter
+@Setter
+public class AppCoursePurchaseOrderDetailVO {
+
+ private Long id;
+ private String orderNo;
+ private Long courseId;
+ private Long courseVideoId;
+ private String productName;
+ private String courseTitle;
+ private String chapterName;
+ private String coverImage;
+ private BigDecimal orderAmount;
+ private String paymentMethod;
+ private String paymentMethodText;
+ private String status;
+ private BigDecimal refundAmount;
+ private Boolean showRefundTag;
+ private LocalDateTime createdAt;
+ private LocalDateTime paidAt;
+ private LocalDateTime refundedAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppCoursePurchaseOrderItemVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppCoursePurchaseOrderItemVO.java
new file mode 100644
index 0000000..f8f6204
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppCoursePurchaseOrderItemVO.java
@@ -0,0 +1,30 @@
+package com.tcm.training.admin.app.course.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * APP 购课记录列表项。
+ */
+@Getter
+@Setter
+public class AppCoursePurchaseOrderItemVO {
+
+ private Long id;
+ private String orderNo;
+ private Long courseId;
+ private Long courseVideoId;
+ private String productName;
+ private String courseTitle;
+ private String chapterName;
+ private String coverImage;
+ private BigDecimal orderAmount;
+ private String paymentMethod;
+ private String paymentMethodText;
+ private String status;
+ private Boolean showRefundTag;
+ private LocalDateTime paidAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseDownloadItemVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseDownloadItemVO.java
new file mode 100644
index 0000000..cdeb69a
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseDownloadItemVO.java
@@ -0,0 +1,25 @@
+package com.tcm.training.admin.app.course.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * APP 我的下载列表项。
+ */
+@Getter
+@Setter
+public class AppHealthCourseDownloadItemVO {
+
+ private Long id;
+ private Long courseId;
+ private Long courseVideoId;
+ private String title;
+ private String subtitle;
+ private String coverImage;
+ private Integer durationSeconds;
+ private String durationText;
+ private String videoUrl;
+ private LocalDateTime downloadedAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseFavoriteItemVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseFavoriteItemVO.java
new file mode 100644
index 0000000..81494a3
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseFavoriteItemVO.java
@@ -0,0 +1,24 @@
+package com.tcm.training.admin.app.course.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * APP 收藏课程列表项。
+ */
+@Getter
+@Setter
+public class AppHealthCourseFavoriteItemVO {
+
+ private Long courseId;
+ private String title;
+ private String coverImage;
+ private String levelName;
+ private String difficultyName;
+ private String teacherName;
+ private String teacherAvatar;
+ private Integer learnUserCount;
+ private LocalDateTime favoritedAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCoursePlayRecordUploadResultVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCoursePlayRecordUploadResultVO.java
new file mode 100644
index 0000000..2c04d0c
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCoursePlayRecordUploadResultVO.java
@@ -0,0 +1,19 @@
+package com.tcm.training.admin.app.course.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * APP 养生课程播放记录上报结果。
+ */
+@Getter
+@Setter
+public class AppHealthCoursePlayRecordUploadResultVO {
+
+ private Integer lastPlayPositionSeconds;
+ private Boolean completed;
+ private Integer progressPercent;
+ private Integer completedLessons;
+ private Integer totalLessons;
+ private Integer courseProgressPercent;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppMyHealthCourseDetailVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppMyHealthCourseDetailVO.java
new file mode 100644
index 0000000..f905e44
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppMyHealthCourseDetailVO.java
@@ -0,0 +1,25 @@
+package com.tcm.training.admin.app.course.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.List;
+
+/**
+ * APP 我的课程详情响应。
+ */
+@Getter
+@Setter
+public class AppMyHealthCourseDetailVO {
+
+ private Long courseId;
+ private String title;
+ private String coverImage;
+ private String courseTitle;
+ private Integer totalLessons;
+ private String totalDurationText;
+ private Long currentVideoId;
+ private String servicePhone;
+ private Boolean introAvailable;
+ private List<AppMyHealthCourseVideoItemVO> videos;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppMyHealthCourseItemVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppMyHealthCourseItemVO.java
new file mode 100644
index 0000000..1c00bbe
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppMyHealthCourseItemVO.java
@@ -0,0 +1,22 @@
+package com.tcm.training.admin.app.course.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * APP 我的课程列表项。
+ */
+@Getter
+@Setter
+public class AppMyHealthCourseItemVO {
+
+ private Long courseId;
+ private String title;
+ private String coverImage;
+ private Boolean hasHealthTag;
+ private Integer completedLessons;
+ private Integer totalLessons;
+ private Integer progressPercent;
+ private String sourceType;
+ private Boolean removable;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppMyHealthCourseVideoItemVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppMyHealthCourseVideoItemVO.java
new file mode 100644
index 0000000..6293b58
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppMyHealthCourseVideoItemVO.java
@@ -0,0 +1,22 @@
+package com.tcm.training.admin.app.course.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * APP 我的课程详情章节项。
+ */
+@Getter
+@Setter
+public class AppMyHealthCourseVideoItemVO {
+
+ private Long videoId;
+ private String chapterName;
+ private Integer durationSeconds;
+ private String durationText;
+ private String videoUrl;
+ private Boolean completed;
+ private Integer progressPercent;
+ private Integer lastPlayPositionSeconds;
+ private Boolean downloadable;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/member/controller/AppMemberOpenPageController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/member/controller/AppMemberOpenPageController.java
index 4238921..128fa00 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/member/controller/AppMemberOpenPageController.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/member/controller/AppMemberOpenPageController.java
@@ -3,14 +3,17 @@
import com.tcm.training.admin.app.member.dto.AppMemberPurchaseRequest;
import com.tcm.training.admin.app.member.service.AppMemberPurchaseService;
import com.tcm.training.admin.app.member.service.AppMemberOpenPageService;
+import com.tcm.training.admin.app.member.vo.AppMemberPurchaseOrderItemVO;
import com.tcm.training.admin.app.member.vo.AppMemberOpenPageVO;
import com.tcm.training.admin.app.member.vo.AppMemberPurchaseResultVO;
import com.tcm.training.admin.common.ApiResponse;
+import com.tcm.training.admin.common.PageResponse;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
@@ -25,14 +28,17 @@
private final AppMemberOpenPageService appMemberOpenPageService;
private final AppMemberPurchaseService appMemberPurchaseService;
+ private final com.tcm.training.admin.member.service.MemberPurchaseOrderAppService memberPurchaseOrderAppService;
/**
* 注入 APP 会员开通页服务。
*/
public AppMemberOpenPageController(AppMemberOpenPageService appMemberOpenPageService,
- AppMemberPurchaseService appMemberPurchaseService) {
+ AppMemberPurchaseService appMemberPurchaseService,
+ com.tcm.training.admin.member.service.MemberPurchaseOrderAppService memberPurchaseOrderAppService) {
this.appMemberOpenPageService = appMemberOpenPageService;
this.appMemberPurchaseService = appMemberPurchaseService;
+ this.memberPurchaseOrderAppService = memberPurchaseOrderAppService;
}
/**
@@ -50,4 +56,14 @@
public ApiResponse<AppMemberPurchaseResultVO> purchase(@Valid @RequestBody AppMemberPurchaseRequest request) {
return ApiResponse.ok(appMemberPurchaseService.purchase(request));
}
+
+ /**
+ * 获取当前登录用户的会员购买记录。
+ */
+ @GetMapping("/purchase-orders")
+ public ApiResponse<PageResponse<AppMemberPurchaseOrderItemVO>> purchaseOrders(
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int pageSize) {
+ return ApiResponse.ok(memberPurchaseOrderAppService.appUserPage(page, pageSize));
+ }
}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/member/vo/AppMemberPurchaseOrderItemVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/member/vo/AppMemberPurchaseOrderItemVO.java
new file mode 100644
index 0000000..a6beb0b
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/member/vo/AppMemberPurchaseOrderItemVO.java
@@ -0,0 +1,27 @@
+package com.tcm.training.admin.app.member.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * APP 会员购买记录列表项。
+ */
+@Getter
+@Setter
+public class AppMemberPurchaseOrderItemVO {
+
+ private Long id;
+ private String orderNo;
+ private String memberType;
+ private String memberTypeText;
+ private BigDecimal orderAmount;
+ private String paymentMethod;
+ private String paymentMethodText;
+ private String status;
+ private BigDecimal refundAmount;
+ private Boolean showRefundTag;
+ private LocalDateTime paidAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/controller/AppPointsLedgerController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/controller/AppPointsLedgerController.java
new file mode 100644
index 0000000..ae8812e
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/controller/AppPointsLedgerController.java
@@ -0,0 +1,40 @@
+package com.tcm.training.admin.app.points.controller;
+
+import com.tcm.training.admin.app.points.service.AppPointsLedgerService;
+import com.tcm.training.admin.app.points.vo.AppPointsLedgerItemVO;
+import com.tcm.training.admin.common.ApiResponse;
+import com.tcm.training.admin.common.PageResponse;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * APP 积分明细接口。
+ */
+@Validated
+@RestController
+@RequestMapping({"/api/app/points", "/api/v1/app/points"})
+public class AppPointsLedgerController {
+
+ private final AppPointsLedgerService appPointsLedgerService;
+
+ /**
+ * 注入 APP 积分明细服务。
+ */
+ public AppPointsLedgerController(AppPointsLedgerService appPointsLedgerService) {
+ this.appPointsLedgerService = appPointsLedgerService;
+ }
+
+ /**
+ * 分页查询当前登录用户的积分明细。
+ */
+ @GetMapping("/ledger")
+ public ApiResponse<PageResponse<AppPointsLedgerItemVO>> page(
+ @RequestParam(required = false, defaultValue = "1") int page,
+ @RequestParam(required = false, defaultValue = "20") int pageSize,
+ @RequestParam(required = false) String incomeExpenseType) {
+ return ApiResponse.ok(appPointsLedgerService.page(page, pageSize, incomeExpenseType));
+ }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/controller/AppPointsOverviewController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/controller/AppPointsOverviewController.java
new file mode 100644
index 0000000..525618c
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/controller/AppPointsOverviewController.java
@@ -0,0 +1,35 @@
+package com.tcm.training.admin.app.points.controller;
+
+import com.tcm.training.admin.app.points.service.AppPointsOverviewService;
+import com.tcm.training.admin.app.points.vo.AppPointsOverviewVO;
+import com.tcm.training.admin.common.ApiResponse;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * APP 我的积分页面接口。
+ */
+@Validated
+@RestController
+@RequestMapping({"/api/app/points", "/api/v1/app/points"})
+public class AppPointsOverviewController {
+
+ private final AppPointsOverviewService appPointsOverviewService;
+
+ /**
+ * 注入 APP 我的积分页面服务。
+ */
+ public AppPointsOverviewController(AppPointsOverviewService appPointsOverviewService) {
+ this.appPointsOverviewService = appPointsOverviewService;
+ }
+
+ /**
+ * 获取当前登录用户的积分页面聚合数据。
+ */
+ @GetMapping("/overview")
+ public ApiResponse<AppPointsOverviewVO> overview() {
+ return ApiResponse.ok(appPointsOverviewService.getCurrentOverview());
+ }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/service/AppPointsLedgerService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/service/AppPointsLedgerService.java
new file mode 100644
index 0000000..475f636
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/service/AppPointsLedgerService.java
@@ -0,0 +1,113 @@
+package com.tcm.training.admin.app.points.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
+import com.tcm.training.admin.app.points.vo.AppPointsLedgerItemVO;
+import com.tcm.training.admin.common.PageResponse;
+import com.tcm.training.admin.points.row.PointsLedgerRow;
+import com.tcm.training.admin.points.service.PointsLedgerAppService;
+import com.tcm.training.admin.points.mapper.PointsLedgerMapper;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * APP 积分明细服务。
+ */
+@Service
+public class AppPointsLedgerService {
+
+ private final AppAuthSessionService appAuthSessionService;
+ private final PointsLedgerMapper pointsLedgerMapper;
+ private final PointsLedgerAppService pointsLedgerAppService;
+
+ /**
+ * 注入 APP 积分明细所需依赖。
+ */
+ public AppPointsLedgerService(AppAuthSessionService appAuthSessionService,
+ PointsLedgerMapper pointsLedgerMapper,
+ PointsLedgerAppService pointsLedgerAppService) {
+ this.appAuthSessionService = appAuthSessionService;
+ this.pointsLedgerMapper = pointsLedgerMapper;
+ this.pointsLedgerAppService = pointsLedgerAppService;
+ }
+
+ /**
+ * 分页查询当前登录用户的积分明细。
+ */
+ public PageResponse<AppPointsLedgerItemVO> page(int page, int pageSize, String incomeExpenseType) {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ int normalizedPage = page <= 0 ? 1 : page;
+ int normalizedPageSize = pageSize <= 0 ? 20 : pageSize;
+ pointsLedgerAppService.syncLedger();
+ IPage<PointsLedgerRow> result = pointsLedgerMapper.selectAppLedgerPage(
+ new Page<PointsLedgerRow>(normalizedPage, normalizedPageSize),
+ userId,
+ trimToNull(incomeExpenseType)
+ );
+ return PageResponse.of(
+ toItems(result.getRecords()),
+ result.getTotal(),
+ result.getCurrent(),
+ result.getSize()
+ );
+ }
+
+ /**
+ * 将积分流水行转换为 APP 页面需要的列表结构。
+ */
+ private List<AppPointsLedgerItemVO> toItems(List<PointsLedgerRow> rows) {
+ List<AppPointsLedgerItemVO> list = new ArrayList<AppPointsLedgerItemVO>();
+ if (rows == null) {
+ return list;
+ }
+ for (PointsLedgerRow row : rows) {
+ AppPointsLedgerItemVO item = new AppPointsLedgerItemVO();
+ item.setId(row.getId());
+ item.setTitle(buildTitle(row));
+ item.setIncomeExpenseType(trimToEmpty(row.getIncomeExpenseType()));
+ item.setPointsDelta(row.getPointsDelta() == null ? 0 : row.getPointsDelta());
+ item.setOperatedAt(row.getOperatedAt());
+ list.add(item);
+ }
+ return list;
+ }
+
+ /**
+ * 构建 APP 积分明细标题,保持与静态稿一致的业务文案。
+ */
+ private String buildTitle(PointsLedgerRow row) {
+ String contentName = trimToEmpty(row == null ? null : row.getContentName());
+ if (contentName.isEmpty()) {
+ contentName = "-";
+ }
+ String businessType = trimToEmpty(row == null ? null : row.getBusinessType());
+ if (PointsLedgerAppService.BUSINESS_LIVE_WATCH.equals(businessType)) {
+ return "观看课程【" + contentName + "】";
+ }
+ if (PointsLedgerAppService.BUSINESS_POINTS_EXCHANGE.equals(businessType)) {
+ return "兑换礼品【" + contentName + "】";
+ }
+ return "积分变动【" + contentName + "】";
+ }
+
+ /**
+ * 将可空文本归一化为空字符串。
+ */
+ private String trimToEmpty(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value.trim();
+ }
+
+ /**
+ * 将空白筛选值归一化为 null,便于 MyBatis 条件判断。
+ */
+ private String trimToNull(String value) {
+ String text = trimToEmpty(value);
+ return text.isEmpty() ? null : text;
+ }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/service/AppPointsOverviewService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/service/AppPointsOverviewService.java
new file mode 100644
index 0000000..53c0b5c
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/service/AppPointsOverviewService.java
@@ -0,0 +1,138 @@
+package com.tcm.training.admin.app.points.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
+import com.tcm.training.admin.app.points.vo.AppPointsOverviewVO;
+import com.tcm.training.admin.live.entity.Clinic;
+import com.tcm.training.admin.live.mapper.ClinicMapper;
+import com.tcm.training.admin.member.entity.AppUser;
+import com.tcm.training.admin.member.mapper.UserMapper;
+import com.tcm.training.admin.points.entity.PointsConfig;
+import com.tcm.training.admin.points.entity.UserPointsAccount;
+import com.tcm.training.admin.points.mapper.UserPointsAccountMapper;
+import com.tcm.training.admin.points.service.PointsSettingsAppService;
+import com.tcm.training.admin.points.vo.PointsConfigVO;
+import org.springframework.stereotype.Service;
+
+/**
+ * APP 我的积分页面聚合服务。
+ */
+@Service
+public class AppPointsOverviewService {
+
+ private static final int USER_STATUS_ENABLED = 1;
+
+ private final AppAuthSessionService appAuthSessionService;
+ private final UserMapper userMapper;
+ private final UserPointsAccountMapper userPointsAccountMapper;
+ private final ClinicMapper clinicMapper;
+ private final PointsSettingsAppService pointsSettingsAppService;
+
+ /**
+ * 注入 APP 我的积分页面所需依赖。
+ */
+ public AppPointsOverviewService(AppAuthSessionService appAuthSessionService,
+ UserMapper userMapper,
+ UserPointsAccountMapper userPointsAccountMapper,
+ ClinicMapper clinicMapper,
+ PointsSettingsAppService pointsSettingsAppService) {
+ this.appAuthSessionService = appAuthSessionService;
+ this.userMapper = userMapper;
+ this.userPointsAccountMapper = userPointsAccountMapper;
+ this.clinicMapper = clinicMapper;
+ this.pointsSettingsAppService = pointsSettingsAppService;
+ }
+
+ /**
+ * 获取当前登录用户的积分页面聚合数据。
+ */
+ public AppPointsOverviewVO getCurrentOverview() {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ AppUser user = requireEnabledUser(userId);
+ PointsConfigVO config = pointsSettingsAppService.current();
+
+ int pointsBalance = resolvePointsBalance(userId);
+ int requiredPoints = config == null || config.getRequiredPoints() == null ? 0 : Math.max(config.getRequiredPoints().intValue(), 0);
+
+ AppPointsOverviewVO result = new AppPointsOverviewVO();
+ result.setPointsBalance(Integer.valueOf(pointsBalance));
+ result.setRequiredPoints(Integer.valueOf(requiredPoints));
+ result.setProgressPercent(Integer.valueOf(calculateProgressPercent(pointsBalance, requiredPoints)));
+ result.setPointsShortfall(Integer.valueOf(Math.max(requiredPoints - pointsBalance, 0)));
+ result.setActivityStartAt(config == null ? null : config.getActivityStartAt());
+ result.setActivityEndAt(config == null ? null : config.getActivityEndAt());
+ result.setGiftCover(trimToEmpty(config == null ? null : config.getGiftCover()));
+ result.setGiftName(trimToEmpty(config == null ? null : config.getGiftName()));
+
+ Clinic clinic = resolveBoundClinic(user);
+ result.setClinicName(trimToEmpty(clinic == null ? null : clinic.getName()));
+ result.setClinicAddress(trimToEmpty(clinic == null ? null : clinic.getAddress()));
+ return result;
+ }
+
+ /**
+ * 校验当前登录用户存在且可用。
+ */
+ private AppUser requireEnabledUser(Long userId) {
+ AppUser user = userMapper.selectById(userId);
+ if (user == null || user.getDeleted() == null || user.getDeleted().intValue() != 0) {
+ throw new IllegalArgumentException("用户不存在");
+ }
+ if (user.getStatus() == null || user.getStatus().intValue() != USER_STATUS_ENABLED) {
+ throw new IllegalArgumentException("用户已禁用");
+ }
+ return user;
+ }
+
+ /**
+ * 读取当前用户积分余额,没有积分账户时按0返回。
+ */
+ private int resolvePointsBalance(Long userId) {
+ UserPointsAccount account = userPointsAccountMapper.selectOne(new LambdaQueryWrapper<UserPointsAccount>()
+ .eq(UserPointsAccount::getAppUserId, userId)
+ .eq(UserPointsAccount::getDeleted, 0)
+ .orderByDesc(UserPointsAccount::getId)
+ .last("LIMIT 1"));
+ if (account == null || account.getPointsBalance() == null) {
+ return 0;
+ }
+ return Math.max(account.getPointsBalance().intValue(), 0);
+ }
+
+ /**
+ * 读取当前用户绑定诊所,不存在时返回空。
+ */
+ private Clinic resolveBoundClinic(AppUser user) {
+ if (user == null || user.getClinicId() == null) {
+ return null;
+ }
+ Clinic clinic = clinicMapper.selectById(user.getClinicId());
+ if (clinic == null || clinic.getDeleted() == null || clinic.getDeleted().intValue() != 0) {
+ return null;
+ }
+ return clinic;
+ }
+
+ /**
+ * 计算积分进度百分比,避免返回非法值。
+ */
+ private int calculateProgressPercent(int current, int total) {
+ if (total <= 0 || current <= 0) {
+ return 0;
+ }
+ if (current >= total) {
+ return 100;
+ }
+ return Math.max(Math.min((int) Math.round((current * 100.0D) / total), 100), 0);
+ }
+
+ /**
+ * 将可空文本归一化为空字符串。
+ */
+ private String trimToEmpty(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value.trim();
+ }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/vo/AppPointsLedgerItemVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/vo/AppPointsLedgerItemVO.java
new file mode 100644
index 0000000..bc885d6
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/vo/AppPointsLedgerItemVO.java
@@ -0,0 +1,20 @@
+package com.tcm.training.admin.app.points.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * APP 积分明细列表项。
+ */
+@Getter
+@Setter
+public class AppPointsLedgerItemVO {
+
+ private Long id;
+ private String title;
+ private String incomeExpenseType;
+ private Integer pointsDelta;
+ private LocalDateTime operatedAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/vo/AppPointsOverviewVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/vo/AppPointsOverviewVO.java
new file mode 100644
index 0000000..bce8f27
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/points/vo/AppPointsOverviewVO.java
@@ -0,0 +1,25 @@
+package com.tcm.training.admin.app.points.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * APP 我的积分页面聚合数据响应。
+ */
+@Getter
+@Setter
+public class AppPointsOverviewVO {
+
+ private Integer pointsBalance;
+ private Integer requiredPoints;
+ private Integer progressPercent;
+ private Integer pointsShortfall;
+ private LocalDateTime activityStartAt;
+ private LocalDateTime activityEndAt;
+ private String giftCover;
+ private String giftName;
+ private String clinicName;
+ private String clinicAddress;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/controller/AppUserCenterController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/controller/AppUserCenterController.java
new file mode 100644
index 0000000..aed98c5
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/controller/AppUserCenterController.java
@@ -0,0 +1,35 @@
+package com.tcm.training.admin.app.user.controller;
+
+import com.tcm.training.admin.app.user.service.AppUserCenterService;
+import com.tcm.training.admin.app.user.vo.AppUserCenterVO;
+import com.tcm.training.admin.common.ApiResponse;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * APP 个人中心接口。
+ */
+@Validated
+@RestController
+@RequestMapping({"/api/app", "/api/v1/app"})
+public class AppUserCenterController {
+
+ private final AppUserCenterService appUserCenterService;
+
+ /**
+ * 注入 APP 个人中心服务。
+ */
+ public AppUserCenterController(AppUserCenterService appUserCenterService) {
+ this.appUserCenterService = appUserCenterService;
+ }
+
+ /**
+ * 获取当前登录用户的个人中心聚合信息。
+ */
+ @GetMapping("/user-center")
+ public ApiResponse<AppUserCenterVO> getCurrentUserCenter() {
+ return ApiResponse.ok(appUserCenterService.getCurrentUserCenter());
+ }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/service/AppUserCenterService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/service/AppUserCenterService.java
new file mode 100644
index 0000000..56ec3b4
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/service/AppUserCenterService.java
@@ -0,0 +1,154 @@
+package com.tcm.training.admin.app.user.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
+import com.tcm.training.admin.app.user.vo.AppUserCenterVO;
+import com.tcm.training.admin.live.entity.Clinic;
+import com.tcm.training.admin.live.mapper.ClinicMapper;
+import com.tcm.training.admin.member.entity.AppUser;
+import com.tcm.training.admin.member.mapper.UserMapper;
+import com.tcm.training.admin.points.entity.UserPointsAccount;
+import com.tcm.training.admin.points.mapper.UserPointsAccountMapper;
+import org.springframework.stereotype.Service;
+
+/**
+ * APP 个人中心聚合服务。
+ */
+@Service
+public class AppUserCenterService {
+
+ private static final int USER_STATUS_ENABLED = 1;
+ private static final int USER_TYPE_PERSONAL = 0;
+ private static final int USER_TYPE_DOCTOR = 1;
+
+ private final AppAuthSessionService appAuthSessionService;
+ private final UserMapper userMapper;
+ private final UserPointsAccountMapper userPointsAccountMapper;
+ private final ClinicMapper clinicMapper;
+
+ /**
+ * 注入 APP 个人中心聚合所需依赖。
+ */
+ public AppUserCenterService(AppAuthSessionService appAuthSessionService,
+ UserMapper userMapper,
+ UserPointsAccountMapper userPointsAccountMapper,
+ ClinicMapper clinicMapper) {
+ this.appAuthSessionService = appAuthSessionService;
+ this.userMapper = userMapper;
+ this.userPointsAccountMapper = userPointsAccountMapper;
+ this.clinicMapper = clinicMapper;
+ }
+
+ /**
+ * 获取当前登录用户的个人中心聚合信息。
+ */
+ public AppUserCenterVO getCurrentUserCenter() {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ AppUser user = requireEnabledUser(userId);
+ AppUserCenterVO result = new AppUserCenterVO();
+ result.setId(user.getId());
+ result.setNickname(resolveNickname(user));
+ result.setAvatar(resolveAvatar(user));
+ result.setPhone(trimToEmpty(user.getPhone()));
+ result.setType(user.getType());
+ result.setTypeText(resolveTypeText(user.getType()));
+ result.setMemberExpireAt(user.getMemberExpireAt());
+ result.setPointsBalance(resolvePointsBalance(user.getId()));
+ result.setClinicId(user.getClinicId());
+ // 医生用户才需要回显所属诊所,个人用户统一返回空诊所名称。
+ result.setClinicName(resolveClinicName(user));
+ return result;
+ }
+
+ /**
+ * 校验当前登录用户存在且可用。
+ */
+ private AppUser requireEnabledUser(Long userId) {
+ AppUser user = userMapper.selectById(userId);
+ if (user == null || user.getDeleted() == null || user.getDeleted().intValue() != 0) {
+ throw new IllegalArgumentException("用户不存在");
+ }
+ if (user.getStatus() == null || user.getStatus().intValue() != USER_STATUS_ENABLED) {
+ throw new IllegalArgumentException("用户已禁用");
+ }
+ return user;
+ }
+
+ /**
+ * 读取当前用户积分余额,没有积分账户时按 0 返回。
+ */
+ private Integer resolvePointsBalance(Long userId) {
+ UserPointsAccount account = userPointsAccountMapper.selectOne(new LambdaQueryWrapper<UserPointsAccount>()
+ .eq(UserPointsAccount::getAppUserId, userId)
+ .eq(UserPointsAccount::getDeleted, 0)
+ .orderByDesc(UserPointsAccount::getId)
+ .last("LIMIT 1"));
+ if (account == null || account.getPointsBalance() == null) {
+ return Integer.valueOf(0);
+ }
+ return account.getPointsBalance();
+ }
+
+ /**
+ * 读取医生用户所属诊所名称,不满足条件时返回空字符串。
+ */
+ private String resolveClinicName(AppUser user) {
+ if (user == null || user.getType() == null || user.getType().intValue() != USER_TYPE_DOCTOR || user.getClinicId() == null) {
+ return "";
+ }
+ Clinic clinic = clinicMapper.selectById(user.getClinicId());
+ if (clinic == null || clinic.getDeleted() == null || clinic.getDeleted().intValue() != 0) {
+ return "";
+ }
+ return trimToEmpty(clinic.getName());
+ }
+
+ /**
+ * 解析用户类型展示文案。
+ */
+ private String resolveTypeText(Integer userType) {
+ if (userType != null && userType.intValue() == USER_TYPE_DOCTOR) {
+ return "医生";
+ }
+ if (userType != null && userType.intValue() == USER_TYPE_PERSONAL) {
+ return "个人";
+ }
+ return "";
+ }
+
+ /**
+ * 优先使用本地昵称,没有时回退微信昵称和手机号。
+ */
+ private String resolveNickname(AppUser user) {
+ String nickname = trimToEmpty(user == null ? null : user.getNickname());
+ if (!nickname.isEmpty()) {
+ return nickname;
+ }
+ String wechatNickname = trimToEmpty(user == null ? null : user.getWechatNickname());
+ if (!wechatNickname.isEmpty()) {
+ return wechatNickname;
+ }
+ return trimToEmpty(user == null ? null : user.getPhone());
+ }
+
+ /**
+ * 优先使用本地头像,没有时回退微信头像。
+ */
+ private String resolveAvatar(AppUser user) {
+ String avatar = trimToEmpty(user == null ? null : user.getAvatar());
+ if (!avatar.isEmpty()) {
+ return avatar;
+ }
+ return trimToEmpty(user == null ? null : user.getWechatAvatar());
+ }
+
+ /**
+ * 将可空文本归一化为空字符串。
+ */
+ private String trimToEmpty(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value.trim();
+ }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/vo/AppUserCenterVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/vo/AppUserCenterVO.java
new file mode 100644
index 0000000..b47c63d
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/vo/AppUserCenterVO.java
@@ -0,0 +1,25 @@
+package com.tcm.training.admin.app.user.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * APP 个人中心聚合数据响应。
+ */
+@Getter
+@Setter
+public class AppUserCenterVO {
+
+ private Long id;
+ private String nickname;
+ private String avatar;
+ private String phone;
+ private Integer type;
+ private String typeText;
+ private LocalDateTime memberExpireAt;
+ private Integer pointsBalance;
+ private Long clinicId;
+ private String clinicName;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/HealthCourseDownloadRecord.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/HealthCourseDownloadRecord.java
new file mode 100644
index 0000000..a40fcd6
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/HealthCourseDownloadRecord.java
@@ -0,0 +1,65 @@
+package com.tcm.training.admin.live.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * 养生课程下载记录。
+ */
+@TableName("tcm_health_course_download_record")
+@Getter
+@Setter
+@NoArgsConstructor
+public class HealthCourseDownloadRecord {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ @TableField("app_user_id")
+ private Long appUserId;
+
+ @TableField("course_id")
+ private Long courseId;
+
+ @TableField("course_video_id")
+ private Long courseVideoId;
+
+ @TableField("course_title_snapshot")
+ private String courseTitleSnapshot;
+
+ @TableField("chapter_name_snapshot")
+ private String chapterNameSnapshot;
+
+ @TableField("cover_img_snapshot")
+ private String coverImgSnapshot;
+
+ @TableField("video_url_snapshot")
+ private String videoUrlSnapshot;
+
+ @TableField("duration_seconds_snapshot")
+ private Integer durationSecondsSnapshot;
+
+ @TableLogic
+ private Integer deleted;
+
+ @TableField(value = "created_by", fill = FieldFill.INSERT)
+ private String createdBy;
+
+ @TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE)
+ private String updatedBy;
+
+ @TableField("created_at")
+ private LocalDateTime createdAt;
+
+ @TableField("updated_at")
+ private LocalDateTime updatedAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/HealthCoursePlayRecord.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/HealthCoursePlayRecord.java
new file mode 100644
index 0000000..001ebda
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/HealthCoursePlayRecord.java
@@ -0,0 +1,48 @@
+package com.tcm.training.admin.live.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * 养生课程视频播放记录。
+ */
+@TableName("tcm_health_course_play_record")
+@Getter
+@Setter
+@NoArgsConstructor
+public class HealthCoursePlayRecord {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ @TableField("course_id")
+ private Long courseId;
+
+ @TableField("video_id")
+ private Long videoId;
+
+ @TableField("app_user_id")
+ private Long appUserId;
+
+ @TableField("play_start")
+ private LocalDateTime playStart;
+
+ @TableField("play_end")
+ private LocalDateTime playEnd;
+
+ @TableField("duration_seconds")
+ private Integer durationSeconds;
+
+ @TableField("is_completed")
+ private Integer isCompleted;
+
+ @TableField("last_play_position_seconds")
+ private Integer lastPlayPositionSeconds;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/CoursePurchaseOrderMapper.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/CoursePurchaseOrderMapper.java
index f954c92..45ffc00 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/CoursePurchaseOrderMapper.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/CoursePurchaseOrderMapper.java
@@ -22,6 +22,13 @@
@Param("createdFrom") LocalDateTime createdFrom,
@Param("createdTo") LocalDateTime createdTo);
+ IPage<CoursePurchaseOrderListRow> selectAppUserOrderPage(IPage<CoursePurchaseOrderListRow> page,
+ @Param("appUserId") Long appUserId,
+ @Param("productName") String productName);
+
+ CoursePurchaseOrderListRow selectAppUserOrderDetail(@Param("appUserId") Long appUserId,
+ @Param("orderNo") String orderNo);
+
List<CoursePurchaseOrderListRow> selectOrderList(@Param("orderNo") String orderNo,
@Param("userPhone") String userPhone,
@Param("productName") String productName,
@@ -29,4 +36,6 @@
@Param("status") String status,
@Param("createdFrom") LocalDateTime createdFrom,
@Param("createdTo") LocalDateTime createdTo);
+
+ List<CoursePurchaseOrderListRow> selectPaidCourseSnapshots(@Param("appUserId") Long appUserId);
}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseDownloadRecordMapper.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseDownloadRecordMapper.java
new file mode 100644
index 0000000..ee170ec
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseDownloadRecordMapper.java
@@ -0,0 +1,21 @@
+package com.tcm.training.admin.live.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.tcm.training.admin.live.entity.HealthCourseDownloadRecord;
+import com.tcm.training.admin.live.row.AppHealthCourseDownloadRow;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 养生课程下载记录 Mapper。
+ */
+@Mapper
+public interface HealthCourseDownloadRecordMapper extends BaseMapper<HealthCourseDownloadRecord> {
+
+ /**
+ * 查询当前用户的已完成下载记录列表。
+ */
+ List<AppHealthCourseDownloadRow> selectAppDownloads(@Param("appUserId") Long appUserId);
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseFavoriteRecordMapper.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseFavoriteRecordMapper.java
index 78ac6fd..8225bba 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseFavoriteRecordMapper.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseFavoriteRecordMapper.java
@@ -2,11 +2,17 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tcm.training.admin.live.entity.HealthCourseFavoriteRecord;
+import com.tcm.training.admin.live.row.AppHealthCourseFavoriteRow;
import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
/**
* 养生课程收藏流水 Mapper。
*/
@Mapper
public interface HealthCourseFavoriteRecordMapper extends BaseMapper<HealthCourseFavoriteRecord> {
+
+ List<AppHealthCourseFavoriteRow> selectAppFavoriteCourses(@Param("appUserId") Long appUserId);
}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseMemberJoinRecordMapper.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseMemberJoinRecordMapper.java
index 3cbc42a..757148c 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseMemberJoinRecordMapper.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseMemberJoinRecordMapper.java
@@ -3,10 +3,18 @@
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tcm.training.admin.live.entity.HealthCourseMemberJoinRecord;
import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
/**
* 养生课程会员加课记录 Mapper。
*/
@Mapper
public interface HealthCourseMemberJoinRecordMapper extends BaseMapper<HealthCourseMemberJoinRecord> {
+
+ /**
+ * 查询用户当前有效的会员加课课程主键集合。
+ */
+ List<Long> selectActiveCourseIdsByUserId(@Param("appUserId") Long appUserId);
}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCoursePlayRecordMapper.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCoursePlayRecordMapper.java
new file mode 100644
index 0000000..f711030
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCoursePlayRecordMapper.java
@@ -0,0 +1,29 @@
+package com.tcm.training.admin.live.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.tcm.training.admin.live.entity.HealthCoursePlayRecord;
+import com.tcm.training.admin.live.row.AppMyHealthCourseProgressRow;
+import com.tcm.training.admin.live.row.AppMyHealthCourseVideoProgressRow;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 养生课程视频播放记录 Mapper。
+ */
+@Mapper
+public interface HealthCoursePlayRecordMapper extends BaseMapper<HealthCoursePlayRecord> {
+
+ /**
+ * 查询指定用户在课程集合下的章节进度汇总。
+ */
+ List<AppMyHealthCourseVideoProgressRow> selectVideoProgressByCourseIds(@Param("appUserId") Long appUserId,
+ @Param("courseIds") List<Long> courseIds);
+
+ /**
+ * 查询指定用户在课程集合下的课程整体进度汇总。
+ */
+ List<AppMyHealthCourseProgressRow> selectCourseProgressByCourseIds(@Param("appUserId") Long appUserId,
+ @Param("courseIds") List<Long> courseIds);
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppHealthCourseDownloadRow.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppHealthCourseDownloadRow.java
new file mode 100644
index 0000000..e003e54
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppHealthCourseDownloadRow.java
@@ -0,0 +1,24 @@
+package com.tcm.training.admin.live.row;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * APP 养生课程下载列表行对象。
+ */
+@Getter
+@Setter
+public class AppHealthCourseDownloadRow {
+
+ private Long id;
+ private Long courseId;
+ private Long courseVideoId;
+ private String title;
+ private String subtitle;
+ private String coverImage;
+ private Integer durationSeconds;
+ private String videoUrl;
+ private LocalDateTime downloadedAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppHealthCourseFavoriteRow.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppHealthCourseFavoriteRow.java
new file mode 100644
index 0000000..d044f82
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppHealthCourseFavoriteRow.java
@@ -0,0 +1,26 @@
+package com.tcm.training.admin.live.row;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * APP 收藏课程查询行。
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class AppHealthCourseFavoriteRow {
+
+ private Long courseId;
+ private String title;
+ private String coverImage;
+ private String levelName;
+ private String difficultyName;
+ private String teacherName;
+ private String teacherAvatar;
+ private Integer learnUserCount;
+ private LocalDateTime favoritedAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppMyHealthCourseProgressRow.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppMyHealthCourseProgressRow.java
new file mode 100644
index 0000000..fa39ad6
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppMyHealthCourseProgressRow.java
@@ -0,0 +1,18 @@
+package com.tcm.training.admin.live.row;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * APP 我的课程整体进度聚合行。
+ */
+@Getter
+@Setter
+public class AppMyHealthCourseProgressRow {
+
+ private Long courseId;
+ private Integer completedLessons;
+ private LocalDateTime latestLearnAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppMyHealthCourseVideoProgressRow.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppMyHealthCourseVideoProgressRow.java
new file mode 100644
index 0000000..12c729d
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/row/AppMyHealthCourseVideoProgressRow.java
@@ -0,0 +1,21 @@
+package com.tcm.training.admin.live.row;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * APP 我的课程章节进度聚合行。
+ */
+@Getter
+@Setter
+public class AppMyHealthCourseVideoProgressRow {
+
+ private Long courseId;
+ private Long videoId;
+ private Integer cumulativeWatchSeconds;
+ private Integer completed;
+ private Integer lastPlayPositionSeconds;
+ private LocalDateTime latestLearnAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/CoursePurchaseOrderAppService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/CoursePurchaseOrderAppService.java
index c3daeb1..31d9d23 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/CoursePurchaseOrderAppService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/CoursePurchaseOrderAppService.java
@@ -4,6 +4,9 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
+import com.tcm.training.admin.app.course.vo.AppCoursePurchaseOrderDetailVO;
+import com.tcm.training.admin.app.course.vo.AppCoursePurchaseOrderItemVO;
import com.tcm.training.admin.common.DateTimes;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.common.PhoneMasker;
@@ -36,11 +39,20 @@
private static final String PAYMENT_METHOD_ALIPAY = "ALIPAY";
private final CoursePurchaseOrderMapper coursePurchaseOrderMapper;
+ private final AppAuthSessionService appAuthSessionService;
- public CoursePurchaseOrderAppService(CoursePurchaseOrderMapper coursePurchaseOrderMapper) {
+ /**
+ * 注入养生课程购买订单服务依赖。
+ */
+ public CoursePurchaseOrderAppService(CoursePurchaseOrderMapper coursePurchaseOrderMapper,
+ AppAuthSessionService appAuthSessionService) {
this.coursePurchaseOrderMapper = coursePurchaseOrderMapper;
+ this.appAuthSessionService = appAuthSessionService;
}
+ /**
+ * 分页查询后台养生课程购买订单。
+ */
public PageResponse<CoursePurchaseOrderListItemVO> page(int page,
int pageSize,
String orderNo,
@@ -63,6 +75,34 @@
return PageResponse.of(toList(result.getRecords()), result.getTotal(), result.getCurrent(), result.getSize());
}
+ /**
+ * 分页查询当前登录用户的 APP 购课记录。
+ */
+ public PageResponse<AppCoursePurchaseOrderItemVO> appUserPage(int page, int pageSize, String productName) {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ IPage<CoursePurchaseOrderListRow> result = coursePurchaseOrderMapper.selectAppUserOrderPage(
+ new Page<CoursePurchaseOrderListRow>(page, pageSize),
+ userId,
+ trimToNull(productName)
+ );
+ return PageResponse.of(toAppList(result.getRecords()), result.getTotal(), result.getCurrent(), result.getSize());
+ }
+
+ /**
+ * 获取当前登录用户的 APP 购课记录详情。
+ */
+ public AppCoursePurchaseOrderDetailVO appUserDetail(String orderNo) {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ CoursePurchaseOrderListRow row = coursePurchaseOrderMapper.selectAppUserOrderDetail(userId, trimToNull(orderNo));
+ if (row == null) {
+ throw new IllegalArgumentException("购课记录不存在");
+ }
+ return toAppDetail(row);
+ }
+
+ /**
+ * 导出后台养生课程购买订单。
+ */
public void export(HttpServletResponse response,
String orderNo,
String userPhone,
@@ -117,6 +157,9 @@
throw new IllegalArgumentException("退款功能待支付方案确认后接入");
}
+ /**
+ * 删除后台已退款养生课程购买订单。
+ */
@Transactional
public void delete(Long id) {
CoursePurchaseOrder order = requireOrder(id);
@@ -126,6 +169,9 @@
coursePurchaseOrderMapper.deleteById(id);
}
+ /**
+ * 校验养生课程购买订单存在。
+ */
private CoursePurchaseOrder requireOrder(Long id) {
CoursePurchaseOrder order = coursePurchaseOrderMapper.selectOne(new LambdaQueryWrapper<CoursePurchaseOrder>()
.eq(CoursePurchaseOrder::getId, id)
@@ -138,6 +184,9 @@
return order;
}
+ /**
+ * 将后台订单查询结果转换为后台列表响应。
+ */
private List<CoursePurchaseOrderListItemVO> toList(List<CoursePurchaseOrderListRow> rows) {
List<CoursePurchaseOrderListItemVO> list = new ArrayList<CoursePurchaseOrderListItemVO>();
for (CoursePurchaseOrderListRow row : rows) {
@@ -158,6 +207,62 @@
return list;
}
+ /**
+ * 将订单查询结果转换为 APP 购课记录列表响应。
+ */
+ private List<AppCoursePurchaseOrderItemVO> toAppList(List<CoursePurchaseOrderListRow> rows) {
+ List<AppCoursePurchaseOrderItemVO> list = new ArrayList<AppCoursePurchaseOrderItemVO>();
+ for (CoursePurchaseOrderListRow row : rows) {
+ AppCoursePurchaseOrderItemVO item = new AppCoursePurchaseOrderItemVO();
+ item.setId(row.getId());
+ item.setOrderNo(row.getOrderNo());
+ item.setCourseId(row.getCourseId());
+ item.setCourseVideoId(row.getCourseVideoId());
+ item.setProductName(row.getProductName());
+ item.setCourseTitle(row.getCourseTitle());
+ item.setChapterName(row.getChapterName());
+ item.setCoverImage(row.getCoverImage());
+ item.setOrderAmount(row.getOrderAmount());
+ item.setPaymentMethod(row.getPaymentMethod());
+ item.setPaymentMethodText(paymentMethodText(row.getPaymentMethod()));
+ item.setStatus(row.getStatus());
+ // APP 购课记录页只需标记是否退款,避免前端重复判断订单状态。
+ item.setShowRefundTag(Boolean.valueOf(STATUS_REFUNDED.equals(row.getStatus())));
+ item.setPaidAt(row.getPaidAt());
+ list.add(item);
+ }
+ return list;
+ }
+
+ /**
+ * 将订单详情查询结果转换为 APP 购课记录详情响应。
+ */
+ private AppCoursePurchaseOrderDetailVO toAppDetail(CoursePurchaseOrderListRow row) {
+ AppCoursePurchaseOrderDetailVO detail = new AppCoursePurchaseOrderDetailVO();
+ detail.setId(row.getId());
+ detail.setOrderNo(row.getOrderNo());
+ detail.setCourseId(row.getCourseId());
+ detail.setCourseVideoId(row.getCourseVideoId());
+ detail.setProductName(row.getProductName());
+ detail.setCourseTitle(row.getCourseTitle());
+ detail.setChapterName(row.getChapterName());
+ detail.setCoverImage(row.getCoverImage());
+ detail.setOrderAmount(row.getOrderAmount());
+ detail.setPaymentMethod(row.getPaymentMethod());
+ detail.setPaymentMethodText(paymentMethodText(row.getPaymentMethod()));
+ detail.setStatus(row.getStatus());
+ // 详情页退款信息直接由后端归一化,避免前端重复拼装退款状态。
+ detail.setRefundAmount(resolveRefundAmount(row.getStatus(), row.getOrderAmount(), row.getRefundAmount()));
+ detail.setShowRefundTag(Boolean.valueOf(STATUS_REFUNDED.equals(row.getStatus())));
+ detail.setCreatedAt(row.getCreatedAt());
+ detail.setPaidAt(row.getPaidAt());
+ detail.setRefundedAt(row.getRefundedAt());
+ return detail;
+ }
+
+ /**
+ * 解析订单应展示的退款金额。
+ */
private BigDecimal resolveRefundAmount(String status, BigDecimal orderAmount, BigDecimal refundAmount) {
if (!STATUS_REFUNDED.equals(status)) {
return null;
@@ -165,6 +270,9 @@
return refundAmount == null ? orderAmount : refundAmount;
}
+ /**
+ * 归一化后台筛选状态。
+ */
private String normalizeStatus(String status) {
String normalized = trimToNull(status);
if (normalized == null) {
@@ -176,6 +284,9 @@
return null;
}
+ /**
+ * 将空白字符串归一化为 null。
+ */
private String trimToNull(String value) {
if (value == null) {
return null;
@@ -184,10 +295,16 @@
return trimmed.isEmpty() ? null : trimmed;
}
+ /**
+ * 缺省文本兜底。
+ */
private String defaultText(String value) {
return value == null || value.trim().isEmpty() ? "-" : value.trim();
}
+ /**
+ * 解析支付方式展示文案。
+ */
private String paymentMethodText(String paymentMethod) {
if (PAYMENT_METHOD_WECHAT.equals(paymentMethod)) {
return "微信支付";
@@ -198,6 +315,9 @@
return "-";
}
+ /**
+ * 解析订单状态展示文案。
+ */
private String statusText(String status) {
if (STATUS_WAITING_PAYMENT.equals(status)) {
return "待支付";
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/CoursePurchaseOrderListRow.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/CoursePurchaseOrderListRow.java
index bf4602b..8caf6b9 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/CoursePurchaseOrderListRow.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/CoursePurchaseOrderListRow.java
@@ -14,13 +14,20 @@
private Long id;
private String orderNo;
+ private Long courseId;
+ private Long courseVideoId;
private String userNickname;
private String userPhone;
private String productName;
+ private String courseTitle;
+ private String chapterName;
+ private String coverImage;
private BigDecimal orderAmount;
private String paymentMethod;
private BigDecimal refundAmount;
private String refundRemark;
private String status;
+ private LocalDateTime paidAt;
+ private LocalDateTime refundedAt;
private LocalDateTime createdAt;
}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/mapper/MemberPurchaseOrderMapper.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/mapper/MemberPurchaseOrderMapper.java
index 3565db0..07ee83b 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/mapper/MemberPurchaseOrderMapper.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/mapper/MemberPurchaseOrderMapper.java
@@ -29,4 +29,7 @@
@Param("status") String status,
@Param("createdFrom") LocalDateTime createdFrom,
@Param("createdTo") LocalDateTime createdTo);
+
+ IPage<MemberPurchaseOrderRow> selectAppUserOrderPage(IPage<MemberPurchaseOrderRow> page,
+ @Param("appUserId") Long appUserId);
}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/MemberPurchaseOrderAppService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/MemberPurchaseOrderAppService.java
index eb886b5..649b403 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/MemberPurchaseOrderAppService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/MemberPurchaseOrderAppService.java
@@ -4,6 +4,8 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
+import com.tcm.training.admin.app.member.vo.AppMemberPurchaseOrderItemVO;
import com.tcm.training.admin.common.DateTimes;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.common.PhoneMasker;
@@ -37,11 +39,20 @@
private static final String PAYMENT_METHOD_ALIPAY = "ALIPAY";
private final MemberPurchaseOrderMapper memberPurchaseOrderMapper;
+ private final AppAuthSessionService appAuthSessionService;
- public MemberPurchaseOrderAppService(MemberPurchaseOrderMapper memberPurchaseOrderMapper) {
+ /**
+ * 注入会员订单查询能力。
+ */
+ public MemberPurchaseOrderAppService(MemberPurchaseOrderMapper memberPurchaseOrderMapper,
+ AppAuthSessionService appAuthSessionService) {
this.memberPurchaseOrderMapper = memberPurchaseOrderMapper;
+ this.appAuthSessionService = appAuthSessionService;
}
+ /**
+ * 分页查询后台会员购买订单。
+ */
public PageResponse<MemberPurchaseOrderListItemVO> page(int page,
int pageSize,
String orderNo,
@@ -64,6 +75,21 @@
return PageResponse.of(toList(result.getRecords()), result.getTotal(), result.getCurrent(), result.getSize());
}
+ /**
+ * 分页查询当前登录用户的 APP 会员购买记录。
+ */
+ public PageResponse<AppMemberPurchaseOrderItemVO> appUserPage(int page, int pageSize) {
+ Long userId = appAuthSessionService.requireLoginUserId();
+ IPage<MemberPurchaseOrderRow> result = memberPurchaseOrderMapper.selectAppUserOrderPage(
+ new Page<MemberPurchaseOrderRow>(page, pageSize),
+ userId
+ );
+ return PageResponse.of(toAppList(result.getRecords()), result.getTotal(), result.getCurrent(), result.getSize());
+ }
+
+ /**
+ * 导出后台会员购买订单。
+ */
public void export(String orderNo,
String userPhone,
String memberType,
@@ -116,6 +142,9 @@
throw new IllegalArgumentException("退款功能待支付方案确认后接入");
}
+ /**
+ * 删除后台已退款会员订单。
+ */
@Transactional
public void delete(Long id) {
MemberPurchaseOrder order = requireOrder(id);
@@ -125,6 +154,9 @@
memberPurchaseOrderMapper.deleteById(id);
}
+ /**
+ * 校验会员购买订单存在。
+ */
private MemberPurchaseOrder requireOrder(Long id) {
MemberPurchaseOrder order = memberPurchaseOrderMapper.selectOne(new LambdaQueryWrapper<MemberPurchaseOrder>()
.eq(MemberPurchaseOrder::getId, id)
@@ -136,6 +168,9 @@
return order;
}
+ /**
+ * 将后台订单查询结果转换为后台列表响应。
+ */
private List<MemberPurchaseOrderListItemVO> toList(List<MemberPurchaseOrderRow> rows) {
List<MemberPurchaseOrderListItemVO> list = new ArrayList<MemberPurchaseOrderListItemVO>();
for (MemberPurchaseOrderRow row : rows) {
@@ -159,6 +194,33 @@
return list;
}
+ /**
+ * 将订单查询结果转换为 APP 购买记录列表响应。
+ */
+ private List<AppMemberPurchaseOrderItemVO> toAppList(List<MemberPurchaseOrderRow> rows) {
+ List<AppMemberPurchaseOrderItemVO> list = new ArrayList<AppMemberPurchaseOrderItemVO>();
+ for (MemberPurchaseOrderRow row : rows) {
+ AppMemberPurchaseOrderItemVO item = new AppMemberPurchaseOrderItemVO();
+ item.setId(row.getId());
+ item.setOrderNo(row.getOrderNo());
+ item.setMemberType(row.getMemberType());
+ item.setMemberTypeText(memberTypeText(row.getMemberType()));
+ item.setOrderAmount(row.getOrderAmount());
+ item.setPaymentMethod(row.getPaymentMethod());
+ item.setPaymentMethodText(paymentMethodText(row.getPaymentMethod()));
+ item.setStatus(row.getStatus());
+ // APP 购买记录页在退款场景下要展示退款金额标签,没有单独退款额时回退订单金额。
+ item.setRefundAmount(resolveRefundAmount(row.getStatus(), row.getOrderAmount(), row.getRefundAmount()));
+ item.setShowRefundTag(Boolean.valueOf(STATUS_REFUNDED.equals(row.getStatus())));
+ item.setPaidAt(row.getPaidAt());
+ list.add(item);
+ }
+ return list;
+ }
+
+ /**
+ * 解析订单应展示的退款金额。
+ */
private BigDecimal resolveRefundAmount(String status, BigDecimal orderAmount, BigDecimal refundAmount) {
if (!STATUS_REFUNDED.equals(status)) {
return null;
@@ -177,6 +239,9 @@
return null;
}
+ /**
+ * 将空白文本归一化为 null。
+ */
private String trimToNull(String value) {
if (value == null) {
return null;
@@ -185,24 +250,36 @@
return trimmed.isEmpty() ? null : trimmed;
}
+ /**
+ * 缺省文本展示兜底。
+ */
private String defaultText(String value) {
return value == null || value.trim().isEmpty() ? "-" : value.trim();
}
+ /**
+ * 将会员天数转换为展示文案。
+ */
private String memberDaysText(Integer memberDays) {
return memberDays == null ? "-" : memberDays + "天";
}
+ /**
+ * 将会员类型转换为展示文案。
+ */
private String memberTypeText(String memberType) {
if (MEMBER_TYPE_MONTH_CARD.equals(memberType)) {
return "月卡";
}
if (MEMBER_TYPE_QUARTER_CARD.equals(memberType)) {
- return "季卡";
+ return "季度会员";
}
return "-";
}
+ /**
+ * 将支付方式转换为展示文案。
+ */
private String paymentMethodText(String paymentMethod) {
if (PAYMENT_METHOD_WECHAT.equals(paymentMethod)) {
return "微信支付";
@@ -213,6 +290,9 @@
return "-";
}
+ /**
+ * 将订单状态转换为展示文案。
+ */
private String statusText(String status) {
if (STATUS_WAITING_PAYMENT.equals(status)) {
return "待支付";
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/points/mapper/PointsLedgerMapper.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/points/mapper/PointsLedgerMapper.java
index 5905a76..e64c223 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/points/mapper/PointsLedgerMapper.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/points/mapper/PointsLedgerMapper.java
@@ -28,6 +28,10 @@
@Param("operatedFrom") LocalDateTime operatedFrom,
@Param("operatedTo") LocalDateTime operatedTo);
+ IPage<PointsLedgerRow> selectAppLedgerPage(IPage<PointsLedgerRow> page,
+ @Param("appUserId") Long appUserId,
+ @Param("incomeExpenseType") String incomeExpenseType);
+
List<PointsLedgerRow> selectLedgerList(@Param("userPhone") String userPhone,
@Param("regionCode") String regionCode,
@Param("clinicName") String clinicName,
diff --git a/tcm-training-admin-backend/src/main/resources/mapper/CoursePurchaseOrderMapper.xml b/tcm-training-admin-backend/src/main/resources/mapper/CoursePurchaseOrderMapper.xml
index c5f299c..3c690cb 100644
--- a/tcm-training-admin-backend/src/main/resources/mapper/CoursePurchaseOrderMapper.xml
+++ b/tcm-training-admin-backend/src/main/resources/mapper/CoursePurchaseOrderMapper.xml
@@ -34,36 +34,138 @@
SELECT
o.id,
o.order_no AS orderNo,
+ o.course_id AS courseId,
+ o.course_video_id AS courseVideoId,
o.user_nickname AS userNickname,
o.user_phone AS userPhone,
o.product_name_snapshot AS productName,
+ o.course_title_snapshot AS courseTitle,
+ o.chapter_name_snapshot AS chapterName,
+ o.cover_img_snapshot AS coverImage,
o.order_amount AS orderAmount,
o.payment_method AS paymentMethod,
o.refund_amount AS refundAmount,
o.refund_remark AS refundRemark,
o.status,
+ o.paid_at AS paidAt,
+ o.refunded_at AS refundedAt,
o.created_at AS createdAt
FROM tcm_course_purchase_order o
<include refid="CoursePurchaseOrderWhere"/>
ORDER BY o.id DESC
</select>
- <select id="selectOrderList" resultType="com.tcm.training.admin.live.vo.CoursePurchaseOrderListRow">
+ <select id="selectAppUserOrderPage" resultType="com.tcm.training.admin.live.vo.CoursePurchaseOrderListRow">
SELECT
o.id,
o.order_no AS orderNo,
+ o.course_id AS courseId,
+ o.course_video_id AS courseVideoId,
o.user_nickname AS userNickname,
o.user_phone AS userPhone,
o.product_name_snapshot AS productName,
+ o.course_title_snapshot AS courseTitle,
+ o.chapter_name_snapshot AS chapterName,
+ o.cover_img_snapshot AS coverImage,
o.order_amount AS orderAmount,
o.payment_method AS paymentMethod,
o.refund_amount AS refundAmount,
o.refund_remark AS refundRemark,
o.status,
+ o.paid_at AS paidAt,
+ o.refunded_at AS refundedAt,
+ o.created_at AS createdAt
+ FROM tcm_course_purchase_order o
+ WHERE o.deleted = 0
+ AND o.course_type = 'HEALTH_COURSE'
+ AND o.status IN ('PAID', 'REFUNDED')
+ AND o.app_user_id = #{appUserId}
+ <if test="productName != null and productName.trim() != ''">
+ AND o.product_name_snapshot LIKE CONCAT('%', #{productName}, '%')
+ </if>
+ ORDER BY o.id DESC
+ </select>
+
+ <select id="selectAppUserOrderDetail" resultType="com.tcm.training.admin.live.vo.CoursePurchaseOrderListRow">
+ SELECT
+ o.id,
+ o.order_no AS orderNo,
+ o.course_id AS courseId,
+ o.course_video_id AS courseVideoId,
+ o.user_nickname AS userNickname,
+ o.user_phone AS userPhone,
+ o.product_name_snapshot AS productName,
+ o.course_title_snapshot AS courseTitle,
+ o.chapter_name_snapshot AS chapterName,
+ o.cover_img_snapshot AS coverImage,
+ o.order_amount AS orderAmount,
+ o.payment_method AS paymentMethod,
+ o.refund_amount AS refundAmount,
+ o.refund_remark AS refundRemark,
+ o.status,
+ o.paid_at AS paidAt,
+ o.refunded_at AS refundedAt,
+ o.created_at AS createdAt
+ FROM tcm_course_purchase_order o
+ WHERE o.deleted = 0
+ AND o.course_type = 'HEALTH_COURSE'
+ AND o.app_user_id = #{appUserId}
+ AND o.order_no = #{orderNo}
+ LIMIT 1
+ </select>
+
+ <select id="selectOrderList" resultType="com.tcm.training.admin.live.vo.CoursePurchaseOrderListRow">
+ SELECT
+ o.id,
+ o.order_no AS orderNo,
+ o.course_id AS courseId,
+ o.course_video_id AS courseVideoId,
+ o.user_nickname AS userNickname,
+ o.user_phone AS userPhone,
+ o.product_name_snapshot AS productName,
+ o.course_title_snapshot AS courseTitle,
+ o.chapter_name_snapshot AS chapterName,
+ o.cover_img_snapshot AS coverImage,
+ o.order_amount AS orderAmount,
+ o.payment_method AS paymentMethod,
+ o.refund_amount AS refundAmount,
+ o.refund_remark AS refundRemark,
+ o.status,
+ o.paid_at AS paidAt,
+ o.refunded_at AS refundedAt,
o.created_at AS createdAt
FROM tcm_course_purchase_order o
<include refid="CoursePurchaseOrderWhere"/>
ORDER BY o.id DESC
</select>
+ <select id="selectPaidCourseSnapshots" resultType="com.tcm.training.admin.live.vo.CoursePurchaseOrderListRow">
+ SELECT
+ o.id,
+ o.order_no AS orderNo,
+ o.course_id AS courseId,
+ o.course_video_id AS courseVideoId,
+ o.user_nickname AS userNickname,
+ o.user_phone AS userPhone,
+ o.product_name_snapshot AS productName,
+ o.course_title_snapshot AS courseTitle,
+ o.chapter_name_snapshot AS chapterName,
+ o.cover_img_snapshot AS coverImage,
+ o.order_amount AS orderAmount,
+ o.payment_method AS paymentMethod,
+ o.refund_amount AS refundAmount,
+ o.refund_remark AS refundRemark,
+ o.status,
+ o.paid_at AS paidAt,
+ o.refunded_at AS refundedAt,
+ o.created_at AS createdAt
+ FROM tcm_course_purchase_order o
+ WHERE o.deleted = 0
+ AND o.course_type = 'HEALTH_COURSE'
+ AND o.purchase_target_type = 'COURSE'
+ AND o.status = 'PAID'
+ AND o.app_user_id = #{appUserId}
+ ORDER BY o.paid_at DESC, o.id DESC
+ </select>
+
</mapper>
diff --git a/tcm-training-admin-backend/src/main/resources/mapper/HealthCourseDownloadRecordMapper.xml b/tcm-training-admin-backend/src/main/resources/mapper/HealthCourseDownloadRecordMapper.xml
new file mode 100644
index 0000000..36a9b07
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/resources/mapper/HealthCourseDownloadRecordMapper.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.tcm.training.admin.live.mapper.HealthCourseDownloadRecordMapper">
+
+ <select id="selectAppDownloads" resultType="com.tcm.training.admin.live.row.AppHealthCourseDownloadRow">
+ SELECT
+ r.id AS id,
+ r.course_id AS courseId,
+ r.course_video_id AS courseVideoId,
+ r.chapter_name_snapshot AS title,
+ r.course_title_snapshot AS subtitle,
+ r.cover_img_snapshot AS coverImage,
+ r.duration_seconds_snapshot AS durationSeconds,
+ r.video_url_snapshot AS videoUrl,
+ r.updated_at AS downloadedAt
+ FROM tcm_health_course_download_record r
+ WHERE r.deleted = 0
+ AND r.app_user_id = #{appUserId}
+ ORDER BY r.updated_at DESC, r.id DESC
+ </select>
+
+</mapper>
diff --git a/tcm-training-admin-backend/src/main/resources/mapper/HealthCourseFavoriteRecordMapper.xml b/tcm-training-admin-backend/src/main/resources/mapper/HealthCourseFavoriteRecordMapper.xml
new file mode 100644
index 0000000..61cb77c
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/resources/mapper/HealthCourseFavoriteRecordMapper.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.tcm.training.admin.live.mapper.HealthCourseFavoriteRecordMapper">
+
+ <select id="selectAppFavoriteCourses" resultType="com.tcm.training.admin.live.row.AppHealthCourseFavoriteRow">
+ SELECT
+ latest.course_id AS courseId,
+ c.title AS title,
+ c.cover_img AS coverImage,
+ lc.name AS levelName,
+ dc.name AS difficultyName,
+ e.name AS teacherName,
+ e.avatar AS teacherAvatar,
+ c.learn_user_count AS learnUserCount,
+ latest.action_time AS favoritedAt
+ FROM (
+ SELECT r.course_id, r.action_time, r.id
+ FROM tcm_health_course_favorite_record r
+ INNER JOIN (
+ SELECT course_id, MAX(id) AS max_id
+ FROM tcm_health_course_favorite_record
+ WHERE app_user_id = #{appUserId}
+ GROUP BY course_id
+ ) latest_record ON latest_record.max_id = r.id
+ WHERE r.app_user_id = #{appUserId}
+ AND r.action_type = 'FAVORITE'
+ ) latest
+ INNER JOIN tcm_health_course c ON c.id = latest.course_id
+ AND c.deleted = 0
+ AND c.status = 1
+ LEFT JOIN tcm_live_course_category lc ON lc.id = c.level_category_id AND lc.deleted = 0
+ LEFT JOIN tcm_live_course_category dc ON dc.id = c.difficulty_category_id AND dc.deleted = 0
+ LEFT JOIN tcm_expert e ON e.id = c.expert_id AND e.deleted = 0
+ ORDER BY latest.action_time DESC, latest.id DESC
+ </select>
+
+</mapper>
diff --git a/tcm-training-admin-backend/src/main/resources/mapper/HealthCourseMemberJoinRecordMapper.xml b/tcm-training-admin-backend/src/main/resources/mapper/HealthCourseMemberJoinRecordMapper.xml
new file mode 100644
index 0000000..9d6ace8
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/resources/mapper/HealthCourseMemberJoinRecordMapper.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.tcm.training.admin.live.mapper.HealthCourseMemberJoinRecordMapper">
+
+ <select id="selectActiveCourseIdsByUserId" resultType="java.lang.Long">
+ SELECT DISTINCT r.course_id
+ FROM tcm_health_course_member_join_record r
+ WHERE r.app_user_id = #{appUserId}
+ AND r.deleted = 0
+ ORDER BY r.joined_at DESC, r.id DESC
+ </select>
+
+</mapper>
diff --git a/tcm-training-admin-backend/src/main/resources/mapper/HealthCoursePlayRecordMapper.xml b/tcm-training-admin-backend/src/main/resources/mapper/HealthCoursePlayRecordMapper.xml
new file mode 100644
index 0000000..6f6d4b4
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/resources/mapper/HealthCoursePlayRecordMapper.xml
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.tcm.training.admin.live.mapper.HealthCoursePlayRecordMapper">
+
+ <select id="selectVideoProgressByCourseIds" resultType="com.tcm.training.admin.live.row.AppMyHealthCourseVideoProgressRow">
+ SELECT
+ p.course_id AS courseId,
+ p.video_id AS videoId,
+ COALESCE(SUM(p.duration_seconds), 0) AS cumulativeWatchSeconds,
+ MAX(p.is_completed) AS completed,
+ SUBSTRING_INDEX(
+ GROUP_CONCAT(
+ COALESCE(p.last_play_position_seconds, 0)
+ ORDER BY COALESCE(p.play_end, p.play_start) DESC, p.id DESC
+ ),
+ ',',
+ 1
+ ) AS lastPlayPositionSeconds,
+ MAX(COALESCE(p.play_end, p.play_start)) AS latestLearnAt
+ FROM tcm_health_course_play_record p
+ WHERE p.app_user_id = #{appUserId}
+ AND p.course_id IN
+ <foreach collection="courseIds" item="courseId" open="(" separator="," close=")">
+ #{courseId}
+ </foreach>
+ GROUP BY p.course_id, p.video_id
+ </select>
+
+ <select id="selectCourseProgressByCourseIds" resultType="com.tcm.training.admin.live.row.AppMyHealthCourseProgressRow">
+ SELECT
+ s.course_id AS courseId,
+ COUNT(CASE WHEN s.completed = 1 THEN 1 END) AS completedLessons,
+ MAX(s.latestLearnAt) AS latestLearnAt
+ FROM (
+ SELECT
+ p.course_id,
+ p.video_id,
+ MAX(p.is_completed) AS completed,
+ MAX(COALESCE(p.play_end, p.play_start)) AS latestLearnAt
+ FROM tcm_health_course_play_record p
+ WHERE p.app_user_id = #{appUserId}
+ AND p.course_id IN
+ <foreach collection="courseIds" item="courseId" open="(" separator="," close=")">
+ #{courseId}
+ </foreach>
+ GROUP BY p.course_id, p.video_id
+ ) s
+ GROUP BY s.course_id
+ </select>
+
+</mapper>
diff --git a/tcm-training-admin-backend/src/main/resources/mapper/MemberPurchaseOrderMapper.xml b/tcm-training-admin-backend/src/main/resources/mapper/MemberPurchaseOrderMapper.xml
index 4863846..f6ec896 100644
--- a/tcm-training-admin-backend/src/main/resources/mapper/MemberPurchaseOrderMapper.xml
+++ b/tcm-training-admin-backend/src/main/resources/mapper/MemberPurchaseOrderMapper.xml
@@ -71,4 +71,27 @@
ORDER BY o.id DESC
</select>
+ <select id="selectAppUserOrderPage" resultType="com.tcm.training.admin.member.row.MemberPurchaseOrderRow">
+ SELECT
+ o.id,
+ o.order_no AS orderNo,
+ o.user_nickname AS userNickname,
+ o.user_phone AS userPhone,
+ o.member_type AS memberType,
+ o.member_days AS memberDays,
+ o.order_amount AS orderAmount,
+ o.payment_method AS paymentMethod,
+ o.refund_amount AS refundAmount,
+ o.refund_remark AS refundRemark,
+ o.status,
+ o.paid_at AS paidAt,
+ o.refunded_at AS refundedAt,
+ o.created_at AS createdAt
+ FROM tcm_member_purchase_order o
+ WHERE o.deleted = 0
+ AND o.app_user_id = #{appUserId}
+ AND o.status IN ('PAID', 'REFUNDED')
+ ORDER BY o.id DESC
+ </select>
+
</mapper>
diff --git a/tcm-training-admin-backend/src/main/resources/mapper/PointsLedgerMapper.xml b/tcm-training-admin-backend/src/main/resources/mapper/PointsLedgerMapper.xml
index ad705c9..1abbc80 100644
--- a/tcm-training-admin-backend/src/main/resources/mapper/PointsLedgerMapper.xml
+++ b/tcm-training-admin-backend/src/main/resources/mapper/PointsLedgerMapper.xml
@@ -80,6 +80,18 @@
ORDER BY l.operated_at DESC, l.id DESC
</select>
+ <select id="selectAppLedgerPage" resultType="com.tcm.training.admin.points.row.PointsLedgerRow">
+ SELECT
+ <include refid="LedgerColumns"/>
+ FROM tcm_points_ledger l
+ WHERE l.deleted = 0
+ AND l.app_user_id = #{appUserId}
+ <if test="incomeExpenseType != null and incomeExpenseType.trim() != ''">
+ AND l.income_expense_type = #{incomeExpenseType}
+ </if>
+ ORDER BY l.operated_at DESC, l.id DESC
+ </select>
+
<select id="selectLedgerList" resultType="com.tcm.training.admin.points.row.PointsLedgerRow">
SELECT
<include refid="LedgerColumns"/>
diff --git a/traditionalApp/pages/courseDetail/courseDetail.vue b/traditionalApp/pages/courseDetail/courseDetail.vue
index 7301e31..d2831b4 100644
--- a/traditionalApp/pages/courseDetail/courseDetail.vue
+++ b/traditionalApp/pages/courseDetail/courseDetail.vue
@@ -279,8 +279,10 @@
memberJoinHealthCourse,
purchaseHealthCourse,
purchaseHealthCourseVideo,
+ reportHealthCourseDownload,
unfavoriteHealthCourse,
} from './service'
+import { clearDownloadTask, listDownloadTasks, upsertDownloadTask } from '@/utils/download-task-storage.js'
interface CourseExpert {
id?: number | null
@@ -299,6 +301,21 @@
purchased: boolean
locked: boolean
videoUrl?: string
+}
+
+interface LocalDownloadTask {
+ clientTaskId: string
+ courseId: number
+ courseVideoId: number
+ title: string
+ subtitle: string
+ coverImage: string
+ durationSeconds: number
+ durationText: string
+ videoUrl: string
+ progress: number
+ status: 'downloading'
+ updatedAt: string
}
interface CourseDetail {
@@ -672,6 +689,55 @@
}
/**
+ * 读取当前缓存中的 APP 用户标识,供本地下载任务按账号隔离。
+ */
+function resolveCurrentUserId() {
+ const cachedUser = uni.getStorageSync('appUserInfo')
+ if (!cachedUser || typeof cachedUser !== 'object') {
+ return 0
+ }
+ const userId = Number((cachedUser as { id?: number | string }).id || 0)
+ if (!Number.isFinite(userId) || userId <= 0) {
+ return 0
+ }
+ return userId
+}
+
+/**
+ * 判断指定章节是否已存在下载中任务,避免重复触发原生下载。
+ */
+function hasActiveDownloadTask(userId: number, courseVideoId: number) {
+ return listDownloadTasks(userId).some((task) => Number(task.courseVideoId) === Number(courseVideoId))
+}
+
+/**
+ * 构建课程下载任务唯一标识。
+ */
+function buildDownloadTaskId(courseVideoId: number) {
+ return `health-course-video-${courseVideoId}`
+}
+
+/**
+ * 组装下载中任务的本地快照,供“我的下载”页面直接复用展示。
+ */
+function createDownloadTaskPayload(clientTaskId: string, item: CourseVideo): LocalDownloadTask {
+ return {
+ clientTaskId,
+ courseId: Number(courseId.value || 0),
+ courseVideoId: Number(item.id || 0),
+ title: item.chapterName || '课程章节',
+ subtitle: courseDetail.value.title || '养生课程',
+ coverImage: swiperList.value[0] || '/static/image/home/class.png',
+ durationSeconds: Number(item.durationSeconds || 0),
+ durationText: item.durationText || '00:00',
+ videoUrl: item.videoUrl || '',
+ progress: 0,
+ status: 'downloading',
+ updatedAt: new Date().toISOString(),
+ }
+}
+
+/**
* 打开整课购买支付弹窗。
*/
function handleCoursePurchase() {
@@ -722,14 +788,33 @@
})
return
// #endif
+ const currentUserId = resolveCurrentUserId()
+ if (!currentUserId) {
+ uni.showToast({
+ title: '请先登录',
+ icon: 'none',
+ })
+ return
+ }
+ if (hasActiveDownloadTask(currentUserId, item.id)) {
+ uni.showToast({
+ title: '当前章节正在下载中',
+ icon: 'none',
+ })
+ return
+ }
+ const downloadTaskId = buildDownloadTaskId(item.id)
+ const initialTask = createDownloadTaskPayload(downloadTaskId, item)
+ upsertDownloadTask(currentUserId, initialTask)
uni.showLoading({
title: '下载中...',
mask: true,
})
- uni.downloadFile({
+ const downloadTask = uni.downloadFile({
url: item.videoUrl || '',
success: (downloadResult) => {
if (downloadResult.statusCode !== 200 || !downloadResult.tempFilePath) {
+ clearDownloadTask(currentUserId, downloadTaskId)
uni.showToast({
title: '下载失败',
icon: 'none',
@@ -739,13 +824,30 @@
// 先持久化保存,避免临时文件被系统回收后用户无法继续使用。
uni.saveFile({
tempFilePath: downloadResult.tempFilePath,
- success: () => {
- uni.showToast({
- title: '下载完成',
- icon: 'none',
- })
+ success: async () => {
+ // 文件已持久化后再移除下载中状态,并把完成记录同步给后端列表。
+ clearDownloadTask(currentUserId, downloadTaskId)
+ try {
+ const response = await reportHealthCourseDownload({
+ courseId: Number(courseId.value),
+ courseVideoId: item.id,
+ })
+ if (response?.code !== 0) {
+ return
+ }
+ uni.showToast({
+ title: '下载完成',
+ icon: 'none',
+ })
+ } catch {
+ uni.showToast({
+ title: '下载完成,记录同步失败',
+ icon: 'none',
+ })
+ }
},
fail: () => {
+ clearDownloadTask(currentUserId, downloadTaskId)
uni.showToast({
title: '文件已下载到临时目录',
icon: 'none',
@@ -754,6 +856,7 @@
})
},
fail: () => {
+ clearDownloadTask(currentUserId, downloadTaskId)
uni.showToast({
title: '下载失败',
icon: 'none',
@@ -763,6 +866,14 @@
uni.hideLoading()
},
})
+ // 下载中的进度仅在当前设备维护,避免把临时任务状态同步到服务端。
+ downloadTask.onProgressUpdate((progressEvent) => {
+ upsertDownloadTask(currentUserId, {
+ ...initialTask,
+ progress: Number(progressEvent?.progress || 0),
+ updatedAt: new Date().toISOString(),
+ })
+ })
}
/**
diff --git a/traditionalApp/pages/courseDetail/service.js b/traditionalApp/pages/courseDetail/service.js
index 9bfb560..598318e 100644
--- a/traditionalApp/pages/courseDetail/service.js
+++ b/traditionalApp/pages/courseDetail/service.js
@@ -43,6 +43,13 @@
}
/**
+ * 上报 APP 养生课程小节下载完成记录。
+ */
+export const reportHealthCourseDownload = (data) => {
+ return request.post('/api/app/health-course-downloads', data, false, false)
+}
+
+/**
* 获取 APP 会员开通页聚合数据。
*/
export const getMemberOpenPage = () => {
diff --git a/traditionalApp/pages/courseRecord/courseRecord.vue b/traditionalApp/pages/courseRecord/courseRecord.vue
index bf8247a..154c06e 100644
--- a/traditionalApp/pages/courseRecord/courseRecord.vue
+++ b/traditionalApp/pages/courseRecord/courseRecord.vue
@@ -1,6 +1,5 @@
<template>
<view class="container">
- <!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-content">
<image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
@@ -9,82 +8,45 @@
</view>
</view>
- <!-- 搜索栏 -->
<view class="search-box">
<view class="search-left">
<icon type="search" size="14" color="#999"></icon>
- <input type="text" placeholder="课程名称" placeholder-class="ph-color" class="search-input" />
+ <input
+ v-model="searchKeyword"
+ type="text"
+ placeholder="课程名称"
+ placeholder-class="ph-color"
+ class="search-input"
+ confirm-type="search"
+ @confirm="handleSearch"
+ />
</view>
- <view class="search-btn">搜索</view>
+ <view class="search-btn" @tap="handleSearch">搜索</view>
</view>
- <!-- 记录列表 -->
<scroll-view scroll-y class="record-list">
- <!-- 记录项 1 -->
- <view class="record-item" @tap="goDetail">
- <image class="course-img" src="/static/image/home/class.png" mode="aspectFill"></image>
- <view class="item-info">
- <view class="item-title">课程课程名称标题</view>
- <view class="item-desc">课程名称课程名称课程名称</view>
- <view class="item-bottom">
- <text class="item-time">2025.12.20 00:20</text>
- <view class="price-box">
- <text class="price-symbol">¥</text>
- <text class="price-num">8888.25</text>
- </view>
- </view>
- </view>
+ <view v-if="records.length === 0" class="empty-box">
+ <text class="empty-text">暂无购买记录</text>
</view>
-
- <!-- 记录项 2 (退款状态) -->
- <view class="record-item" @tap="goDetail">
- <image class="course-img" src="/static/image/home/class.png" mode="aspectFill"></image>
+ <view
+ v-for="item in records"
+ :key="item.id || item.orderNo"
+ class="record-item"
+ @tap="goDetail(item)"
+ >
+ <image class="course-img" :src="resolveCoverImage(item.coverImage)" mode="aspectFill"></image>
<view class="item-info">
- <view class="title-row">
- <view class="item-title">课程课程名称标题</view>
+ <view v-if="item.showRefundTag" class="title-row">
+ <view class="item-title">{{ item.productName }}</view>
<view class="refund-tag">退款</view>
</view>
- <view class="item-desc"></view>
+ <view v-else class="item-title">{{ item.productName }}</view>
+ <view class="item-desc">{{ resolveDescription(item) }}</view>
<view class="item-bottom">
- <text class="item-time">2025.12.20 00:20</text>
+ <text class="item-time">{{ formatDateTime(item.paidAt) }}</text>
<view class="price-box">
<text class="price-symbol">¥</text>
- <text class="price-num">8888.25</text>
- </view>
- </view>
- </view>
- </view>
-
- <!-- 记录项 3 -->
- <view class="record-item">
- <image class="course-img" src="/static/image/home/class.png" mode="aspectFill"></image>
- <view class="item-info">
- <view class="item-title">课程课程名称标题</view>
- <view class="item-desc">课程名称课程名称课程名称</view>
- <view class="item-bottom">
- <text class="item-time">2025.12.20 00:20</text>
- <view class="price-box">
- <text class="price-symbol">¥</text>
- <text class="price-num">8888.25</text>
- </view>
- </view>
- </view>
- </view>
-
- <!-- 记录项 4 (退款状态) -->
- <view class="record-item">
- <image class="course-img" src="/static/image/home/class.png" mode="aspectFill"></image>
- <view class="item-info">
- <view class="title-row">
- <view class="item-title">课程课程名称标题</view>
- <view class="refund-tag">退款</view>
- </view>
- <view class="item-desc"></view>
- <view class="item-bottom">
- <text class="item-time">2025.12.20 00:20</text>
- <view class="price-box">
- <text class="price-symbol">¥</text>
- <text class="price-num">8888.25</text>
+ <text class="price-num">{{ formatPrice(item.orderAmount) }}</text>
</view>
</view>
</view>
@@ -94,210 +56,374 @@
</template>
<script setup lang="ts">
- import { ref, onMounted } from 'vue';
+import { ref } from 'vue'
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import { getCoursePurchaseOrders } from './service'
- const statusBarHeight = ref(20);
+interface CoursePurchaseOrderItem {
+ id?: number | null
+ orderNo?: string
+ courseId?: number | null
+ courseVideoId?: number | null
+ productName?: string
+ courseTitle?: string
+ chapterName?: string
+ coverImage?: string
+ orderAmount?: number | string | null
+ paymentMethod?: string
+ paymentMethodText?: string
+ status?: string
+ showRefundTag?: boolean
+ paidAt?: string
+}
- onMounted(() => {
- const systemInfo = uni.getSystemInfoSync();
- statusBarHeight.value = systemInfo.statusBarHeight || 20;
- });
+const DEFAULT_COURSE_IMAGE = '/static/image/home/class.png'
- const goBack = () => {
- uni.navigateBack();
- };
+const statusBarHeight = ref(20)
+const searchKeyword = ref('')
+const records = ref<CoursePurchaseOrderItem[]>([])
- const goDetail = () => {
- uni.navigateTo({
- url: '/pages/courseRecordDetail/courseRecordDetail'
- });
- };
+/**
+ * 页面加载时初始化状态栏高度。
+ */
+onLoad(() => {
+ const systemInfo = uni.getSystemInfoSync()
+ statusBarHeight.value = systemInfo.statusBarHeight || 20
+})
+
+/**
+ * 页面回显时刷新购课记录列表。
+ */
+onShow(() => {
+ loadCoursePurchaseOrders()
+})
+
+/**
+ * 拉取当前登录用户的购课记录第一页。
+ */
+async function loadCoursePurchaseOrders() {
+ try {
+ const response = await getCoursePurchaseOrders({
+ page: 1,
+ pageSize: 20,
+ productName: normalizeOptionalText(searchKeyword.value),
+ })
+ if (response?.code !== 0) {
+ return
+ }
+ records.value = normalizeRecords(response?.data?.list)
+ } catch {
+ // 请求层会统一处理未登录跳转和异常提示,这里不重复 toast。
+ }
+}
+
+/**
+ * 点击搜索时按课程名称刷新列表。
+ */
+function handleSearch() {
+ loadCoursePurchaseOrders()
+}
+
+/**
+ * 将接口返回的记录列表归一化为页面渲染结构。
+ */
+function normalizeRecords(value: unknown): CoursePurchaseOrderItem[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+ return value.map((item) => {
+ const record = (item && typeof item === 'object' ? item : {}) as Partial<CoursePurchaseOrderItem>
+ return {
+ id: Number.isFinite(Number(record.id)) ? Number(record.id) : null,
+ orderNo: normalizeOptionalText(record.orderNo),
+ courseId: normalizeOptionalNumber(record.courseId),
+ courseVideoId: normalizeOptionalNumber(record.courseVideoId),
+ productName: normalizeOptionalText(record.productName) || '课程名称',
+ courseTitle: normalizeOptionalText(record.courseTitle),
+ chapterName: normalizeOptionalText(record.chapterName),
+ coverImage: normalizeOptionalText(record.coverImage),
+ orderAmount: record.orderAmount ?? null,
+ paymentMethod: normalizeOptionalText(record.paymentMethod),
+ paymentMethodText: normalizeOptionalText(record.paymentMethodText),
+ status: normalizeOptionalText(record.status),
+ showRefundTag: !!record.showRefundTag,
+ paidAt: normalizeOptionalText(record.paidAt),
+ }
+ })
+}
+
+/**
+ * 返回上一页。
+ */
+function goBack() {
+ uni.navigateBack()
+}
+
+/**
+ * 跳转到现有购课记录详情页。
+ */
+function goDetail(item: CoursePurchaseOrderItem) {
+ const orderNo = encodeURIComponent(normalizeOptionalText(item.orderNo))
+ uni.navigateTo({
+ url: `/pages/courseRecordDetail/courseRecordDetail?orderNo=${orderNo}`,
+ })
+}
+
+/**
+ * 解析课程封面图,避免接口为空时页面出现空白。
+ */
+function resolveCoverImage(value?: string) {
+ return normalizeOptionalText(value) || DEFAULT_COURSE_IMAGE
+}
+
+/**
+ * 解析列表副标题,优先展示小节名称,没有小节时展示课程名称。
+ */
+function resolveDescription(item: CoursePurchaseOrderItem) {
+ const chapterName = normalizeOptionalText(item.chapterName)
+ if (chapterName) {
+ return chapterName
+ }
+ return normalizeOptionalText(item.courseTitle) || normalizeOptionalText(item.productName) || '-'
+}
+
+/**
+ * 将金额统一格式化为两位小数。
+ */
+function formatPrice(value: number | string | null | undefined) {
+ const amount = Number(value || 0)
+ if (!Number.isFinite(amount)) {
+ return '0.00'
+ }
+ return amount.toFixed(2)
+}
+
+/**
+ * 将接口时间格式化为页面所需的展示文案。
+ */
+function formatDateTime(value?: string) {
+ const normalized = normalizeOptionalText(value)
+ if (!normalized) {
+ return '-'
+ }
+ return normalized.slice(0, 16).replace(/-/g, '.').replace('T', ' ')
+}
+
+/**
+ * 归一化可选数字字段。
+ */
+function normalizeOptionalNumber(value: unknown) {
+ const numberValue = Number(value)
+ if (!Number.isFinite(numberValue)) {
+ return null
+ }
+ return numberValue
+}
+
+/**
+ * 将可选文本归一化为空字符串。
+ */
+function normalizeOptionalText(value: unknown) {
+ if (typeof value !== 'string') {
+ return ''
+ }
+ return value.trim()
+}
</script>
<style lang="scss" scoped>
- page {
- background-color: #FFFFFF;
- }
+page {
+ background-color: #FFFFFF;
+}
- .container {
- width: 750rpx;
- height: 100vh; /* Changed from min-height to height for flex child scroll-view to work */
- background-color: #FFFFFF;
- display: flex;
- flex-direction: column;
- }
+.container {
+ width: 750rpx;
+ height: 100vh;
+ background-color: #FFFFFF;
+ display: flex;
+ flex-direction: column;
+}
- /* 自定义导航栏 */
- .custom-navbar {
- width: 100%;
- background-color: #FFFFFF;
- position: relative;
- z-index: 100;
- }
+.custom-navbar {
+ width: 100%;
+ background-color: #FFFFFF;
+ position: relative;
+ z-index: 100;
+}
- .nav-content {
- height: 88rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 32rpx;
- }
+.nav-content {
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 32rpx;
+}
- .back-icon {
- width: 40rpx;
- height: 40rpx;
- }
+.back-icon {
+ width: 40rpx;
+ height: 40rpx;
+}
- .nav-title {
- font-size: 36rpx;
- color: #333333;
- font-weight: bold;
- }
+.nav-title {
+ font-size: 36rpx;
+ color: #333333;
+ font-weight: bold;
+}
- .nav-right-placeholder {
- width: 40rpx;
- }
+.nav-right-placeholder {
+ width: 40rpx;
+}
- /* 搜索框 */
- .search-box {
- width: 686rpx;
- height: 72rpx;
- margin: 24rpx 32rpx;
- background: #F5F5F5;
- border-radius: 8rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 6rpx 0 24rpx;
- box-sizing: border-box;
- }
+.search-box {
+ width: 686rpx;
+ height: 72rpx;
+ margin: 24rpx 32rpx;
+ background: #F5F5F5;
+ border-radius: 8rpx;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 6rpx 0 24rpx;
+ box-sizing: border-box;
+}
- .search-left {
- display: flex;
- align-items: center;
- flex: 1;
+.search-left {
+ display: flex;
+ align-items: center;
+ flex: 1;
+}
- .search-input {
- margin-left: 12rpx;
- font-size: 28rpx;
- color: #333;
- flex: 1;
- }
+.search-input {
+ margin-left: 12rpx;
+ font-size: 28rpx;
+ color: #333;
+ flex: 1;
+}
- .ph-color {
- color: #999999;
- }
- }
+.ph-color {
+ color: #999999;
+}
- .search-btn {
- width: 120rpx;
- height: 60rpx;
- background: #C63520;
- border-radius: 8rpx;
- color: #FFFFFF;
- font-size: 28rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
+.search-btn {
+ width: 120rpx;
+ height: 60rpx;
+ background: #C63520;
+ border-radius: 8rpx;
+ color: #FFFFFF;
+ font-size: 28rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
- /* 记录列表 */
- .record-list {
- flex: 1;
- height: 0; /* Important for scroll-view inside flex container */
- padding: 0 32rpx;
- box-sizing: border-box;
- }
+.record-list {
+ flex: 1;
+ height: 0;
+ padding: 0 32rpx;
+ box-sizing: border-box;
+}
- .record-item {
- width: 686rpx;
- height: 200rpx;
- background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
- border-radius: 16rpx;
- margin-bottom: 20rpx;
- padding: 24rpx;
- box-sizing: border-box;
- display: flex;
- align-items: center;
- border: 1rpx solid rgba(249, 246, 239, 0.5);
- }
+.empty-box {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding-top: 160rpx;
+}
- .course-img {
- width: 200rpx;
- height: 152rpx;
- border-radius: 12rpx;
- margin-right: 24rpx;
- flex-shrink: 0;
- }
+.empty-text {
+ font-size: 28rpx;
+ color: #999999;
+}
- .item-info {
- flex: 1;
- height: 152rpx;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- min-width: 0; /* 配合 flex: 1 解决文本溢出 */
- }
+.record-item {
+ width: 686rpx;
+ height: 200rpx;
+ background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
+ border-radius: 16rpx;
+ margin-bottom: 20rpx;
+ padding: 24rpx;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ border: 1rpx solid rgba(249, 246, 239, 0.5);
+}
- .title-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
+.course-img {
+ width: 200rpx;
+ height: 152rpx;
+ border-radius: 12rpx;
+ margin-right: 24rpx;
+ flex-shrink: 0;
+}
- .item-title {
- font-size: 32rpx;
- color: #333333;
- font-weight: bold;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- flex: 1;
- }
+.item-info {
+ flex: 1;
+ height: 152rpx;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-width: 0;
+}
- .refund-tag {
- padding: 2rpx 12rpx;
- background-color: rgba(211, 46, 41, 0.1);
- color: #D32E29;
- font-size: 20rpx;
- border-radius: 4rpx;
- margin-left: 16rpx;
- flex-shrink: 0;
- }
+.title-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
- .item-desc {
- font-size: 24rpx;
- color: #999999;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- min-height: 34rpx; /* 保持占位 */
- }
+.item-title {
+ font-size: 32rpx;
+ color: #333333;
+ font-weight: bold;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+}
- .item-bottom {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
+.refund-tag {
+ padding: 2rpx 12rpx;
+ background-color: rgba(211, 46, 41, 0.1);
+ color: #D32E29;
+ font-size: 20rpx;
+ border-radius: 4rpx;
+ margin-left: 16rpx;
+ flex-shrink: 0;
+}
- .item-time {
- font-size: 24rpx;
- color: #999999;
- }
+.item-desc {
+ font-size: 24rpx;
+ color: #999999;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-height: 34rpx;
+}
- .price-box {
- display: flex;
- align-items: baseline;
- color: #D32E29;
- }
+.item-bottom {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
- .price-symbol {
- font-size: 28rpx;
- font-weight: bold;
- }
+.item-time {
+ font-size: 24rpx;
+ color: #999999;
+}
- .price-num {
- font-size: 36rpx;
- font-weight: bold;
- }
+.price-box {
+ display: flex;
+ align-items: baseline;
+ color: #D32E29;
+}
+
+.price-symbol {
+ font-size: 28rpx;
+ font-weight: bold;
+}
+
+.price-num {
+ font-size: 36rpx;
+ font-weight: bold;
+}
</style>
diff --git a/traditionalApp/pages/courseRecord/service.js b/traditionalApp/pages/courseRecord/service.js
new file mode 100644
index 0000000..6459513
--- /dev/null
+++ b/traditionalApp/pages/courseRecord/service.js
@@ -0,0 +1,8 @@
+import request from '@/utils/request.js'
+
+/**
+ * 获取当前登录用户的购课记录列表。
+ */
+export const getCoursePurchaseOrders = (data) => {
+ return request.get('/api/app/course-purchase-orders', data, false, false)
+}
diff --git a/traditionalApp/pages/courseRecordDetail/courseRecordDetail.vue b/traditionalApp/pages/courseRecordDetail/courseRecordDetail.vue
index 652cd20..6c40edc 100644
--- a/traditionalApp/pages/courseRecordDetail/courseRecordDetail.vue
+++ b/traditionalApp/pages/courseRecordDetail/courseRecordDetail.vue
@@ -1,69 +1,65 @@
<template>
<view class="container">
- <!-- 顶部背景图 -->
<view class="top-bg">
<image src="https://mxcd.zhinenganguan.com/file/2026-03-30/5d3dd485-5af1-4913-ba22-c27f668c4660.png" mode="widthFix" class="bg-img"></image>
</view>
- <!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-content">
<image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
- <!-- 没有标题 -->
<view class="nav-right-placeholder"></view>
</view>
</view>
<view class="content">
- <!-- 购买课程模块 -->
<view class="section">
<text class="section-title">购买课程</text>
<view class="course-card">
- <image class="course-img" src="/static/image/home/class.png" mode="aspectFill"></image>
+ <image class="course-img" :src="resolveCoverImage(detail.coverImage)" mode="aspectFill"></image>
<view class="course-info">
- <view class="title-row">
- <text class="course-title">课程课程名称标题</text>
+ <view v-if="detail.showRefundTag" class="title-row">
+ <text class="course-title">{{ detail.productName }}</text>
<view class="refund-tag">退款</view>
</view>
+ <text v-else class="course-title">{{ detail.productName }}</text>
<view class="price-box">
<text class="price-symbol">¥</text>
- <text class="price-num">8888.25</text>
+ <text class="price-num">{{ formatPrice(detail.orderAmount) }}</text>
</view>
</view>
</view>
</view>
- <!-- 订单信息模块 -->
<view class="section mt-32">
<text class="section-title">订单信息</text>
<view class="order-info-card">
<view class="info-item">
<text class="info-label">订单编号</text>
- <text class="info-value">4534654265467</text>
+ <text class="info-value">{{ detail.orderNo || '-' }}</text>
</view>
<view class="info-item">
<text class="info-label">下单时间</text>
- <text class="info-value">2025-09-09 11:09:09</text>
+ <text class="info-value">{{ formatDateTime(detail.createdAt) }}</text>
</view>
<view class="info-item">
<text class="info-label">实付费用</text>
- <text class="info-value price-text">¥88.00</text>
+ <text class="info-value price-text">¥{{ formatPrice(detail.orderAmount) }}</text>
</view>
<view class="info-item">
<text class="info-label">支付时间</text>
- <text class="info-value">2025-09-09 11:09:09</text>
+ <text class="info-value">{{ formatDateTime(detail.paidAt) }}</text>
</view>
<view class="info-item">
<text class="info-label">支付方式</text>
- <text class="info-value">微信支付</text>
+ <text class="info-value">{{ detail.paymentMethodText || '-' }}</text>
</view>
- <view class="info-item">
+ <view v-if="detail.showRefundTag" class="info-item">
<text class="info-label">退款金额</text>
- <text class="info-value refund-text">¥88.00</text>
+ <text class="info-value refund-text">¥{{ formatPrice(detail.refundAmount) }}</text>
</view>
- <view class="info-item">
+ <view v-if="detail.showRefundTag" class="info-item">
<text class="info-label">退款时间</text>
- <text class="info-value">2025-09-09 11:09:09</text>
+ <text class="info-value">{{ formatDateTime(detail.refundedAt) }}</text>
</view>
</view>
</view>
@@ -72,191 +68,351 @@
</template>
<script setup lang="ts">
- import { ref, onMounted } from 'vue';
+import { ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import { getCoursePurchaseOrderDetail } from './service'
- const statusBarHeight = ref(20);
+interface CoursePurchaseOrderDetail {
+ id?: number | null
+ orderNo?: string
+ courseId?: number | null
+ courseVideoId?: number | null
+ productName?: string
+ courseTitle?: string
+ chapterName?: string
+ coverImage?: string
+ orderAmount?: number | string | null
+ paymentMethod?: string
+ paymentMethodText?: string
+ status?: string
+ refundAmount?: number | string | null
+ showRefundTag?: boolean
+ createdAt?: string
+ paidAt?: string
+ refundedAt?: string
+}
- onMounted(() => {
- const systemInfo = uni.getSystemInfoSync();
- statusBarHeight.value = systemInfo.statusBarHeight || 20;
- });
+const DEFAULT_COURSE_IMAGE = '/static/image/home/class.png'
- const goBack = () => {
- uni.navigateBack();
- };
+const statusBarHeight = ref(20)
+const detail = ref<CoursePurchaseOrderDetail>(createDefaultDetail())
+
+/**
+ * 创建购课记录详情默认值,避免页面首屏空引用。
+ */
+function createDefaultDetail(): CoursePurchaseOrderDetail {
+ return {
+ id: null,
+ orderNo: '',
+ courseId: null,
+ courseVideoId: null,
+ productName: '课程名称',
+ courseTitle: '',
+ chapterName: '',
+ coverImage: '',
+ orderAmount: null,
+ paymentMethod: '',
+ paymentMethodText: '',
+ status: '',
+ refundAmount: null,
+ showRefundTag: false,
+ createdAt: '',
+ paidAt: '',
+ refundedAt: '',
+ }
+}
+
+/**
+ * 页面加载时初始化状态栏高度并根据订单编号拉取详情。
+ */
+onLoad((options) => {
+ const systemInfo = uni.getSystemInfoSync()
+ statusBarHeight.value = systemInfo.statusBarHeight || 20
+ loadCoursePurchaseOrderDetail(options?.orderNo)
+})
+
+/**
+ * 拉取当前登录用户的购课记录详情。
+ */
+async function loadCoursePurchaseOrderDetail(orderNo?: string) {
+ const normalizedOrderNo = normalizeOptionalText(orderNo)
+ if (!normalizedOrderNo) {
+ return
+ }
+ try {
+ const response = await getCoursePurchaseOrderDetail(normalizedOrderNo)
+ if (response?.code !== 0 || !response.data) {
+ return
+ }
+ detail.value = normalizeDetail(response.data)
+ } catch {
+ // 请求层会统一处理未登录跳转和异常提示,这里不重复 toast。
+ }
+}
+
+/**
+ * 将接口返回的详情归一化为页面渲染结构。
+ */
+function normalizeDetail(value: unknown): CoursePurchaseOrderDetail {
+ const record = (value && typeof value === 'object' ? value : {}) as Partial<CoursePurchaseOrderDetail>
+ return {
+ id: normalizeOptionalNumber(record.id),
+ orderNo: normalizeOptionalText(record.orderNo),
+ courseId: normalizeOptionalNumber(record.courseId),
+ courseVideoId: normalizeOptionalNumber(record.courseVideoId),
+ productName: normalizeOptionalText(record.productName) || '课程名称',
+ courseTitle: normalizeOptionalText(record.courseTitle),
+ chapterName: normalizeOptionalText(record.chapterName),
+ coverImage: normalizeOptionalText(record.coverImage),
+ orderAmount: record.orderAmount ?? null,
+ paymentMethod: normalizeOptionalText(record.paymentMethod),
+ paymentMethodText: normalizeOptionalText(record.paymentMethodText),
+ status: normalizeOptionalText(record.status),
+ refundAmount: record.refundAmount ?? null,
+ showRefundTag: !!record.showRefundTag,
+ createdAt: normalizeOptionalText(record.createdAt),
+ paidAt: normalizeOptionalText(record.paidAt),
+ refundedAt: normalizeOptionalText(record.refundedAt),
+ }
+}
+
+/**
+ * 返回上一页。
+ */
+function goBack() {
+ uni.navigateBack()
+}
+
+/**
+ * 解析课程封面图,避免接口为空时页面出现空白。
+ */
+function resolveCoverImage(value?: string) {
+ return normalizeOptionalText(value) || DEFAULT_COURSE_IMAGE
+}
+
+/**
+ * 将金额统一格式化为两位小数。
+ */
+function formatPrice(value: number | string | null | undefined) {
+ const amount = Number(value || 0)
+ if (!Number.isFinite(amount)) {
+ return '0.00'
+ }
+ return amount.toFixed(2)
+}
+
+/**
+ * 将接口时间格式化为页面所需的展示文案。
+ */
+function formatDateTime(value?: string) {
+ const normalized = normalizeOptionalText(value)
+ if (!normalized) {
+ return '-'
+ }
+ return normalized.slice(0, 19).replace('T', ' ')
+}
+
+/**
+ * 归一化可选数字字段。
+ */
+function normalizeOptionalNumber(value: unknown) {
+ const numberValue = Number(value)
+ if (!Number.isFinite(numberValue)) {
+ return null
+ }
+ return numberValue
+}
+
+/**
+ * 将可选文本归一化为空字符串。
+ */
+function normalizeOptionalText(value: unknown) {
+ if (typeof value !== 'string') {
+ return ''
+ }
+ return value.trim()
+}
</script>
<style lang="scss" scoped>
- page {
- background-color: #F8F6F4;
- }
+page {
+ background-color: #F8F6F4;
+}
- .container {
- width: 750rpx;
- min-height: 100vh;
- position: relative;
- background-color: #F8F6F4;
- }
+.container {
+ width: 750rpx;
+ min-height: 100vh;
+ position: relative;
+ background-color: #F8F6F4;
+}
- .top-bg {
- position: absolute;
- top: 0;
- left: 0;
- width: 750rpx;
- z-index: 0;
+.top-bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 750rpx;
+ z-index: 0;
+}
- .bg-img {
- width: 750rpx;
- height: auto;
- }
- }
+.bg-img {
+ width: 750rpx;
+ height: auto;
+}
- /* 自定义导航栏 */
- .custom-navbar {
- width: 100%;
- position: relative;
- z-index: 100;
- }
+.custom-navbar {
+ width: 100%;
+ position: relative;
+ z-index: 100;
+}
- .nav-content {
- height: 88rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 32rpx;
- }
+.nav-content {
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 32rpx;
+}
- .back-icon {
- width: 40rpx;
- height: 40rpx;
- }
+.back-icon {
+ width: 40rpx;
+ height: 40rpx;
+}
- .nav-right-placeholder {
- width: 40rpx;
- }
+.nav-right-placeholder {
+ width: 40rpx;
+}
- .content {
- position: relative;
- z-index: 10;
- padding: 24rpx 32rpx;
- }
+.content {
+ position: relative;
+ z-index: 10;
+ padding: 24rpx 32rpx;
+}
- .section {
- margin-bottom: 40rpx;
- }
+.section {
+ margin-bottom: 40rpx;
+}
- .mt-32 {
- margin-top: 32rpx;
- }
+.mt-32 {
+ margin-top: 32rpx;
+}
- .section-title {
- font-size: 32rpx;
- color: #1A1A1A;
- font-weight: bold;
- margin-bottom: 24rpx;
- display: block;
- }
+.section-title {
+ font-size: 32rpx;
+ color: #1A1A1A;
+ font-weight: bold;
+ margin-bottom: 24rpx;
+ display: block;
+}
- /* 课程卡片 */
- .course-card {
- width: 100%;
- height: 160rpx;
- background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
- border-radius: 16rpx;
- padding: 24rpx;
- box-sizing: border-box;
- display: flex;
- align-items: center;
- }
+.course-card {
+ width: 100%;
+ height: 160rpx;
+ background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
+ border-radius: 16rpx;
+ padding: 24rpx;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+}
- .course-img {
- width: 152rpx;
- height: 112rpx;
- border-radius: 12rpx;
- margin-right: 24rpx;
- flex-shrink: 0;
- }
+.course-img {
+ width: 152rpx;
+ height: 112rpx;
+ border-radius: 12rpx;
+ margin-right: 24rpx;
+ flex-shrink: 0;
+}
- .course-info {
- flex: 1;
- height: 112rpx;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- }
+.course-info {
+ flex: 1;
+ height: 112rpx;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+}
- .title-row {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- }
+.title-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+}
- .course-title {
- font-size: 32rpx;
- color: #333333;
- font-weight: bold;
- }
+.course-title {
+ font-size: 32rpx;
+ color: #333333;
+ font-weight: bold;
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
- .refund-tag {
- padding: 2rpx 12rpx;
- background-color: rgba(211, 46, 41, 0.1);
- color: #D32E29;
- font-size: 20rpx;
- border-radius: 4rpx;
- }
+.refund-tag {
+ padding: 2rpx 12rpx;
+ background-color: rgba(211, 46, 41, 0.1);
+ color: #D32E29;
+ font-size: 20rpx;
+ border-radius: 4rpx;
+ margin-left: 16rpx;
+ flex-shrink: 0;
+}
- .price-box {
- display: flex;
- align-items: baseline;
- justify-content: flex-end;
- color: #D32E29;
- }
+.price-box {
+ display: flex;
+ align-items: baseline;
+ justify-content: flex-end;
+ color: #D32E29;
+}
- .price-symbol {
- font-size: 24rpx;
- font-weight: bold;
- }
+.price-symbol {
+ font-size: 24rpx;
+ font-weight: bold;
+}
- .price-num {
- font-size: 32rpx;
- font-weight: bold;
- }
+.price-num {
+ font-size: 32rpx;
+ font-weight: bold;
+}
- /* 订单信息卡片 */
- .order-info-card {
- background-color: #FFFFFF;
- border-radius: 16rpx;
- padding: 32rpx;
- }
+.order-info-card {
+ background-color: #FFFFFF;
+ border-radius: 16rpx;
+ padding: 32rpx;
+}
- .info-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 32rpx;
+.info-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 32rpx;
+}
- &:last-child {
- margin-bottom: 0;
- }
- }
+.info-item:last-child {
+ margin-bottom: 0;
+}
- .info-label {
- font-size: 28rpx;
- color: #86909C;
- }
+.info-label {
+ font-size: 28rpx;
+ color: #86909C;
+}
- .info-value {
- font-size: 28rpx;
- color: #1A1A1A;
- font-weight: 500;
- }
+.info-value {
+ font-size: 28rpx;
+ color: #1A1A1A;
+ font-weight: 500;
+ text-align: right;
+ margin-left: 24rpx;
+ flex: 1;
+ word-break: break-all;
+}
- .price-text {
- font-weight: bold;
- }
+.price-text {
+ font-weight: bold;
+}
- .refund-text {
- color: #D32E29;
- font-weight: bold;
- }
+.refund-text {
+ color: #D32E29;
+ font-weight: bold;
+}
</style>
diff --git a/traditionalApp/pages/courseRecordDetail/service.js b/traditionalApp/pages/courseRecordDetail/service.js
new file mode 100644
index 0000000..fcb8a62
--- /dev/null
+++ b/traditionalApp/pages/courseRecordDetail/service.js
@@ -0,0 +1,8 @@
+import request from '@/utils/request.js'
+
+/**
+ * 获取当前登录用户的购课记录详情。
+ */
+export const getCoursePurchaseOrderDetail = (orderNo) => {
+ return request.get(`/api/app/course-purchase-orders/${encodeURIComponent(orderNo)}`, {}, false, false)
+}
diff --git a/traditionalApp/pages/downloads/downloads.vue b/traditionalApp/pages/downloads/downloads.vue
index 63d218e..47e3870 100644
--- a/traditionalApp/pages/downloads/downloads.vue
+++ b/traditionalApp/pages/downloads/downloads.vue
@@ -1,6 +1,5 @@
<template>
<view class="container">
- <!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-content">
<image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
@@ -10,26 +9,27 @@
</view>
<scroll-view scroll-y class="downloads-list">
+ <view v-if="downloadList.length === 0" class="empty-box">
+ <text class="empty-text">暂无下载记录</text>
+ </view>
<block v-for="(item, index) in downloadList" :key="item.id">
<view class="list-item-wrapper" @tap="handleItemClick(index)">
- <!-- 批量删除模式下的左侧复选框 -->
<view class="checkbox-col" v-if="isBatchMode">
- <image class="check-icon"
+ <image
+ class="check-icon"
:src="item.selected ? '/static/image/course/check.png' : '/static/image/course/uncheck.png'"
- mode="aspectFit"></image>
+ mode="aspectFit"
+ ></image>
</view>
<view class="swipe-wrap" :class="{ 'in-batch-mode': isBatchMode }">
- <u-swipe-action :options="swipeOptions" :index="index" @click="handleDelete(index)"
- :disabled="isBatchMode">
+ <u-swipe-action :options="swipeOptions" :index="index" @click="handleDelete(index)" :disabled="isBatchMode">
<view class="course-item-container">
- <view class="course-item">
- <!-- 图片 -->
+ <view class="course-item">
<view class="img-wrap">
<image class="course-img" :src="item.image" mode="aspectFill"></image>
</view>
- <!-- 课程信息 -->
<view class="course-info">
<view class="title-wrap">
<text class="course-title">{{ item.title }}</text>
@@ -41,8 +41,7 @@
</view>
</view>
</view>
-
- <!-- 下载进度条 -->
+
<view class="progress-row" v-if="item.status === 'downloading'">
<text class="progress-label">下载中</text>
<view class="progress-bar-wrap">
@@ -60,372 +59,664 @@
</template>
<script setup lang="ts">
- import { ref, onMounted } from 'vue';
+import { ref } from 'vue'
+import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
+import { getHealthCourseDownloads, deleteHealthCourseDownloads } from './service'
+import {
+ getDownloadTaskChangeEventName,
+ listDownloadTasks,
+ removeDownloadTasks,
+} from '@/utils/download-task-storage.js'
- const statusBarHeight = ref(20);
- const isBatchMode = ref(false);
+interface RemoteDownloadRecord {
+ id?: number | null
+ courseId?: number | null
+ courseVideoId?: number | null
+ title?: string
+ subtitle?: string
+ coverImage?: string
+ durationSeconds?: number | null
+ durationText?: string
+ videoUrl?: string
+ downloadedAt?: string
+}
- // 模拟数据
- const downloadList = ref([
- {
- id: 1,
- title: '方剂学方剂学',
- subtitle: '中医药剂学',
- duration: '1:15:08',
- image: '/static/image/home/doctor.png',
- status: 'downloaded', // downloaded, downloading
- progress: 100,
- selected: false
+interface LocalDownloadTask {
+ clientTaskId?: string
+ courseId?: number | null
+ courseVideoId?: number | null
+ title?: string
+ subtitle?: string
+ coverImage?: string
+ durationSeconds?: number | null
+ durationText?: string
+ videoUrl?: string
+ progress?: number | null
+ updatedAt?: string
+}
+
+type DownloadSourceType = 'remoteCompleted' | 'localDownloading'
+
+interface DownloadListItem {
+ id: string
+ remoteId: number | null
+ clientTaskId: string
+ courseId: number | null
+ courseVideoId: number | null
+ title: string
+ subtitle: string
+ duration: string
+ image: string
+ status: 'downloaded' | 'downloading'
+ progress: number
+ selected: boolean
+ sourceType: DownloadSourceType
+ sortTime: number
+}
+
+const DEFAULT_COURSE_IMAGE = '/static/image/home/class.png'
+
+const statusBarHeight = ref(20)
+const isBatchMode = ref(false)
+const downloadList = ref<DownloadListItem[]>([])
+
+const swipeOptions = ref([
+ {
+ text: '删除',
+ style: {
+ backgroundColor: '#F53F3F',
+ color: '#fff',
+ fontSize: '28rpx',
+ width: '120rpx',
+ height: '120rpx',
+ borderRadius: '50%',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ margin: '0 30rpx',
},
- {
- id: 2,
- title: '方剂学方剂学',
- subtitle: '中医药剂学',
- duration: '1:15:08',
- image: '/static/image/home/doctor.png',
- status: 'downloaded',
- progress: 100,
- selected: false
- },
- {
- id: 3,
- title: '方剂学方剂学',
- subtitle: '中医药剂学',
- duration: '1:15:08',
- image: '/static/image/home/doctor.png',
- status: 'downloaded',
- progress: 100,
- selected: false
- },
- {
- id: 4,
- title: '方剂学方剂学',
- subtitle: '中医药剂学',
- duration: '1:15:08',
- image: '/static/image/home/doctor.png',
- status: 'downloaded',
- progress: 100,
- selected: false
- },
- {
- id: 5,
- title: '方剂学方剂学',
- subtitle: '中医药剂学',
- duration: '1:15:08',
- image: '/static/image/home/doctor.png',
- status: 'downloading',
- progress: 40,
- selected: false
- },
- {
- id: 6,
- title: '方剂学方剂学',
- subtitle: '中医药剂学',
- duration: '1:15:08',
- image: '/static/image/home/doctor.png',
- status: 'downloading',
- progress: 40,
- selected: false
+ },
+])
+
+/**
+ * 页面加载时初始化状态栏高度并注册下载任务刷新监听。
+ */
+onLoad(() => {
+ const systemInfo = uni.getSystemInfoSync()
+ statusBarHeight.value = systemInfo.statusBarHeight || 20
+ uni.$on(getDownloadTaskChangeEventName(), handleDownloadTaskChanged)
+})
+
+/**
+ * 页面展示时重新拉取远端完成记录,并合并本地下载中任务。
+ */
+onShow(() => {
+ loadDownloadList()
+})
+
+/**
+ * 页面销毁时解绑下载任务监听,避免重复回调。
+ */
+onUnload(() => {
+ uni.$off(getDownloadTaskChangeEventName(), handleDownloadTaskChanged)
+})
+
+/**
+ * 拉取当前登录用户的下载列表,并与本地下载中的临时任务合并。
+ */
+async function loadDownloadList() {
+ const localItems = normalizeLocalDownloadItems(listDownloadTasks(resolveCurrentUserId()))
+ try {
+ const response = await getHealthCourseDownloads()
+ if (response?.code !== 0) {
+ downloadList.value = sortDownloadList(localItems)
+ return
}
- ]);
+ const remoteItems = normalizeRemoteDownloadItems(response.data)
+ downloadList.value = sortDownloadList([...localItems, ...remoteItems])
+ } catch {
+ downloadList.value = sortDownloadList(localItems)
+ }
+}
- // u-swipe-action 右侧滑出选项
- const swipeOptions = ref([
- {
- text: '删除',
- style: {
- backgroundColor: '#F53F3F',
- color: '#fff',
- fontSize: '28rpx',
- width: '120rpx',
- height: '120rpx',
- borderRadius: '50%',
- display: 'flex',
- justifyContent: 'center',
- alignItems: 'center',
- margin: '0 30rpx'
+/**
+ * 下载任务发生变化时刷新页面列表,保证下载中进度实时回显。
+ */
+function handleDownloadTaskChanged() {
+ loadDownloadList()
+}
+
+/**
+ * 返回上一页;如果没有历史栈,则回到个人中心。
+ */
+function goBack() {
+ if (getCurrentPages().length > 1) {
+ uni.navigateBack()
+ return
+ }
+ uni.switchTab({
+ url: '/pages/mine/mine',
+ })
+}
+
+/**
+ * 切换批量模式;完成时执行批量删除。
+ */
+function toggleBatchEdit() {
+ if (!isBatchMode.value) {
+ isBatchMode.value = true
+ return
+ }
+ handleBatchDelete()
+}
+
+/**
+ * 批量模式下切换勾选;普通模式下当前不扩展新交互,保持现状不跳转。
+ */
+function handleItemClick(index: number) {
+ const currentItem = downloadList.value[index]
+ if (!currentItem) {
+ return
+ }
+ if (isBatchMode.value) {
+ currentItem.selected = !currentItem.selected
+ return
+ }
+}
+
+/**
+ * 左滑删除当前下载项。
+ */
+function handleDelete(index: number) {
+ const currentItem = downloadList.value[index]
+ if (!currentItem) {
+ return
+ }
+ uni.showModal({
+ title: '提示',
+ content: '确认删除该下载记录?',
+ success: async (res) => {
+ if (!res.confirm) {
+ return
+ }
+ await deleteDownloadItems([currentItem])
+ },
+ })
+}
+
+/**
+ * 批量删除当前勾选项;未勾选时直接退出批量模式。
+ */
+async function handleBatchDelete() {
+ const selectedItems = downloadList.value.filter((item) => item.selected)
+ if (selectedItems.length === 0) {
+ resetBatchMode()
+ return
+ }
+ await deleteDownloadItems(selectedItems)
+}
+
+/**
+ * 删除勾选或单条下载项,并分别处理远端完成记录与本地下载中任务。
+ */
+async function deleteDownloadItems(items: DownloadListItem[]) {
+ const remoteIds = items
+ .filter((item) => item.sourceType === 'remoteCompleted' && item.remoteId)
+ .map((item) => Number(item.remoteId))
+ const localTaskIds = items
+ .filter((item) => item.sourceType === 'localDownloading' && item.clientTaskId)
+ .map((item) => item.clientTaskId)
+
+ try {
+ if (remoteIds.length > 0) {
+ const response = await deleteHealthCourseDownloads(remoteIds)
+ if (response?.code !== 0) {
+ return
}
}
- ]);
-
- onMounted(() => {
- const systemInfo = uni.getSystemInfoSync();
- statusBarHeight.value = systemInfo.statusBarHeight || 20;
- });
-
- const goBack = () => {
- uni.navigateBack();
- };
-
- const toggleBatchEdit = () => {
- isBatchMode.value = !isBatchMode.value;
- if (!isBatchMode.value) {
- // 如果取消批量操作,清空所有选中状态
- downloadList.value.forEach(item => {
- item.selected = false;
- });
+ if (localTaskIds.length > 0) {
+ // 下载中删除只移除本地任务展示,不强制终止底层原生下载。
+ removeDownloadTasks(resolveCurrentUserId(), localTaskIds)
}
- };
+ downloadList.value = downloadList.value.filter((item) => !items.some((target) => target.id === item.id))
+ resetBatchMode()
+ uni.showToast({
+ title: '已删除',
+ icon: 'none',
+ })
+ } catch {
+ // 请求层会统一处理异常提示,这里不重复 toast。
+ }
+}
- const handleItemClick = (index : number) => {
- if (isBatchMode.value) {
- downloadList.value[index].selected = !downloadList.value[index].selected;
- } else {
- // 播放视频逻辑
- console.log('Play video');
+/**
+ * 退出批量模式并清理勾选状态。
+ */
+function resetBatchMode() {
+ isBatchMode.value = false
+ downloadList.value.forEach((item) => {
+ item.selected = false
+ })
+}
+
+/**
+ * 将后端返回的已完成下载记录归一化为页面项。
+ */
+function normalizeRemoteDownloadItems(value: unknown): DownloadListItem[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+ return value.map((item) => {
+ const record = (item && typeof item === 'object' ? item : {}) as RemoteDownloadRecord
+ const remoteId = normalizeOptionalNumber(record.id)
+ return {
+ id: `remote-${remoteId || Math.random()}`,
+ remoteId,
+ clientTaskId: '',
+ courseId: normalizeOptionalNumber(record.courseId),
+ courseVideoId: normalizeOptionalNumber(record.courseVideoId),
+ title: normalizeOptionalText(record.title) || '课程章节',
+ subtitle: normalizeOptionalText(record.subtitle) || '养生课程',
+ duration: resolveDurationText(record.durationText, record.durationSeconds),
+ image: resolveCourseImage(record.coverImage),
+ status: 'downloaded',
+ progress: 100,
+ selected: false,
+ sourceType: 'remoteCompleted',
+ sortTime: resolveDateTime(record.downloadedAt),
}
- };
+ })
+}
- const handleDelete = (index : number) => {
- uni.showModal({
- title: '提示',
- content: '确认删除该下载记录?',
- success: (res) => {
- if (res.confirm) {
- downloadList.value.splice(index, 1);
- uni.showToast({
- title: '已删除',
- icon: 'none'
- });
- }
- }
- });
- };
+/**
+ * 将本地下载中任务归一化为页面项。
+ */
+function normalizeLocalDownloadItems(value: LocalDownloadTask[]): DownloadListItem[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+ return value.map((item) => {
+ const clientTaskId = normalizeOptionalText(item.clientTaskId) || `local-${Math.random()}`
+ return {
+ id: `local-${clientTaskId}`,
+ remoteId: null,
+ clientTaskId,
+ courseId: normalizeOptionalNumber(item.courseId),
+ courseVideoId: normalizeOptionalNumber(item.courseVideoId),
+ title: normalizeOptionalText(item.title) || '课程章节',
+ subtitle: normalizeOptionalText(item.subtitle) || '养生课程',
+ duration: resolveDurationText(item.durationText, item.durationSeconds),
+ image: resolveCourseImage(item.coverImage),
+ status: 'downloading',
+ progress: normalizeProgress(item.progress),
+ selected: false,
+ sourceType: 'localDownloading',
+ sortTime: resolveDateTime(item.updatedAt),
+ }
+ })
+}
+
+/**
+ * 按“下载中优先、时间倒序”排序下载列表。
+ */
+function sortDownloadList(items: DownloadListItem[]) {
+ return [...items].sort((left, right) => {
+ if (left.status !== right.status) {
+ return left.status === 'downloading' ? -1 : 1
+ }
+ return right.sortTime - left.sortTime
+ })
+}
+
+/**
+ * 读取当前缓存中的 APP 用户标识。
+ */
+function resolveCurrentUserId() {
+ const cachedUser = uni.getStorageSync('appUserInfo')
+ if (!cachedUser || typeof cachedUser !== 'object') {
+ return 0
+ }
+ const userId = Number((cachedUser as { id?: number | string }).id || 0)
+ if (!Number.isFinite(userId) || userId <= 0) {
+ return 0
+ }
+ return userId
+}
+
+/**
+ * 解析课程封面图。
+ */
+function resolveCourseImage(value?: string) {
+ return normalizeOptionalText(value) || DEFAULT_COURSE_IMAGE
+}
+
+/**
+ * 优先使用接口返回的时长文案,没有时再用秒数自行兜底格式化。
+ */
+function resolveDurationText(durationText?: string, durationSeconds?: number | null) {
+ const normalizedText = normalizeOptionalText(durationText)
+ if (normalizedText) {
+ return normalizedText
+ }
+ return formatDurationSeconds(durationSeconds)
+}
+
+/**
+ * 将秒数格式化为页面所需时长文案。
+ */
+function formatDurationSeconds(value?: number | null) {
+ const totalSeconds = Math.max(0, Number(value || 0))
+ if (!Number.isFinite(totalSeconds)) {
+ return '00:00'
+ }
+ const hours = Math.floor(totalSeconds / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const seconds = Math.floor(totalSeconds % 60)
+ if (hours > 0) {
+ return `${padNumber(hours)}:${padNumber(minutes)}:${padNumber(seconds)}`
+ }
+ return `${padNumber(minutes)}:${padNumber(seconds)}`
+}
+
+/**
+ * 补齐两位数字展示。
+ */
+function padNumber(value: number) {
+ return String(value).padStart(2, '0')
+}
+
+/**
+ * 将可选数字归一化为空值或有效数字。
+ */
+function normalizeOptionalNumber(value: unknown) {
+ const numberValue = Number(value)
+ if (!Number.isFinite(numberValue)) {
+ return null
+ }
+ return numberValue
+}
+
+/**
+ * 归一化可选文本。
+ */
+function normalizeOptionalText(value: unknown) {
+ if (typeof value !== 'string') {
+ return ''
+ }
+ return value.trim()
+}
+
+/**
+ * 将时间字符串解析为排序所需的时间戳。
+ */
+function resolveDateTime(value?: string) {
+ const normalizedText = normalizeOptionalText(value)
+ if (!normalizedText) {
+ return 0
+ }
+ const timestamp = new Date(normalizedText.replace(' ', 'T')).getTime()
+ if (!Number.isFinite(timestamp)) {
+ return 0
+ }
+ return timestamp
+}
+
+/**
+ * 归一化进度值,保证范围在 0 到 100 之间。
+ */
+function normalizeProgress(value: unknown) {
+ const progressValue = Math.round(Number(value || 0))
+ if (!Number.isFinite(progressValue)) {
+ return 0
+ }
+ if (progressValue < 0) {
+ return 0
+ }
+ if (progressValue > 100) {
+ return 100
+ }
+ return progressValue
+}
</script>
<style lang="scss" scoped>
- page {
- background-color: #ffffff;
- }
+page {
+ background-color: #ffffff;
+}
- .container {
- width: 100vw;
- height: 100vh;
- background-color: #ffffff;
- display: flex;
- flex-direction: column;
- }
+.container {
+ width: 100vw;
+ height: 100vh;
+ background-color: #ffffff;
+ display: flex;
+ flex-direction: column;
+}
- /* 自定义导航栏 */
- .custom-navbar {
- width: 100%;
- background-color: #FFFFFF;
- position: relative;
- z-index: 100;
- }
+.custom-navbar {
+ width: 100%;
+ background-color: #FFFFFF;
+ position: relative;
+ z-index: 100;
+}
- .nav-content {
- height: 88rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 32rpx;
- }
+.nav-content {
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 32rpx;
+}
- .back-icon {
+.back-icon {
+ width: 40rpx;
+ height: 40rpx;
+}
+
+.nav-title {
+ font-size: 36rpx;
+ color: #333333;
+ font-weight: bold;
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.nav-right-text {
+ font-size: 28rpx;
+ color: #333333;
+}
+
+.downloads-list {
+ flex: 1;
+ height: 0;
+ padding: 24rpx 32rpx;
+ box-sizing: border-box;
+ width: 100%;
+}
+
+.empty-box {
+ padding-top: 160rpx;
+ display: flex;
+ justify-content: center;
+}
+
+.empty-text {
+ font-size: 28rpx;
+ color: #999999;
+}
+
+.list-item-wrapper {
+ display: flex;
+ align-items: flex-start;
+ width: 100%;
+ margin-bottom: 24rpx;
+}
+
+.checkbox-col {
+ width: 60rpx;
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ flex-shrink: 0;
+ height: 200rpx;
+
+ .check-icon {
width: 40rpx;
height: 40rpx;
}
+}
- .nav-title {
- font-size: 36rpx;
- color: #333333;
- font-weight: bold;
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- }
+.swipe-wrap {
+ flex: 1;
+ min-width: 0;
+ transition: all 0.3s;
+}
- .nav-right-text {
- font-size: 28rpx;
- color: #333333;
- }
+:deep(.u-swipe-action) {
+ background-color: transparent !important;
+}
- /* 下载列表 */
- .downloads-list {
- flex: 1;
- height: 0;
- padding: 24rpx 32rpx;
- box-sizing: border-box;
- width: 100%;
- }
+:deep(.u-swipe-action-item) {
+ background-color: transparent !important;
+}
- .list-item-wrapper {
- display: flex;
- align-items: flex-start;
- width: 100%;
- margin-bottom: 24rpx;
- }
+:deep(.u-swipe-del) {
+ background-color: transparent !important;
+ display: flex;
+ align-items: center;
+ height: 100%;
+ width: 192rpx !important;
+ height: 180rpx !important;
+ margin: unset !important;
+}
- .checkbox-col {
- width: 60rpx;
- display: flex;
- justify-content: flex-start;
- align-items: center;
- flex-shrink: 0;
- /* Height set to match the course card to enable vertical centering */
- height: 200rpx;
-
- .check-icon {
- width: 40rpx;
- height: 40rpx;
- }
- }
+:deep(.u-btn-text) {
+ display: none;
+}
- .swipe-wrap {
- flex: 1;
- min-width: 0;
- transition: all 0.3s;
+:deep(.u-swipe-del::after) {
+ content: '';
+ width: 120rpx;
+ height: 120rpx;
+ background-color: transparent;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin: 0 32rpx;
+ background-image: url('/static/image/course/delete.png');
+ background-size: 120rpx 120rpx;
+ background-repeat: no-repeat;
+ background-position: center;
+}
- &.in-batch-mode {
- /* You can add specific styles when in batch mode if needed */
- }
- }
+.course-item-container {
+ width: 100%;
+ background-color: transparent;
+}
- // 修改 u-swipe-action 的样式适应圆形删除按钮设计
- :deep(.u-swipe-action) {
- background-color: transparent !important;
- }
+.course-item {
+ width: 100%;
+ height: 200rpx;
+ background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
+ padding: 24rpx;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ border-radius: 24rpx;
+}
- /* The actual scroll view container inside u-swipe-action */
- :deep(.u-swipe-action-item) {
- background-color: transparent !important;
- }
+.img-wrap {
+ width: 152rpx;
+ height: 152rpx;
+ border-radius: 16rpx;
+ margin-right: 24rpx;
+ flex-shrink: 0;
+ background-color: #BDBDBD;
+}
- :deep(.u-swipe-del) {
- background-color: transparent !important;
- display: flex;
- align-items: center;
- height: 100%;
- width: 192rpx !important;
- height: 180rpx !important;
- margin: unset !important;
- }
+.course-img {
+ width: 100%;
+ height: 100%;
+ border-radius: 16rpx;
+}
- :deep(.u-btn-text) {
- display: none; // 隐藏文字,我们在样式里画个垃圾桶
- }
+.course-info {
+ flex: 1;
+ height: 152rpx;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-width: 0;
+}
- :deep(.u-swipe-del::after) {
- content: '';
- width: 120rpx;
- height: 120rpx;
- background-color: transparent;
- display: flex;
- justify-content: center;
- align-items: center;
- margin: 0 32rpx;
- background-image: url('/static/image/course/delete.png');
- background-size: 120rpx 120rpx;
- background-repeat: no-repeat;
- background-position: center;
- }
+.title-wrap {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
- .course-item-container {
- width: 100%;
- background-color: transparent;
- }
+.course-title {
+ font-size: 32rpx;
+ color: #1A1A1A;
+ font-weight: bold;
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
- .course-item {
- width: 100%;
- height: 200rpx;
- background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
- padding: 24rpx;
- box-sizing: border-box;
- display: flex;
- align-items: center;
- border-radius: 24rpx;
- }
+.course-bottom {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
- .img-wrap {
- width: 152rpx;
- height: 152rpx;
- border-radius: 16rpx;
- margin-right: 24rpx;
- flex-shrink: 0;
- background-color: #BDBDBD; // 灰底
- }
+.course-subtitle {
+ font-size: 26rpx;
+ color: #86909C;
+ max-width: 420rpx;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
- .course-img {
- width: 100%;
- height: 100%;
- border-radius: 16rpx;
- }
+.course-duration {
+ font-size: 26rpx;
+ color: #86909C;
+}
- .course-info {
- flex: 1;
- height: 152rpx;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- min-width: 0;
- }
+.progress-row {
+ display: flex;
+ align-items: center;
+ padding: 16rpx 0 0;
+ width: 100%;
+}
- .title-wrap {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- }
+.progress-label {
+ font-size: 24rpx;
+ color: #1A1A1A;
+ margin-right: 16rpx;
+}
- .course-title {
- font-size: 32rpx;
- color: #1A1A1A;
- font-weight: bold;
- flex: 1;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
+.progress-bar-wrap {
+ flex: 1;
+ height: 10rpx;
+ background-color: #E5E6EB;
+ border-radius: 5rpx;
+ overflow: hidden;
+ margin-right: 16rpx;
+}
- .course-bottom {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
+.progress-bar-inner {
+ height: 100%;
+ background-color: #D32E29;
+ border-radius: 5rpx;
+}
- .course-subtitle {
- font-size: 26rpx;
- color: #86909C;
- }
-
- .course-duration {
- font-size: 26rpx;
- color: #86909C;
- }
-
- /* 下载进度条 */
- .progress-row {
- display: flex;
- align-items: center;
- padding: 16rpx 0 0;
- width: 100%;
- }
-
- .progress-label {
- font-size: 24rpx;
- color: #1A1A1A;
- margin-right: 16rpx;
- }
-
- .progress-bar-wrap {
- flex: 1;
- height: 10rpx;
- background-color: #E5E6EB;
- border-radius: 5rpx;
- overflow: hidden;
- margin-right: 16rpx;
- }
-
- .progress-bar-inner {
- height: 100%;
- background-color: #D32E29; /* 红色进度条 */
- border-radius: 5rpx;
- }
-
- .progress-value {
- font-size: 24rpx;
- color: #86909C;
- width: 60rpx;
- text-align: right;
- }
+.progress-value {
+ font-size: 24rpx;
+ color: #86909C;
+ width: 60rpx;
+ text-align: right;
+}
</style>
diff --git a/traditionalApp/pages/downloads/service.js b/traditionalApp/pages/downloads/service.js
new file mode 100644
index 0000000..da32415
--- /dev/null
+++ b/traditionalApp/pages/downloads/service.js
@@ -0,0 +1,15 @@
+import request from '@/utils/request.js'
+
+/**
+ * 获取当前登录用户的课程下载记录。
+ */
+export const getHealthCourseDownloads = () => {
+ return request.get('/api/app/health-course-downloads', {}, false, false)
+}
+
+/**
+ * 批量删除当前登录用户的课程下载记录。
+ */
+export const deleteHealthCourseDownloads = (ids) => {
+ return request.delete('/api/app/health-course-downloads', { ids }, false, true)
+}
diff --git a/traditionalApp/pages/favorites/favorites.vue b/traditionalApp/pages/favorites/favorites.vue
index 18eb1ae..2e670e5 100644
--- a/traditionalApp/pages/favorites/favorites.vue
+++ b/traditionalApp/pages/favorites/favorites.vue
@@ -1,6 +1,5 @@
<template>
<view class="container">
- <!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-content">
<image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
@@ -10,47 +9,50 @@
</view>
<scroll-view scroll-y class="favorites-list">
- <block v-for="(item, index) in favoriteList" :key="item.id">
+ <view v-if="favoriteList.length === 0" class="empty-box">
+ <text class="empty-text">暂无收藏课程</text>
+ </view>
+ <block v-for="(item, index) in favoriteList" :key="item.courseId || index">
<view class="list-item-wrapper" @tap="handleItemClick(index)">
- <!-- 批量删除模式下的左侧复选框 -->
- <view class="checkbox-col" v-if="isBatchMode">
- <image class="check-icon"
+ <view v-if="isBatchMode" class="checkbox-col">
+ <image
+ class="check-icon"
:src="item.selected ? '/static/image/course/check.png' : '/static/image/course/uncheck.png'"
- mode="aspectFit"></image>
+ mode="aspectFit"
+ ></image>
</view>
<view class="swipe-wrap" :class="{ 'in-batch-mode': isBatchMode }">
- <u-swipe-action :options="swipeOptions" :index="index" @click="handleDelete(index)"
- :disabled="isBatchMode">
+ <u-swipe-action
+ :options="swipeOptions"
+ :index="index"
+ @click="handleDelete(index)"
+ :disabled="isBatchMode"
+ >
<view class="course-item-container">
<view class="course-item">
- <!-- 图片及红心图标 -->
<view class="img-wrap">
- <image class="course-img" :src="item.image" mode="aspectFill"></image>
- <image class="heart-icon" src="/static/image/course/live.png" mode="aspectFit">
- </image>
+ <image class="course-img" :src="resolveCourseImage(item.coverImage)" mode="aspectFill"></image>
+ <image class="heart-icon" src="/static/image/course/live.png" mode="aspectFit"></image>
</view>
- <!-- 课程信息 -->
<view class="course-info">
<view class="title-wrap">
<view class="tag-list">
- <view class="tag red-tag" v-if="item.hasHealthTag">养生</view>
- <view class="tag red-tag" v-if="item.hasIntroTag">入门</view>
+ <view class="tag red-tag">养生</view>
+ <view v-if="item.difficultyName" class="tag red-tag">{{ item.difficultyName }}</view>
</view>
<text class="course-title">{{ item.title }}</text>
</view>
<view class="course-bottom">
<view class="teacher-info">
- <image class="teacher-avatar" :src="item.teacherAvatar"
- mode="aspectFill"></image>
- <text class="teacher-name">{{ item.teacherName }}</text>
+ <image class="teacher-avatar" :src="resolveTeacherAvatar(item.teacherAvatar)" mode="aspectFill"></image>
+ <text class="teacher-name">{{ item.teacherName || '-' }}</text>
</view>
<view class="student-count">
- <wd-icon name="user" size="12px" color="#999"
- custom-class="user-icon"></wd-icon>
- <text>{{ item.studentCount }}人正在学</text>
+ <wd-icon name="user" size="12px" color="#999" custom-class="user-icon"></wd-icon>
+ <text>{{ formatLearnUserCount(item.learnUserCount) }}人正在学</text>
</view>
</view>
</view>
@@ -62,383 +64,514 @@
</block>
</scroll-view>
</view>
-
</template>
<script setup lang="ts">
- import { ref, onMounted } from 'vue';
+import { ref } from 'vue'
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import { batchCancelFavoriteCourses, cancelFavoriteCourse, getFavoriteCourses } from './service'
- const statusBarHeight = ref(20);
- const isBatchMode = ref(false);
+interface FavoriteCourseItem {
+ courseId?: number | null
+ title?: string
+ coverImage?: string
+ levelName?: string
+ difficultyName?: string
+ teacherName?: string
+ teacherAvatar?: string
+ learnUserCount?: number | null
+ selected?: boolean
+}
- // 模拟数据
- const favoriteList = ref([
- {
- id: 1,
- title: '拍八虚新手入门版',
- image: '/static/image/home/class.png',
- hasHealthTag: true,
- hasIntroTag: false,
- teacherName: '姓名姓名',
- teacherAvatar: '/static/image/home/doctor.png',
- studentCount: 8888,
- selected: false
+const DEFAULT_COURSE_IMAGE = '/static/image/home/class.png'
+const DEFAULT_TEACHER_AVATAR = '/static/image/home/doctor.png'
+
+const statusBarHeight = ref(20)
+const isBatchMode = ref(false)
+const favoriteList = ref<FavoriteCourseItem[]>([])
+
+const swipeOptions = ref([
+ {
+ text: '删除',
+ style: {
+ backgroundColor: '#F53F3F',
+ color: '#fff',
+ fontSize: '28rpx',
+ width: '120rpx',
+ height: '120rpx',
+ borderRadius: '50%',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ margin: '0 30rpx',
},
- {
- id: 2,
- title: '拍八虚新手入门版',
- image: '', // 灰底无图测试
- hasHealthTag: true,
- hasIntroTag: true,
- teacherName: '姓名姓名',
- teacherAvatar: '/static/image/home/doctor.png',
- studentCount: 8888,
- selected: false
- },
- {
- id: 3,
- title: '拍八虚新手入门版',
- image: '',
- hasHealthTag: true,
- hasIntroTag: false,
- teacherName: '姓名姓名',
- teacherAvatar: '/static/image/home/doctor.png',
- studentCount: 8888,
- selected: false
- },
- {
- id: 4,
- title: '拍八虚新手入门版',
- image: '',
- hasHealthTag: true,
- hasIntroTag: false,
- teacherName: '姓名姓名',
- teacherAvatar: '/static/image/home/doctor.png',
- studentCount: 8888,
- selected: false
+ },
+])
+
+/**
+ * 页面加载时初始化状态栏高度。
+ */
+onLoad(() => {
+ const systemInfo = uni.getSystemInfoSync()
+ statusBarHeight.value = systemInfo.statusBarHeight || 20
+})
+
+/**
+ * 页面回显时刷新收藏列表。
+ */
+onShow(() => {
+ loadFavoriteCourses()
+})
+
+/**
+ * 拉取当前登录用户的收藏课程列表。
+ */
+async function loadFavoriteCourses() {
+ try {
+ const response = await getFavoriteCourses()
+ if (response?.code !== 0) {
+ return
}
- ]);
+ favoriteList.value = normalizeFavoriteList(response.data)
+ } catch {
+ // 请求层会统一处理未登录跳转和异常提示,这里不重复 toast。
+ }
+}
- // u-swipe-action 右侧滑出选项
- const swipeOptions = ref([
- {
- text: '删除',
- style: {
- backgroundColor: '#F53F3F',
- color: '#fff',
- fontSize: '28rpx',
- width: '120rpx',
- height: '120rpx',
- borderRadius: '50%',
- display: 'flex',
- justifyContent: 'center',
- alignItems: 'center',
- margin: '0 30rpx'
+/**
+ * 将接口返回的收藏列表归一化为页面渲染结构。
+ */
+function normalizeFavoriteList(value: unknown): FavoriteCourseItem[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+ return value.map((item) => {
+ const record = (item && typeof item === 'object' ? item : {}) as Partial<FavoriteCourseItem>
+ return {
+ courseId: normalizeOptionalNumber(record.courseId),
+ title: normalizeOptionalText(record.title) || '课程名称',
+ coverImage: normalizeOptionalText(record.coverImage),
+ levelName: normalizeOptionalText(record.levelName),
+ difficultyName: normalizeOptionalText(record.difficultyName),
+ teacherName: normalizeOptionalText(record.teacherName),
+ teacherAvatar: normalizeOptionalText(record.teacherAvatar),
+ learnUserCount: normalizeOptionalNumber(record.learnUserCount),
+ selected: false,
+ }
+ })
+}
+
+/**
+ * 返回上一页。
+ */
+function goBack() {
+ uni.navigateBack()
+}
+
+/**
+ * 切换批量模式;确认时执行批量取消。
+ */
+function toggleBatchEdit() {
+ if (!isBatchMode.value) {
+ isBatchMode.value = true
+ return
+ }
+ handleBatchConfirm()
+}
+
+/**
+ * 批量模式下切换勾选;普通模式下跳课程详情。
+ */
+function handleItemClick(index: number) {
+ const currentItem = favoriteList.value[index]
+ if (!currentItem) {
+ return
+ }
+ if (isBatchMode.value) {
+ currentItem.selected = !currentItem.selected
+ return
+ }
+ if (!currentItem.courseId) {
+ return
+ }
+ uni.navigateTo({
+ url: `/pages/courseDetail/courseDetail?id=${currentItem.courseId}`,
+ })
+}
+
+/**
+ * 确认批量取消收藏;未勾选时直接退出批量模式。
+ */
+async function handleBatchConfirm() {
+ const selectedCourseIds = favoriteList.value
+ .filter((item) => item.selected && item.courseId)
+ .map((item) => Number(item.courseId))
+ if (selectedCourseIds.length === 0) {
+ resetBatchMode()
+ return
+ }
+ try {
+ const response = await batchCancelFavoriteCourses(selectedCourseIds)
+ if (response?.code !== 0) {
+ return
+ }
+ favoriteList.value = favoriteList.value.filter((item) => !item.selected)
+ resetBatchMode()
+ uni.showToast({
+ title: '已取消收藏',
+ icon: 'none',
+ })
+ } catch {
+ // 请求层会统一处理异常提示,这里不重复 toast。
+ }
+}
+
+/**
+ * 单条左滑取消收藏。
+ */
+function handleDelete(index: number) {
+ const currentItem = favoriteList.value[index]
+ if (!currentItem || !currentItem.courseId) {
+ return
+ }
+ uni.showModal({
+ title: '提示',
+ content: '确认取消收藏该课程?',
+ success: async (res) => {
+ if (!res.confirm) {
+ return
}
- }
- ]);
-
- onMounted(() => {
- const systemInfo = uni.getSystemInfoSync();
- statusBarHeight.value = systemInfo.statusBarHeight || 20;
- });
-
- const goBack = () => {
- uni.navigateBack();
- };
-
- const toggleBatchEdit = () => {
- isBatchMode.value = !isBatchMode.value;
- if (!isBatchMode.value) {
- // 如果取消批量操作,清空所有选中状态
- favoriteList.value.forEach(item => {
- item.selected = false;
- });
- }
- };
-
- const handleItemClick = (index : number) => {
- if (isBatchMode.value) {
- favoriteList.value[index].selected = !favoriteList.value[index].selected;
- } else {
- // 跳转到详情页逻辑
- console.log('Go to detail');
- }
- };
-
- const handleDelete = (index : number) => {
- uni.showModal({
- title: '提示',
- content: '确认取消收藏该课程?',
- success: (res) => {
- if (res.confirm) {
- favoriteList.value.splice(index, 1);
- uni.showToast({
- title: '已取消收藏',
- icon: 'none'
- });
+ try {
+ const response = await cancelFavoriteCourse(currentItem.courseId as number)
+ if (response?.code !== 0) {
+ return
}
+ favoriteList.value.splice(index, 1)
+ uni.showToast({
+ title: '已取消收藏',
+ icon: 'none',
+ })
+ } catch {
+ // 请求层会统一处理异常提示,这里不重复 toast。
}
- });
- };
+ },
+ })
+}
+
+/**
+ * 退出批量模式并清空全部勾选状态。
+ */
+function resetBatchMode() {
+ isBatchMode.value = false
+ favoriteList.value.forEach((item) => {
+ item.selected = false
+ })
+}
+
+/**
+ * 解析课程封面图。
+ */
+function resolveCourseImage(value?: string) {
+ return normalizeOptionalText(value) || DEFAULT_COURSE_IMAGE
+}
+
+/**
+ * 解析讲师头像。
+ */
+function resolveTeacherAvatar(value?: string) {
+ return normalizeOptionalText(value) || DEFAULT_TEACHER_AVATAR
+}
+
+/**
+ * 格式化正在学习人数。
+ */
+function formatLearnUserCount(value?: number | null) {
+ const count = Number(value || 0)
+ if (!Number.isFinite(count)) {
+ return 0
+ }
+ return count
+}
+
+/**
+ * 归一化可选数字字段。
+ */
+function normalizeOptionalNumber(value: unknown) {
+ const numberValue = Number(value)
+ if (!Number.isFinite(numberValue)) {
+ return null
+ }
+ return numberValue
+}
+
+/**
+ * 将可选文本归一化为空字符串。
+ */
+function normalizeOptionalText(value: unknown) {
+ if (typeof value !== 'string') {
+ return ''
+ }
+ return value.trim()
+}
</script>
<style>
- page,
- html {
- background-color: #fff;
- }
+page,
+html {
+ background-color: #fff;
+}
</style>
<style lang="scss" scoped>
- .container {
- width: 100vw;
- height: 100vh;
- // background-color: #F8F6F4;
- display: flex;
- flex-direction: column;
- }
+.container {
+ width: 100vw;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
- /* 自定义导航栏 */
- .custom-navbar {
- width: 100%;
- background-color: #FFFFFF;
- position: relative;
- z-index: 100;
- }
+.custom-navbar {
+ width: 100%;
+ background-color: #FFFFFF;
+ position: relative;
+ z-index: 100;
+}
- .nav-content {
- height: 88rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 32rpx;
- }
+.nav-content {
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 32rpx;
+}
- .back-icon {
- width: 40rpx;
- height: 40rpx;
- }
+.back-icon {
+ width: 40rpx;
+ height: 40rpx;
+}
- .nav-title {
- font-size: 36rpx;
- color: #333333;
- font-weight: bold;
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- }
+.nav-title {
+ font-size: 36rpx;
+ color: #333333;
+ font-weight: bold;
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+}
- .nav-right-text {
- font-size: 28rpx;
- color: #333333;
- }
+.nav-right-text {
+ font-size: 28rpx;
+ color: #333333;
+}
- /* 收藏列表 */
- .favorites-list {
- flex: 1;
- height: 0;
- padding: 24rpx 32rpx;
- box-sizing: border-box;
- width: 100%;
- }
+.favorites-list {
+ flex: 1;
+ height: 0;
+ padding: 24rpx 32rpx;
+ box-sizing: border-box;
+ width: 100%;
+}
- .list-item-wrapper {
- display: flex;
- align-items: flex-start;
- width: 100%;
- }
+.empty-box {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding-top: 200rpx;
+}
- .checkbox-col {
- width: 60rpx;
- display: flex;
- justify-content: flex-start;
- align-items: center;
- flex-shrink: 0;
- /* Height set to match the course card to enable vertical centering */
- height: 200rpx;
- margin-bottom: 24rpx;
+.empty-text {
+ font-size: 28rpx;
+ color: #999999;
+}
- .check-icon {
- width: 40rpx;
- height: 40rpx;
- }
- }
+.list-item-wrapper {
+ display: flex;
+ align-items: flex-start;
+ width: 100%;
+}
- .swipe-wrap {
- margin-bottom: 24rpx;
- flex: 1;
- min-width: 0;
- transition: all 0.3s;
+.checkbox-col {
+ width: 60rpx;
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ flex-shrink: 0;
+ height: 200rpx;
+ margin-bottom: 24rpx;
+}
- &.in-batch-mode {
- /* You can add specific styles when in batch mode if needed */
- }
- }
+.check-icon {
+ width: 40rpx;
+ height: 40rpx;
+}
- // 修改 u-swipe-action 的样式适应圆形删除按钮设计
- :deep(.u-swipe-action) {
- background-color: transparent !important;
- /* 匹配页面底色,让右侧操作区看起来像是悬浮的 */
- }
+.swipe-wrap {
+ margin-bottom: 24rpx;
+ flex: 1;
+ min-width: 0;
+ transition: all 0.3s;
+}
- /* The actual scroll view container inside u-swipe-action */
- :deep(.u-swipe-action-item) {
- background-color: transparent !important;
- }
+:deep(.u-swipe-action) {
+ background-color: transparent !important;
+}
- :deep(.u-swipe-del) {
- background-color: transparent !important;
- display: flex;
- align-items: center;
- height: 100%;
- width: 192rpx !important;
- height: 204rpx !important;
- margin: unset !important;
- background-color: #fff !important;
- border-radius: unset !important;
- }
+:deep(.u-swipe-action-item) {
+ background-color: transparent !important;
+}
- :deep(.u-btn-text) {
- display: none; // 隐藏文字,我们在样式里画个垃圾桶
- }
+:deep(.u-swipe-del) {
+ background-color: transparent !important;
+ display: flex;
+ align-items: center;
+ height: 100%;
+ width: 192rpx !important;
+ height: 204rpx !important;
+ margin: unset !important;
+ background-color: #fff !important;
+ border-radius: unset !important;
+}
- :deep(.u-swipe-del::after) {
- content: '';
- width: 120rpx;
- height: 120rpx;
- background-color: transparent;
- display: flex;
- justify-content: center;
- align-items: center;
- margin: 0 32rpx;
- background-image: url('/static/image/course/delete.png');
- background-size: 120rpx 120rpx;
- background-repeat: no-repeat;
- background-position: center;
- }
+:deep(.u-btn-text) {
+ display: none;
+}
- .course-item-container {
- width: 100%;
- background-color: transparent;
- }
+:deep(.u-swipe-del::after) {
+ content: '';
+ width: 120rpx;
+ height: 120rpx;
+ background-color: transparent;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin: 0 32rpx;
+ background-image: url('/static/image/course/delete.png');
+ background-size: 120rpx 120rpx;
+ background-repeat: no-repeat;
+ background-position: center;
+}
- .course-item {
- width: 100%;
- height: 200rpx;
- background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
- padding: 24rpx;
- box-sizing: border-box;
- display: flex;
- align-items: center;
- border-radius: 24rpx;
- }
+.course-item-container {
+ width: 100%;
+ background-color: transparent;
+}
- .img-wrap {
- position: relative;
- width: 152rpx;
- height: 152rpx;
- border-radius: 16rpx;
- margin-right: 24rpx;
- flex-shrink: 0;
- background-color: #BDBDBD; // 测试灰底
- /* Make sure the image wrap doesn't clip the absolutely positioned heart icon */
- overflow: visible;
- }
+.course-item {
+ width: 100%;
+ height: 200rpx;
+ background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
+ padding: 24rpx;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ border-radius: 24rpx;
+}
- .course-img {
- width: 100%;
- height: 100%;
- border-radius: 16rpx;
- }
+.img-wrap {
+ position: relative;
+ width: 152rpx;
+ height: 152rpx;
+ border-radius: 16rpx;
+ margin-right: 24rpx;
+ flex-shrink: 0;
+ background-color: #BDBDBD;
+ overflow: visible;
+}
- .heart-icon {
- position: absolute;
- top: 12rpx;
- right: 12rpx;
- width: 32rpx;
- height: 32rpx;
- z-index: 10;
- }
+.course-img {
+ width: 100%;
+ height: 100%;
+ border-radius: 16rpx;
+}
- .course-info {
- flex: 1;
- height: 152rpx;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- min-width: 0;
- }
+.heart-icon {
+ position: absolute;
+ top: 12rpx;
+ right: 12rpx;
+ width: 32rpx;
+ height: 32rpx;
+ z-index: 10;
+}
- .title-wrap {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- }
+.course-info {
+ flex: 1;
+ height: 152rpx;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-width: 0;
+}
- .tag-list {
- display: flex;
- align-items: center;
- }
+.title-wrap {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
- .tag {
- font-size: 20rpx;
- padding: 2rpx 10rpx;
- border-radius: 8rpx;
- margin-right: 12rpx;
- color: #FFFFFF;
- }
+.tag-list {
+ display: flex;
+ align-items: center;
+}
- .red-tag {
- background-color: #D32E29;
- }
+.tag {
+ font-size: 20rpx;
+ padding: 2rpx 10rpx;
+ border-radius: 8rpx;
+ margin-right: 12rpx;
+ color: #FFFFFF;
+}
- .orange-tag {
- background-color: #C65F34;
- }
+.red-tag {
+ background-color: #D32E29;
+}
- .course-title {
- font-size: 32rpx;
- color: #1A1A1A;
- font-weight: bold;
- flex: 1;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
+.course-title {
+ font-size: 32rpx;
+ color: #1A1A1A;
+ font-weight: bold;
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
- .course-bottom {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
+.course-bottom {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
- .teacher-info {
- display: flex;
- align-items: center;
- }
+.teacher-info {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
- .teacher-avatar {
- width: 40rpx;
- height: 40rpx;
- border-radius: 50%;
- margin-right: 12rpx;
- background-color: #f5f5f5;
- }
+.teacher-avatar {
+ width: 40rpx;
+ height: 40rpx;
+ border-radius: 50%;
+ margin-right: 12rpx;
+ background-color: #f5f5f5;
+}
- .teacher-name {
- font-size: 26rpx;
- color: #86909C;
- }
+.teacher-name {
+ font-size: 26rpx;
+ color: #86909C;
+ max-width: 160rpx;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
- .student-count {
- display: flex;
- align-items: center;
- font-size: 24rpx;
- color: #999999;
+.student-count {
+ display: flex;
+ align-items: center;
+ font-size: 24rpx;
+ color: #999999;
+}
- .user-icon {
- margin-right: 8rpx;
- }
- }
-</style>
\ No newline at end of file
+.user-icon {
+ margin-right: 8rpx;
+}
+</style>
diff --git a/traditionalApp/pages/favorites/service.js b/traditionalApp/pages/favorites/service.js
new file mode 100644
index 0000000..c7890bd
--- /dev/null
+++ b/traditionalApp/pages/favorites/service.js
@@ -0,0 +1,22 @@
+import request from '@/utils/request.js'
+
+/**
+ * 获取当前登录用户的收藏课程列表。
+ */
+export const getFavoriteCourses = () => {
+ return request.get('/api/app/health-courses/favorites', {}, false, false)
+}
+
+/**
+ * 取消单个课程收藏。
+ */
+export const cancelFavoriteCourse = (courseId) => {
+ return request.delete(`/api/app/health-courses/${courseId}/favorite`, {}, false, true)
+}
+
+/**
+ * 批量取消课程收藏。
+ */
+export const batchCancelFavoriteCourses = (courseIds) => {
+ return request.delete('/api/app/health-courses/favorites', { courseIds }, false, true)
+}
diff --git a/traditionalApp/pages/mine/mine.vue b/traditionalApp/pages/mine/mine.vue
index 58931f0..0d8efb0 100644
--- a/traditionalApp/pages/mine/mine.vue
+++ b/traditionalApp/pages/mine/mine.vue
@@ -3,31 +3,27 @@
<view class="top-bg">
<image src="/static/image/home/bg.png" mode="widthFix" class="bg-img"></image>
</view>
- <!-- 状态栏占位 -->
<view class="status-bar-placeholder" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="content">
- <!-- 顶部图标 (通知、设置) -->
<view class="top-actions">
<image class="action-icon" src="/static/image/mine/notice.png" mode="aspectFit" @tap="goPage('/pages/notice/notice')"></image>
<image class="action-icon" src="/static/image/mine/setting.png" mode="aspectFit" @tap="goPage('/pages/settings/settings')"></image>
</view>
- <!-- 用户信息 -->
<view class="user-info">
<view class="avatar-wrap">
- <image class="avatar" :src="userInfo.avatar" mode="aspectFill"></image>
+ <image class="avatar" :src="displayAvatar" mode="aspectFill"></image>
<view class="role-tag" :class="userRole === 'doctor' ? 'doctor-tag' : 'personal-tag'">
{{ userRole === 'doctor' ? '医生' : '个人' }}
</view>
</view>
<view class="user-detail">
- <view class="user-name">{{ userInfo.name }}</view>
- <view v-if="userRole === 'personal'" class="user-points">积分:{{ userInfo.points }}</view>
+ <view class="user-name">{{ displayName }}</view>
+ <view v-if="userRole === 'personal'" class="user-points">积分:{{ displayPoints }}</view>
</view>
</view>
- <!-- 会员入口 -->
<view class="vip-banner">
<image class="vip-bg" src="/static/image/mine/openVip.png" mode="aspectFill"></image>
<view class="vip-content">
@@ -35,23 +31,21 @@
<view class="vip-title-box">
<text class="vip-title">VIP会员</text>
</view>
- <text class="vip-desc">15元起开通畅享养生课</text>
+ <text class="vip-desc">{{ vipDescription }}</text>
</view>
<view class="vip-btn" @tap="goPage('/pages/vip/vip')">立即开通</view>
</view>
</view>
- <!-- 操作区 (网格) -->
<view class="grid-section card-box">
- <view class="grid-item" v-for="(item, index) in gridMenuList" :key="index" @tap="handleGridClick(item)">
+ <view class="grid-item" v-for="(item, index) in gridMenuList" :key="`${item.title}-${index}`" @tap="handleGridClick(item)">
<image class="grid-icon" :src="item.icon" mode="aspectFit"></image>
<text class="grid-title">{{ item.title }}</text>
</view>
</view>
- <!-- 列表区 -->
<view class="list-section card-box">
- <view class="list-item" v-for="(item, index) in bottomMenuList" :key="index" @tap="goPage(item.url)">
+ <view class="list-item" v-for="(item, index) in bottomMenuList" :key="`${item.title}-${index}`" @tap="goPage(item.url)">
<view class="list-left">
<image class="list-icon" :class="item.iconClass" :src="item.icon" mode="aspectFit"></image>
<text class="list-title">{{ item.title }}</text>
@@ -59,335 +53,541 @@
<u-icon name="arrow-right" color="#999" size="16"></u-icon>
</view>
</view>
- <!-- ICP备案号 -->
<view class="icp-text">ICP备案号:XXXXXXXXXXXXXXXX</view>
</view>
</view>
</template>
<script setup lang="ts">
- import { ref, onMounted, computed } from "vue";
- import { onLoad, onShow } from "@dcloudio/uni-app";
+import { computed, ref } from 'vue'
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import { getMemberOpenPage, getUserCenter } from './service'
- const statusBarHeight = ref(0);
- const userRole = ref('personal'); // 'doctor' or 'personal'
+type UserRole = 'personal' | 'doctor'
- const userInfo = ref({
- name: '褚冰枫',
- points: 88888,
- role: 'personal',
- avatar: '/static/image/mine/header.png'
- });
+interface UserCenterData {
+ id?: number | null
+ nickname?: string
+ avatar?: string
+ phone?: string
+ type?: number | null
+ typeText?: string
+ memberExpireAt?: string
+ pointsBalance?: number | null
+ clinicId?: number | null
+ clinicName?: string
+}
- const gridMenuList = computed(() => {
- if (userRole.value === 'doctor') {
- return [
- { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord' },
- { title: '会员续费', icon: '/static/image/mine/vip.png', url: '/pages/vip/vip' },
- { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites' },
- { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads' },
- { title: '我的患者', icon: '/static/image/mine/person.png', url: '/pages/myPatients/myPatients' },
- { title: '诊所信息', icon: '/static/image/mine/hosptial.png', url: '/pages/clinicInfo/clinicInfo' },
- { title: '二维码', icon: '/static/image/mine/code.png', url: '/pages/inviteUser/inviteUser' },
- ];
- } else {
- return [
- { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord' },
- { title: '会员续费', icon: '/static/image/mine/vip.png', url: '/pages/vip/vip' },
- { title: '兑换订单', icon: '/static/image/mine/Shopping@2x.png', url: '/pages/myPoints/myPoints' },
- { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites' },
- { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads' },
- { title: '我的积分', icon: '/static/image/mine/point.png', url: '/pages/myPoints/myPoints' },
- ];
+interface AppUserCache {
+ id?: number | null
+ nickname?: string
+ avatar?: string
+ phone?: string
+ type?: number | null
+ typeText?: string
+ memberExpireAt?: string
+ clinicId?: number | null
+}
+
+interface MenuItem {
+ title: string
+ icon: string
+ url: string
+ iconClass?: string
+}
+
+const statusBarHeight = ref(0)
+const userCenter = ref<UserCenterData>(createDefaultUserCenter())
+const memberMinPrice = ref<number | string | null>(null)
+
+const userRole = computed<UserRole>(() => {
+ return Number(userCenter.value.type) === 1 ? 'doctor' : 'personal'
+})
+const displayName = computed(() => {
+ return normalizeOptionalText(userCenter.value.nickname) || normalizeOptionalText(userCenter.value.phone) || '游客'
+})
+const displayAvatar = computed(() => {
+ return normalizeOptionalText(userCenter.value.avatar) || '/static/image/mine/header.png'
+})
+const displayPoints = computed(() => {
+ const points = Number(userCenter.value.pointsBalance || 0)
+ return Number.isFinite(points) ? points : 0
+})
+const vipDescription = computed(() => {
+ const formattedPrice = formatVipMinPrice(memberMinPrice.value)
+ if (!formattedPrice) {
+ return '开通畅享养生课'
+ }
+ return `${formattedPrice}元起开通畅享养生课`
+})
+
+const gridMenuList = computed<MenuItem[]>(() => {
+ if (userRole.value === 'doctor') {
+ return [
+ { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord' },
+ { title: '会员续费', icon: '/static/image/mine/vip.png', url: '/pages/vip/vip' },
+ { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites' },
+ { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads' },
+ { title: '我的患者', icon: '/static/image/mine/person.png', url: '/pages/myPatients/myPatients' },
+ { title: '诊所信息', icon: '/static/image/mine/hosptial.png', url: '/pages/clinicInfo/clinicInfo' },
+ { title: '二维码', icon: '/static/image/mine/code.png', url: '/pages/inviteUser/inviteUser' },
+ ]
+ }
+ return [
+ { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord' },
+ { title: '会员续费', icon: '/static/image/mine/vip.png', url: '/pages/vip/vip' },
+ { title: '兑换订单', icon: '/static/image/mine/Shopping@2x.png', url: '/pages/exchangeRecord/exchangeRecord' },
+ { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites' },
+ { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads' },
+ { title: '我的积分', icon: '/static/image/mine/point.png', url: '/pages/point/point' },
+ ]
+})
+
+const bottomMenuList = computed<MenuItem[]>(() => {
+ const baseList = [
+ { title: '我的课程', icon: '/static/image/mine/myClass.png', url: '/pages/myCourses/myCourses', iconClass: 'icon-myclass' },
+ { title: '帮助中心', icon: '/static/image/mine/help.png', url: '/pages/helpCenter/helpCenter', iconClass: 'icon-help' },
+ { title: '意见反馈', icon: '/static/image/mine/message.png', url: '/pages/feedback/feedback', iconClass: 'icon-feedback' },
+ ]
+ const agreementList = [
+ { title: '用户协议', icon: '/static/image/mine/coustom.png', url: '/pages/protocol/protocol?type=user', iconClass: 'icon-user-agree' },
+ { title: '隐私协议', icon: '/static/image/mine/agree.png', url: '/pages/protocol/protocol?type=privacy', iconClass: 'icon-privacy' },
+ ]
+ if (userRole.value === 'doctor') {
+ return [
+ ...baseList,
+ { title: '平台资质', icon: '/static/image/mine/chimaping.png', url: '/pages/qualification/qualification', iconClass: 'icon-platform' },
+ ...agreementList,
+ ]
+ }
+ return [...baseList, ...agreementList]
+})
+
+/**
+ * 创建个人中心默认数据,便于首屏兜底。
+ */
+function createDefaultUserCenter(): UserCenterData {
+ return {
+ id: null,
+ nickname: '',
+ avatar: '',
+ phone: '',
+ type: 0,
+ typeText: '个人',
+ memberExpireAt: '',
+ pointsBalance: 0,
+ clinicId: null,
+ clinicName: '',
+ }
+}
+
+/**
+ * 页面加载时初始化状态栏高度并回显本地缓存。
+ */
+onLoad(() => {
+ const systemInfo = uni.getSystemInfoSync()
+ statusBarHeight.value = systemInfo.statusBarHeight || 20
+ syncCachedUserInfo()
+ loadMemberOpenPage()
+})
+
+/**
+ * 页面回显时重新拉取个人中心聚合数据,保证头像和积分同步刷新。
+ */
+onShow(() => {
+ syncCachedUserInfo()
+ loadUserCenter()
+ loadMemberOpenPage()
+})
+
+/**
+ * 拉取当前登录用户的个人中心聚合数据。
+ */
+async function loadUserCenter() {
+ try {
+ const response = await getUserCenter()
+ if (response?.code !== 0 || !response.data) {
+ return
}
- });
+ userCenter.value = normalizeUserCenter(response.data)
+ cacheUserCenter(userCenter.value)
+ } catch {
+ // 未登录或网络异常时由统一请求层处理跳转,这里不重复 toast。
+ }
+}
- const bottomMenuList = computed(() => {
- const baseList = [
- { title: '我的课程', icon: '/static/image/mine/myClass.png', url: '/pages/myCourses/myCourses', iconClass: 'icon-myclass' },
- { title: '帮助中心', icon: '/static/image/mine/help.png', url: '/pages/helpCenter/helpCenter', iconClass: 'icon-help' },
- { title: '意见反馈', icon: '/static/image/mine/message.png', url: '/pages/feedback/feedback', iconClass: 'icon-feedback' },
- ];
-
- const agreementList = [
- { title: '用户协议', icon: '/static/image/mine/coustom.png', url: '/pages/protocol/protocol?type=user', iconClass: 'icon-user-agree' },
- { title: '隐私协议', icon: '/static/image/mine/agree.png', url: '/pages/protocol/protocol?type=privacy', iconClass: 'icon-privacy' },
- ];
-
- if (userRole.value === 'doctor') {
- return [
- ...baseList,
- { title: '平台资质', icon: '/static/image/mine/chimaping.png', url: '/pages/qualification/qualification', iconClass: 'icon-platform' },
- ...agreementList
- ];
- } else {
- return [
- ...baseList,
- ...agreementList
- ];
+/**
+ * 拉取会员开通页最低价格,用于个人中心会员横幅文案展示。
+ */
+async function loadMemberOpenPage() {
+ try {
+ const response = await getMemberOpenPage()
+ if (response?.code !== 0 || !response.data) {
+ memberMinPrice.value = null
+ return
}
- });
+ memberMinPrice.value = response.data.minPrice ?? null
+ } catch {
+ // 文案接口失败时使用兜底文案,避免个人中心反复提示。
+ memberMinPrice.value = null
+ }
+}
- onMounted(() => {
- const systemInfo = uni.getSystemInfoSync();
- statusBarHeight.value = systemInfo.statusBarHeight || 20;
- });
+/**
+ * 使用本地登录缓存兜底首屏展示,减少页面闪烁。
+ */
+function syncCachedUserInfo() {
+ const cachedUser = uni.getStorageSync('appUserInfo')
+ if (!cachedUser || typeof cachedUser !== 'object') {
+ userCenter.value = createDefaultUserCenter()
+ return
+ }
+ userCenter.value = normalizeUserCenter(cachedUser)
+}
- const handleGridClick = (item : any) => {
- if (item.url) {
- uni.navigateTo({
- url: item.url
- });
- }
- };
+/**
+ * 将接口或缓存中的用户信息归一化为页面可直接消费的结构。
+ */
+function normalizeUserCenter(value: unknown): UserCenterData {
+ const source = (value && typeof value === 'object' ? value : {}) as Partial<UserCenterData>
+ const type = Number(source.type)
+ return {
+ id: Number.isFinite(Number(source.id)) ? Number(source.id) : null,
+ nickname: normalizeOptionalText(source.nickname),
+ avatar: normalizeOptionalText(source.avatar),
+ phone: normalizeOptionalText(source.phone),
+ type: Number.isFinite(type) ? type : 0,
+ typeText: normalizeOptionalText(source.typeText),
+ memberExpireAt: normalizeOptionalText(source.memberExpireAt),
+ pointsBalance: Number.isFinite(Number(source.pointsBalance)) ? Number(source.pointsBalance) : 0,
+ clinicId: Number.isFinite(Number(source.clinicId)) ? Number(source.clinicId) : null,
+ clinicName: normalizeOptionalText(source.clinicName),
+ }
+}
- const goPage = (url : string) => {
- uni.navigateTo({
- url
- });
- };
+/**
+ * 将个人中心接口返回的关键用户字段同步回本地登录缓存。
+ */
+function cacheUserCenter(centerData: UserCenterData) {
+ const cachedUser = uni.getStorageSync('appUserInfo')
+ const nextCache: AppUserCache = {
+ ...(cachedUser && typeof cachedUser === 'object' ? cachedUser : {}),
+ id: centerData.id,
+ nickname: centerData.nickname,
+ avatar: centerData.avatar,
+ phone: centerData.phone,
+ type: centerData.type,
+ typeText: centerData.typeText,
+ memberExpireAt: centerData.memberExpireAt,
+ clinicId: centerData.clinicId,
+ }
+ uni.setStorageSync('appUserInfo', nextCache)
+}
+
+/**
+ * 点击宫格菜单时跳转目标页面。
+ */
+function handleGridClick(item: MenuItem) {
+ if (!item.url) {
+ return
+ }
+ goPage(item.url)
+}
+
+/**
+ * 统一跳转个人中心相关页面。
+ */
+function goPage(url: string) {
+ if (!url) {
+ return
+ }
+ uni.navigateTo({
+ url,
+ })
+}
+
+/**
+ * 将会员最低价格式化为横幅文案所需的金额字符串。
+ */
+function formatVipMinPrice(value: number | string | null | undefined) {
+ const price = Number(value)
+ if (!Number.isFinite(price) || price <= 0) {
+ return ''
+ }
+ const fixed = price.toFixed(2)
+ return fixed.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1')
+}
+
+/**
+ * 将可选文本归一化为空字符串。
+ */
+function normalizeOptionalText(value: unknown) {
+ if (typeof value !== 'string') {
+ return ''
+ }
+ return value.trim()
+}
</script>
<style lang="scss" scoped>
- page {
- background-color: #F8F6F4;
- }
+page {
+ background-color: #F8F6F4;
+}
- .container {
+.container {
+ width: 750rpx;
+ min-height: 100vh;
+ position: relative;
+ background-color: #F8F6F4;
+ padding-bottom: env(safe-area-inset-bottom);
+}
+
+.top-bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 750rpx;
+ z-index: 0;
+
+ .bg-img {
width: 750rpx;
- min-height: 100vh;
+ height: auto;
+ }
+}
+
+.status-bar-placeholder {
+ width: 100%;
+ position: relative;
+ z-index: 10;
+}
+
+.content {
+ position: relative;
+ z-index: 10;
+ padding: 0 32rpx;
+ padding-bottom: 40rpx;
+}
+
+.top-actions {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ padding-top: 20rpx;
+ gap: 32rpx;
+
+ .action-icon {
+ width: 48rpx;
+ height: 48rpx;
+ }
+}
+
+.user-info {
+ display: flex;
+ align-items: center;
+ margin-top: 40rpx;
+
+ .avatar-wrap {
position: relative;
- background-color: #F8F6F4;
- padding-bottom: env(safe-area-inset-bottom);
- }
+ width: 120rpx;
+ height: 120rpx;
- .top-bg {
- position: absolute;
- top: 0;
- left: 0;
- width: 750rpx;
- z-index: 0;
-
- .bg-img {
- width: 750rpx;
- height: auto;
- }
- }
-
- .status-bar-placeholder {
- width: 100%;
- position: relative;
- z-index: 10;
- }
-
- .content {
- position: relative;
- z-index: 10;
- padding: 0 32rpx;
- padding-bottom: 40rpx;
- }
-
- .top-actions {
- display: flex;
- justify-content: flex-end;
- align-items: center;
- padding-top: 20rpx;
- gap: 32rpx;
-
- .action-icon {
- width: 48rpx;
- height: 48rpx;
- }
- }
-
- .user-info {
- display: flex;
- align-items: center;
- margin-top: 40rpx;
-
- .avatar-wrap {
- position: relative;
+ .avatar {
width: 120rpx;
height: 120rpx;
-
- .avatar {
- width: 120rpx;
- height: 120rpx;
- border-radius: 50%;
- background-color: #eee;
- }
-
- .role-tag {
- position: absolute;
- bottom: -10rpx;
- left: 50%;
- transform: translateX(-50%);
- color: #fff;
- font-size: 20rpx;
- padding: 2rpx 12rpx;
- border-radius: 10rpx;
- white-space: nowrap;
- z-index: 1;
- }
-
- .doctor-tag {
- background-color: #E64340;
- }
-
- .personal-tag {
- background-color: #FF5A5F;
- }
+ border-radius: 50%;
+ background-color: #eee;
}
- .user-detail {
- margin-left: 32rpx;
- display: flex;
- flex-direction: column;
+ .role-tag {
+ position: absolute;
+ bottom: -10rpx;
+ left: 50%;
+ transform: translateX(-50%);
+ color: #fff;
+ font-size: 20rpx;
+ padding: 2rpx 12rpx;
+ border-radius: 10rpx;
+ white-space: nowrap;
+ z-index: 1;
+ }
- .user-name {
- font-size: 40rpx;
- color: #1A1A1A;
- font-weight: 500;
- }
+ .doctor-tag {
+ background-color: #E64340;
+ }
- .user-points {
- font-size: 24rpx;
- color: #666;
- margin-top: 8rpx;
- }
+ .personal-tag {
+ background-color: #FF5A5F;
}
}
- .vip-banner {
+ .user-detail {
+ margin-left: 32rpx;
+ display: flex;
+ flex-direction: column;
+
+ .user-name {
+ font-size: 40rpx;
+ color: #1A1A1A;
+ font-weight: 500;
+ }
+
+ .user-points {
+ font-size: 24rpx;
+ color: #666;
+ margin-top: 8rpx;
+ }
+ }
+}
+
+.vip-banner {
+ position: relative;
+ width: 100%;
+ height: 140rpx;
+ border-radius: 16rpx;
+ overflow: hidden;
+ margin-top: 48rpx;
+ margin-bottom: 32rpx;
+
+ .vip-bg {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ }
+
+ .vip-content {
position: relative;
width: 100%;
- height: 140rpx;
- border-radius: 16rpx;
- overflow: hidden;
- margin-top: 48rpx;
- margin-bottom: 32rpx;
+ height: 100%;
+ box-sizing: border-box;
+ padding: 24rpx 32rpx 24rpx 140rpx;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
- .vip-bg {
- position: absolute;
- width: 100%;
- height: 100%;
- top: 0;
- left: 0;
- }
-
- .vip-content {
- position: relative;
- width: 100%;
- height: 100%;
- box-sizing: border-box;
- padding: 24rpx 32rpx 24rpx 140rpx;
- display: flex;
- justify-content: space-between;
- align-items: center;
-
- .vip-left {
- display: flex;
- flex-direction: column;
- justify-content: center;
-
- .vip-title {
- font-size: 32rpx;
- font-weight: bold;
- color: #AA5200;
- margin-bottom: 4rpx;
- }
-
- .vip-desc {
- font-size: 24rpx;
- color: #AA5200;
- opacity: 0.8;
- }
- }
-
- .vip-btn {
- background: linear-gradient(180deg, #FFD99B 0%, #FFB13C 100%);
- color: #AA5200;
- font-size: 24rpx;
- font-weight: bold;
- padding: 12rpx 24rpx;
- border-radius: 30rpx;
- }
- }
- }
-
- .card-box {
- background-color: #FFFFFF;
- border-radius: 16rpx;
- padding: 32rpx;
- margin-bottom: 24rpx;
- }
-
- .grid-section {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 40rpx 0;
-
- .grid-item {
+ .vip-left {
display: flex;
flex-direction: column;
- align-items: center;
+ justify-content: center;
- .grid-icon {
- width: 56rpx;
- height: 56rpx;
- margin-bottom: 16rpx;
+ .vip-title {
+ font-size: 32rpx;
+ font-weight: bold;
+ color: #AA5200;
+ margin-bottom: 4rpx;
}
- .grid-title {
- font-size: 26rpx;
- color: #1A1A1A;
+ .vip-desc {
+ font-size: 24rpx;
+ color: #AA5200;
+ opacity: 0.8;
}
}
+
+ .vip-btn {
+ background: linear-gradient(180deg, #FFD99B 0%, #FFB13C 100%);
+ color: #AA5200;
+ font-size: 24rpx;
+ font-weight: bold;
+ padding: 12rpx 24rpx;
+ border-radius: 30rpx;
+ }
}
+}
- .list-section {
- padding: 0 32rpx;
+.card-box {
+ background-color: #FFFFFF;
+ border-radius: 16rpx;
+ padding: 32rpx;
+ margin-bottom: 24rpx;
+}
- .list-item {
+.grid-section {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 40rpx 0;
+
+ .grid-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ .grid-icon {
+ width: 56rpx;
+ height: 56rpx;
+ margin-bottom: 16rpx;
+ }
+
+ .grid-title {
+ font-size: 26rpx;
+ color: #1A1A1A;
+ }
+ }
+}
+
+.list-section {
+ padding: 0 32rpx;
+
+ .list-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 110rpx;
+ border-bottom: 1px solid #F2F3F5;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .list-left {
display: flex;
align-items: center;
- justify-content: space-between;
- height: 110rpx;
- border-bottom: 1px solid #F2F3F5;
- &:last-child {
- border-bottom: none;
+ .list-icon {
+ width: 40rpx;
+ height: 40rpx;
+
+ &.icon-help {
+ width: 34rpx;
+ height: 34rpx;
+ }
+
+ &.icon-feedback {
+ width: 34rpx;
+ height: 34rpx;
+ }
+
+ &.icon-platform {
+ width: 34rpx;
+ height: 34rpx;
+ }
+
+ &.icon-user-agree {
+ width: 30rpx;
+ height: 30rpx;
+ }
+
+ &.icon-privacy {
+ width: 27rpx;
+ height: 33rpx;
+ }
}
- .list-left {
- display: flex;
- align-items: center;
-
- .list-icon {
- width: 40rpx;
- height: 40rpx;
-
- &.icon-help { width: 34rpx; height: 34rpx; }
- &.icon-feedback { width: 34rpx; height: 34rpx; }
- &.icon-platform { width: 34rpx; height: 34rpx; }
- &.icon-user-agree { width: 30rpx; height: 30rpx; }
- &.icon-privacy { width: 27rpx; height: 33rpx; }
- }
-
- .list-title {
- font-size: 32rpx;
- color: #1A1A1A;
- margin-left: 22rpx;
- }
+ .list-title {
+ font-size: 32rpx;
+ color: #1A1A1A;
+ margin-left: 22rpx;
}
}
}
+}
- .icp-text {
- font-size: 28rpx;
- color: #86909C;
- text-align: center;
- margin-top: 32rpx;
- padding-bottom: 20rpx;
- }
+.icp-text {
+ font-size: 28rpx;
+ color: #86909C;
+ text-align: center;
+ margin-top: 32rpx;
+ padding-bottom: 20rpx;
+}
</style>
diff --git a/traditionalApp/pages/mine/service.js b/traditionalApp/pages/mine/service.js
index 6aa6b01..111066d 100644
--- a/traditionalApp/pages/mine/service.js
+++ b/traditionalApp/pages/mine/service.js
@@ -1,12 +1,15 @@
import request from '@/utils/request.js'
-//人中心--用户信息
-export const getUserInfo = (data) => {
- return request.get(`/userIndex/getUserInfo`, )
+/**
+ * 获取 APP 个人中心聚合数据。
+ */
+export const getUserCenter = () => {
+ return request.get('/api/app/user-center', {}, false, false)
}
-
-//协议配置
-export const htmlConfig = (type) => {
- return request.get(`/html-config/${type}`, )
-}
\ No newline at end of file
+/**
+ * 获取 APP 会员开通页聚合数据。
+ */
+export const getMemberOpenPage = () => {
+ return request.get('/api/app/member/open-page', {}, false, false)
+}
diff --git a/traditionalApp/pages/myCourseDetail/myCourseDetail.vue b/traditionalApp/pages/myCourseDetail/myCourseDetail.vue
index 0797765..8c467d7 100644
--- a/traditionalApp/pages/myCourseDetail/myCourseDetail.vue
+++ b/traditionalApp/pages/myCourseDetail/myCourseDetail.vue
@@ -1,6 +1,5 @@
<template>
<view class="container">
- <!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-content">
<image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
@@ -9,39 +8,43 @@
</view>
</view>
- <!-- 视频播放区域 (占位) -->
<view class="video-section">
- <!-- 这里用图片模拟视频播放器 -->
- <image class="video-poster" src="/static/image/course/bg.png" mode="aspectFill"></image>
- <view class="play-btn-wrap">
- <image class="play-icon" src="/static/image/course/live.png" mode="aspectFit"></image>
- </view>
- <!-- 底部控制栏模拟 -->
- <view class="video-controls">
- <view class="controls-left">
- <view class="control-icon play-small"></view>
- <view class="control-icon next-small"></view>
- <view class="control-icon sound-small"></view>
- </view>
- <view class="controls-right">
- <view class="control-icon setting-small"></view>
- <view class="control-icon fullscreen-small"></view>
+ <view v-if="currentVideoUrl" class="video-player-wrapper">
+ <video
+ id="my-course-video-player"
+ class="video-player"
+ :src="currentVideoUrl"
+ :poster="videoPoster"
+ :controls="true"
+ :autoplay="isAppPlus"
+ object-fit="contain"
+ play-btn-position="center"
+ @loadedmetadata="handleVideoLoadedMetadata"
+ @timeupdate="handleVideoTimeUpdate"
+ @play="handleVideoPlay"
+ @pause="handleVideoPause"
+ @ended="handleVideoEnded"
+ @error="handleVideoError"
+ ></video>
+ <view v-if="showPlaybackRetry" class="video-retry-mask" @tap.stop="retryPlayback">
+ <text class="video-retry-text">点击开始播放</text>
</view>
</view>
+ <image v-else class="video-poster" :src="videoPoster" mode="aspectFill"></image>
</view>
- <!-- 课程列表头部 -->
<view class="list-header">
<text class="list-title">课程列表</text>
- <text class="list-desc">共12课 | 15小时</text>
+ <text class="list-desc">共{{ detail.totalLessons || 0 }}课 | {{ detail.totalDurationText || '0分钟' }}</text>
</view>
- <!-- 课程列表 -->
<scroll-view scroll-y class="lesson-list">
- <block v-for="(item, index) in lessonList" :key="index">
- <view class="lesson-item" :class="{ active: item.isActive }">
+ <view v-if="!loading && lessonList.length === 0" class="empty-box">
+ <text class="empty-text">暂无课程章节</text>
+ </view>
+ <block v-for="item in lessonList" :key="item.videoId">
+ <view class="lesson-item" :class="{ active: item.isActive }" @tap="switchVideo(item.videoId)">
<view class="item-left">
- <!-- 正在播放的动画图标 -->
<view class="playing-icon" v-if="item.isActive">
<view class="bar bar1"></view>
<view class="bar bar2"></view>
@@ -53,9 +56,13 @@
</view>
<view class="item-right">
<text class="lesson-duration" :class="{ 'active-text': item.isActive }">{{ item.duration }}</text>
- <image class="download-icon" :src="item.isDownloaded ? '/static/image/course/grayDownLoad.png' : '/static/image/course/redDownload@2x.png'" mode="aspectFit"></image>
+ <image
+ class="download-icon"
+ :src="item.isDownloaded ? '/static/image/course/grayDownLoad.png' : '/static/image/course/redDownload@2x.png'"
+ mode="aspectFit"
+ @tap.stop="handleVideoDownload(item)"
+ ></image>
</view>
- <!-- 进度条 -->
<view class="progress-wrap" v-if="item.progress > 0">
<view class="progress-bar">
<view class="progress-inner" :style="{ width: item.progress + '%' }"></view>
@@ -66,13 +73,12 @@
</block>
</scroll-view>
- <!-- 底部操作栏 -->
<view class="bottom-bar">
- <view class="action-btn">
- <image class="action-icon" src="/static\image\healthCourses\listen.png" mode="aspectFit"></image>
+ <view class="action-btn" @tap="handleConsult">
+ <image class="action-icon" src="/static/image/healthCourses/listen.png" mode="aspectFit"></image>
<text class="action-text">咨询</text>
</view>
- <view class="action-btn">
+ <view class="action-btn" @tap="handleCurrentVideoDownload">
<image class="action-icon" src="/static/image/course/download.png" mode="aspectFit"></image>
<text class="action-text">下载</text>
</view>
@@ -82,31 +88,895 @@
</template>
<script setup lang="ts">
- import { ref, onMounted } from 'vue';
+import { computed, getCurrentInstance, ref } from 'vue'
+import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
+import { getMyHealthCourseDetail, uploadMyHealthCoursePlayRecord } from './service'
+import { clearDownloadTask, listDownloadTasks, upsertDownloadTask } from '@/utils/download-task-storage.js'
+import { reportHealthCourseDownload } from '@/pages/courseDetail/service'
- const statusBarHeight = ref(20);
+interface MyHealthCourseVideoItem {
+ videoId: number
+ chapterName: string
+ durationSeconds: number
+ durationText: string
+ videoUrl: string
+ completed: boolean
+ progressPercent: number
+ lastPlayPositionSeconds: number
+ downloadable: boolean
+}
- const lessonList = ref([
- { title: '1、食品安全风险分级管理 (1)', duration: '00:20:42', isActive: false, progress: 0, isDownloaded: true },
- { title: '1、食品安全风险分级管理 (1)', duration: '00:20:42', isActive: true, progress: 0, isDownloaded: false },
- { title: '1、食品安全风险分级管理 (1)', duration: '00:20:42', isActive: false, progress: 0, isDownloaded: false },
- { title: '1、食品安全风险分级管理 (1)', duration: '00:20:42', isActive: false, progress: 0, isDownloaded: true },
- { title: '1、食品安全风险分级管理 (1)', duration: '00:20:42', isActive: false, progress: 40, isDownloaded: false },
- { title: '1、食品安全风险分级管理 (1)', duration: '00:20:42', isActive: false, progress: 0, isDownloaded: false }
- ]);
+interface MyHealthCourseDetail {
+ courseId: number
+ title: string
+ coverImage: string
+ courseTitle: string
+ totalLessons: number
+ totalDurationText: string
+ currentVideoId: number
+ servicePhone: string
+ introAvailable: boolean
+ videos: MyHealthCourseVideoItem[]
+}
- onMounted(() => {
- const systemInfo = uni.getSystemInfoSync();
- statusBarHeight.value = systemInfo.statusBarHeight || 20;
- });
+interface LessonViewItem {
+ videoId: number
+ title: string
+ duration: string
+ progress: number
+ isDownloaded: boolean
+ isActive: boolean
+ downloadable: boolean
+ videoUrl: string
+ durationSeconds: number
+}
- const goBack = () => {
- uni.navigateBack();
- };
+interface LocalDownloadTask {
+ clientTaskId: string
+ courseId: number
+ courseVideoId: number
+ title: string
+ subtitle: string
+ coverImage: string
+ durationSeconds: number
+ durationText: string
+ videoUrl: string
+ progress: number
+ status: 'downloading'
+ updatedAt: string
+}
- const goCourseIntro = () => {
- console.log('Go to course intro');
- };
+type VideoMetadataEvent = {
+ detail?: {
+ duration?: number
+ }
+}
+
+type VideoTimeUpdateEvent = {
+ detail?: {
+ currentTime?: number
+ }
+}
+
+const VIDEO_PLAYER_ID = 'my-course-video-player'
+const DEFAULT_COURSE_IMAGE = '/static/image/home/class.png'
+let isAppPlus = false
+let isH5 = false
+// #ifdef APP-PLUS
+isAppPlus = true
+// #endif
+// #ifdef H5
+isH5 = true
+// #endif
+
+const statusBarHeight = ref(20)
+const loading = ref(false)
+const courseId = ref('')
+const detail = ref<MyHealthCourseDetail>(createDefaultDetail())
+const activeVideoId = ref<number>(0)
+const isVideoReady = ref(false)
+const isPlaybackStarted = ref(false)
+const playbackFailed = ref(false)
+const isSeekingPlayback = ref(false)
+const mediaDurationSeconds = ref(0)
+const currentWatchStartedAt = ref(0)
+const currentPlaybackSeconds = ref(0)
+const lastReportedPlaybackSeconds = ref(0)
+const backSubmitting = ref(false)
+const watchSegmentSubmitting = ref(false)
+const instance = getCurrentInstance()
+let videoContext: ReturnType<typeof uni.createVideoContext> | null = null
+let watchHeartbeatTimer: ReturnType<typeof setInterval> | null = null
+
+/**
+ * 生成默认详情数据,避免页面空对象访问。
+ */
+function createDefaultDetail(): MyHealthCourseDetail {
+ return {
+ courseId: 0,
+ title: '',
+ coverImage: '',
+ courseTitle: '',
+ totalLessons: 0,
+ totalDurationText: '',
+ currentVideoId: 0,
+ servicePhone: '',
+ introAvailable: false,
+ videos: [],
+ }
+}
+
+const currentVideo = computed(() => {
+ return detail.value.videos.find((item) => item.videoId === activeVideoId.value) || null
+})
+
+const currentVideoUrl = computed(() => currentVideo.value?.videoUrl || '')
+const videoPoster = computed(() => detail.value.coverImage || DEFAULT_COURSE_IMAGE)
+const lessonList = computed<LessonViewItem[]>(() => {
+ const downloadingTasks = listCurrentDownloadTasks()
+ return detail.value.videos.map((item) => ({
+ videoId: item.videoId,
+ title: item.chapterName,
+ duration: item.durationText,
+ progress: normalizePercent(item.progressPercent),
+ isDownloaded: item.downloadable && hasDownloadedTask(downloadingTasks, item.videoId),
+ isActive: item.videoId === activeVideoId.value,
+ downloadable: item.downloadable,
+ videoUrl: item.videoUrl,
+ durationSeconds: item.durationSeconds,
+ }))
+})
+const showPlaybackRetry = computed(() => {
+ if (!currentVideoUrl.value || !isVideoReady.value) {
+ return false
+ }
+ return playbackFailed.value || (isH5 && !isPlaybackStarted.value)
+})
+
+/**
+ * 页面加载时初始化参数与状态栏高度。
+ */
+onLoad((options) => {
+ const systemInfo = uni.getSystemInfoSync()
+ statusBarHeight.value = systemInfo.statusBarHeight || 20
+ courseId.value = typeof options?.courseId === 'string' ? options.courseId : ''
+})
+
+/**
+ * 页面显示时刷新详情,保证章节和进度为最新状态。
+ */
+onShow(() => {
+ loadMyHealthCourseDetail()
+})
+
+/**
+ * 页面隐藏时补提最后一段播放区间。
+ */
+onHide(() => {
+ clearWatchHeartbeatTimer()
+ submitCurrentWatchSegment()
+})
+
+/**
+ * 页面卸载时补提最后一段播放区间。
+ */
+onUnload(() => {
+ unbindH5PlaybackLifecycle()
+ clearWatchHeartbeatTimer()
+ submitCurrentWatchSegment()
+})
+
+/**
+ * 拉取我的课程详情。
+ */
+async function loadMyHealthCourseDetail() {
+ if (!courseId.value) {
+ return
+ }
+ loading.value = true
+ await submitCurrentWatchSegment()
+ resetPlaybackState()
+ try {
+ const response = await getMyHealthCourseDetail(courseId.value)
+ if (response?.code !== 0 || !response.data) {
+ return
+ }
+ detail.value = normalizeDetail(response.data)
+ activeVideoId.value = resolveInitialVideoId(detail.value)
+ initVideoContext()
+ bindH5PlaybackLifecycle()
+ } catch {
+ // 请求层已统一处理异常提示。
+ } finally {
+ loading.value = false
+ }
+}
+
+/**
+ * 将接口详情归一化为页面结构。
+ */
+function normalizeDetail(value: unknown): MyHealthCourseDetail {
+ const record = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>
+ return {
+ courseId: normalizeNumber(record.courseId),
+ title: normalizeText(record.title),
+ coverImage: normalizeText(record.coverImage),
+ courseTitle: normalizeText(record.courseTitle),
+ totalLessons: normalizeNumber(record.totalLessons),
+ totalDurationText: normalizeText(record.totalDurationText),
+ currentVideoId: normalizeNumber(record.currentVideoId),
+ servicePhone: normalizeText(record.servicePhone),
+ introAvailable: !!record.introAvailable,
+ videos: normalizeVideos(record.videos),
+ }
+}
+
+/**
+ * 归一化章节列表。
+ */
+function normalizeVideos(value: unknown): MyHealthCourseVideoItem[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+ return value.map((item) => {
+ const video = (item && typeof item === 'object' ? item : {}) as Record<string, unknown>
+ return {
+ videoId: normalizeNumber(video.videoId),
+ chapterName: normalizeText(video.chapterName) || '课程章节',
+ durationSeconds: normalizeNumber(video.durationSeconds),
+ durationText: normalizeText(video.durationText) || '00:00:00',
+ videoUrl: normalizeText(video.videoUrl),
+ completed: !!video.completed,
+ progressPercent: normalizePercent(video.progressPercent),
+ lastPlayPositionSeconds: normalizeNumber(video.lastPlayPositionSeconds),
+ downloadable: !!video.downloadable,
+ }
+ }).filter((item) => item.videoId > 0)
+}
+
+/**
+ * 解析默认播放章节,优先续播章节。
+ */
+function resolveInitialVideoId(detailValue: MyHealthCourseDetail) {
+ if (detailValue.currentVideoId > 0) {
+ return detailValue.currentVideoId
+ }
+ return detailValue.videos[0]?.videoId || 0
+}
+
+/**
+ * 初始化视频上下文,便于控制续播和自动播放。
+ */
+function initVideoContext() {
+ videoContext = uni.createVideoContext(VIDEO_PLAYER_ID, instance?.proxy)
+}
+
+/**
+ * 重置播放状态。
+ */
+function resetPlaybackState() {
+ isVideoReady.value = false
+ isPlaybackStarted.value = false
+ playbackFailed.value = false
+ isSeekingPlayback.value = false
+ mediaDurationSeconds.value = 0
+ currentWatchStartedAt.value = 0
+ currentPlaybackSeconds.value = 0
+ lastReportedPlaybackSeconds.value = 0
+ clearWatchHeartbeatTimer()
+}
+
+/**
+ * 元数据加载完成后执行续播。
+ */
+function handleVideoLoadedMetadata(event: VideoMetadataEvent) {
+ isVideoReady.value = true
+ mediaDurationSeconds.value = normalizeNumber(event?.detail?.duration) || currentVideo.value?.durationSeconds || 0
+ seekToResumePosition()
+ retryPlayback()
+}
+
+/**
+ * 播放过程中持续记录当前时间,确保暂停或离开时能上报真实续播点。
+ */
+function handleVideoTimeUpdate(event: VideoTimeUpdateEvent) {
+ if (isSeekingPlayback.value) {
+ return
+ }
+ const nextPlaybackSeconds = Math.max(Math.floor(Number(event?.detail?.currentTime || 0)), 0)
+ const previousPlaybackSeconds = currentPlaybackSeconds.value
+ currentPlaybackSeconds.value = nextPlaybackSeconds
+ if (nextPlaybackSeconds > previousPlaybackSeconds) {
+ isPlaybackStarted.value = true
+ playbackFailed.value = false
+ startWatchSegment()
+ startWatchHeartbeat()
+ }
+}
+
+/**
+ * 播放开始时记录片段开始位置。
+ */
+function handleVideoPlay() {
+ isPlaybackStarted.value = true
+ playbackFailed.value = false
+ if (!isSeekingPlayback.value) {
+ startWatchSegment()
+ startWatchHeartbeat()
+ }
+}
+
+/**
+ * 播放暂停时提交已观看区间。
+ */
+function handleVideoPause() {
+ if (isSeekingPlayback.value) {
+ return
+ }
+ clearWatchHeartbeatTimer()
+ submitCurrentWatchSegment()
+}
+
+/**
+ * 播放结束时提交最后一段区间,并把当前位置归零。
+ */
+function handleVideoEnded() {
+ currentPlaybackSeconds.value = mediaDurationSeconds.value
+ clearWatchHeartbeatTimer()
+ submitCurrentWatchSegment(mediaDurationSeconds.value)
+ updateActiveVideoProgress(100, true, 0)
+}
+
+/**
+ * 视频报错时展示点击重试入口。
+ */
+function handleVideoError() {
+ playbackFailed.value = true
+ clearWatchHeartbeatTimer()
+}
+
+/**
+ * 重试当前章节播放。
+ */
+function retryPlayback() {
+ if (!videoContext || !currentVideoUrl.value) {
+ return
+ }
+ try {
+ videoContext.play()
+ } catch {
+ playbackFailed.value = true
+ }
+}
+
+/**
+ * 按章节续播位置 seek。
+ */
+function seekToResumePosition() {
+ if (!videoContext || !currentVideo.value) {
+ return
+ }
+ const seekSeconds = normalizeNumber(currentVideo.value.lastPlayPositionSeconds)
+ if (seekSeconds <= 0) {
+ return
+ }
+ isSeekingPlayback.value = true
+ currentPlaybackSeconds.value = seekSeconds
+ videoContext.seek(seekSeconds)
+ setTimeout(() => {
+ isSeekingPlayback.value = false
+ }, 300)
+}
+
+/**
+ * 记录当前播放片段起点。
+ */
+function startWatchSegment() {
+ if (currentWatchStartedAt.value > 0) {
+ return
+ }
+ currentWatchStartedAt.value = Math.floor(Date.now() / 1000)
+}
+
+/**
+ * 启动播放心跳,保证 H5 环境下持续播放时也能定时落库。
+ */
+function startWatchHeartbeat() {
+ if (watchHeartbeatTimer) {
+ return
+ }
+ watchHeartbeatTimer = setInterval(() => {
+ if (!shouldFlushWatchHeartbeat()) {
+ return
+ }
+ submitCurrentWatchSegment()
+ }, 15000)
+}
+
+/**
+ * 清理播放心跳定时器,避免页面离开后继续上报。
+ */
+function clearWatchHeartbeatTimer() {
+ if (!watchHeartbeatTimer) {
+ return
+ }
+ clearInterval(watchHeartbeatTimer)
+ watchHeartbeatTimer = null
+}
+
+/**
+ * 判断当前是否满足心跳补提条件,避免无效空请求。
+ */
+function shouldFlushWatchHeartbeat() {
+ if (watchSegmentSubmitting.value || !currentWatchStartedAt.value || !currentVideo.value) {
+ return false
+ }
+ if (isSeekingPlayback.value) {
+ return false
+ }
+ return currentPlaybackSeconds.value > lastReportedPlaybackSeconds.value
+}
+
+/**
+ * 提交当前播放片段。
+ */
+async function submitCurrentWatchSegment(forcePositionSeconds?: number) {
+ if (watchSegmentSubmitting.value) {
+ return false
+ }
+ if (!courseId.value || !currentVideo.value) {
+ return false
+ }
+ if (!currentWatchStartedAt.value) {
+ return false
+ }
+ const now = new Date()
+ const startedAt = currentWatchStartedAt.value
+ const durationSeconds = Math.max(Math.floor(Date.now() / 1000) - currentWatchStartedAt.value, 0)
+ currentWatchStartedAt.value = 0
+ if (durationSeconds <= 0 && typeof forcePositionSeconds !== 'number') {
+ return false
+ }
+ const playEnd = now
+ const playStart = new Date(playEnd.getTime() - durationSeconds * 1000)
+ const lastPlayPositionSeconds = typeof forcePositionSeconds === 'number'
+ ? Math.max(forcePositionSeconds, 0)
+ : resolveCurrentPositionSeconds()
+ try {
+ watchSegmentSubmitting.value = true
+ const response = await uploadMyHealthCoursePlayRecord(courseId.value, currentVideo.value.videoId, {
+ playStart: formatDateTime(playStart),
+ playEnd: formatDateTime(playEnd),
+ durationSeconds,
+ lastPlayPositionSeconds,
+ })
+ if (response?.code !== 0 || !response.data) {
+ currentWatchStartedAt.value = startedAt
+ return false
+ }
+ updateActiveVideoProgress(
+ normalizePercent(response.data.progressPercent),
+ !!response.data.completed,
+ normalizeNumber(response.data.lastPlayPositionSeconds),
+ )
+ lastReportedPlaybackSeconds.value = Math.max(lastPlayPositionSeconds, 0)
+ if (!response.data.completed && currentPlaybackSeconds.value > 0) {
+ startWatchSegment()
+ }
+ return true
+ } catch {
+ currentWatchStartedAt.value = startedAt
+ return false
+ } finally {
+ watchSegmentSubmitting.value = false
+ }
+}
+
+/**
+ * 估算当前播放位置,供暂停/离开页面时上报续播点。
+ */
+function resolveCurrentPositionSeconds() {
+ if (currentVideo.value?.completed) {
+ return 0
+ }
+ if (currentPlaybackSeconds.value > 0) {
+ return normalizePlayPosition(currentPlaybackSeconds.value)
+ }
+ return normalizePlayPosition(currentVideo.value?.lastPlayPositionSeconds)
+}
+
+/**
+ * 更新当前章节的本地进度,避免页面等待下一次刷新。
+ */
+function updateActiveVideoProgress(progressPercent: number, completed: boolean, lastPlayPositionSeconds: number) {
+ const nextVideos = detail.value.videos.map((item) => {
+ if (item.videoId !== activeVideoId.value) {
+ return item
+ }
+ return {
+ ...item,
+ progressPercent,
+ completed,
+ lastPlayPositionSeconds,
+ }
+ })
+ detail.value = {
+ ...detail.value,
+ videos: nextVideos,
+ }
+ currentPlaybackSeconds.value = completed ? 0 : normalizePlayPosition(lastPlayPositionSeconds)
+ lastReportedPlaybackSeconds.value = completed ? 0 : normalizePlayPosition(lastPlayPositionSeconds)
+}
+
+/**
+ * 切换播放章节,先提交当前片段再切换,避免丢进度。
+ */
+async function switchVideo(videoId: number) {
+ if (!videoId || videoId === activeVideoId.value) {
+ return
+ }
+ clearWatchHeartbeatTimer()
+ await submitCurrentWatchSegment()
+ activeVideoId.value = videoId
+ resetPlaybackState()
+ initVideoContext()
+}
+
+/**
+ * 处理章节下载。
+ */
+function handleVideoDownload(item: LessonViewItem) {
+ if (!item.downloadable) {
+ uni.showToast({
+ title: '当前章节不可下载',
+ icon: 'none',
+ })
+ return
+ }
+ if (!item.videoUrl) {
+ uni.showToast({
+ title: '视频地址不存在',
+ icon: 'none',
+ })
+ return
+ }
+ // H5 端直接打开资源地址,避免浏览器环境下 `downloadFile` 因跨域策略导致下载失败。
+ // #ifdef H5
+ window.open(item.videoUrl, '_blank')
+ uni.showToast({
+ title: '已打开下载地址',
+ icon: 'none',
+ })
+ return
+ // #endif
+ const currentUserId = normalizeNumber(resolveCurrentUserId())
+ if (!currentUserId) {
+ uni.showToast({
+ title: '请先登录',
+ icon: 'none',
+ })
+ return
+ }
+ if (hasDownloadedTask(listCurrentDownloadTasks(), item.videoId)) {
+ uni.showToast({
+ title: '当前章节正在下载中',
+ icon: 'none',
+ })
+ return
+ }
+ const downloadTaskId = buildDownloadTaskId(item.videoId)
+ const initialTask = createDownloadTaskPayload(downloadTaskId, item)
+ upsertDownloadTask(currentUserId, initialTask)
+ uni.showLoading({
+ title: '下载中...',
+ mask: true,
+ })
+ const downloadTask = uni.downloadFile({
+ url: item.videoUrl,
+ success: async (response) => {
+ if (response.statusCode !== 200 || !response.tempFilePath) {
+ clearDownloadTask(currentUserId, downloadTaskId)
+ uni.showToast({
+ title: '下载失败',
+ icon: 'none',
+ })
+ return
+ }
+ // 先保存到持久化目录,再同步下载记录,避免临时文件失效后“我的下载”里只有记录没有文件。
+ uni.saveFile({
+ tempFilePath: response.tempFilePath,
+ success: async () => {
+ clearDownloadTask(currentUserId, downloadTaskId)
+ try {
+ const reportResponse = await reportHealthCourseDownload({
+ courseId: detail.value.courseId,
+ courseVideoId: item.videoId,
+ })
+ if (reportResponse?.code !== 0) {
+ return
+ }
+ uni.showToast({
+ title: '下载完成',
+ icon: 'none',
+ })
+ } catch {
+ uni.showToast({
+ title: '下载完成,记录同步失败',
+ icon: 'none',
+ })
+ }
+ },
+ fail: () => {
+ clearDownloadTask(currentUserId, downloadTaskId)
+ uni.showToast({
+ title: '文件已下载到临时目录',
+ icon: 'none',
+ })
+ },
+ })
+ },
+ fail: () => {
+ clearDownloadTask(currentUserId, downloadTaskId)
+ uni.showToast({
+ title: '下载失败',
+ icon: 'none',
+ })
+ },
+ complete: () => {
+ uni.hideLoading()
+ },
+ })
+ downloadTask.onProgressUpdate((progressEvent) => {
+ upsertDownloadTask(currentUserId, {
+ ...initialTask,
+ progress: progressEvent.progress,
+ updatedAt: new Date().toISOString(),
+ })
+ })
+}
+
+/**
+ * 拨打系统配置的客服电话,未配置时给出提示。
+ */
+function handleConsult() {
+ const servicePhone = detail.value.servicePhone.trim()
+ if (!servicePhone) {
+ uni.showToast({
+ title: '暂未配置客服电话',
+ icon: 'none',
+ })
+ return
+ }
+ uni.makePhoneCall({
+ phoneNumber: servicePhone,
+ })
+}
+
+/**
+ * 底部下载按钮直接下载当前正在播放的章节。
+ */
+function handleCurrentVideoDownload() {
+ if (!currentVideo.value) {
+ uni.showToast({
+ title: '暂无可下载章节',
+ icon: 'none',
+ })
+ return
+ }
+ handleVideoDownload({
+ videoId: currentVideo.value.videoId,
+ title: currentVideo.value.chapterName,
+ duration: currentVideo.value.durationText,
+ progress: normalizePercent(currentVideo.value.progressPercent),
+ isDownloaded: hasDownloadedTask(listCurrentDownloadTasks(), currentVideo.value.videoId),
+ isActive: true,
+ downloadable: !!currentVideo.value.downloadable,
+ videoUrl: currentVideo.value.videoUrl,
+ durationSeconds: currentVideo.value.durationSeconds,
+ })
+}
+
+/**
+ * 构建下载任务快照。
+ */
+function createDownloadTaskPayload(clientTaskId: string, item: LessonViewItem): LocalDownloadTask {
+ return {
+ clientTaskId,
+ courseId: detail.value.courseId,
+ courseVideoId: item.videoId,
+ title: detail.value.title || '养生课程',
+ subtitle: item.title,
+ coverImage: detail.value.coverImage,
+ durationSeconds: item.durationSeconds,
+ durationText: item.duration,
+ videoUrl: item.videoUrl,
+ progress: 0,
+ status: 'downloading',
+ updatedAt: new Date().toISOString(),
+ }
+}
+
+/**
+ * 构建下载任务唯一标识。
+ */
+function buildDownloadTaskId(videoId: number) {
+ return `my-course-${detail.value.courseId}-${videoId}`
+}
+
+/**
+ * 读取当前用户的下载任务列表。
+ */
+function listCurrentDownloadTasks(): LocalDownloadTask[] {
+ const currentUserId = normalizeNumber(resolveCurrentUserId())
+ if (!currentUserId) {
+ return []
+ }
+ return listDownloadTasks(currentUserId) as LocalDownloadTask[]
+}
+
+/**
+ * 判断章节是否已在下载列表中。
+ */
+function hasDownloadedTask(tasks: LocalDownloadTask[], videoId: number) {
+ return tasks.some((item) => item.courseVideoId === videoId)
+}
+
+/**
+ * 获取当前登录用户主键。
+ */
+function resolveCurrentUserId() {
+ const userInfo = uni.getStorageSync('appUserInfo')
+ if (!userInfo || typeof userInfo !== 'object') {
+ return 0
+ }
+ return (userInfo.id || userInfo.userId || 0) as number
+}
+
+/**
+ * 跳转到课程介绍页。
+ */
+function goCourseIntro() {
+ if (!detail.value.courseId) {
+ return
+ }
+ uni.navigateTo({
+ url: `/pages/courseDetail/courseDetail?id=${detail.value.courseId}`,
+ })
+}
+
+/**
+ * 返回上一页,优先完成播放进度上报,避免直接返回导致续播点丢失。
+ */
+async function goBack() {
+ if (backSubmitting.value) {
+ return
+ }
+ backSubmitting.value = true
+ try {
+ clearWatchHeartbeatTimer()
+ if (currentWatchStartedAt.value > 0) {
+ uni.showLoading({
+ title: '保存进度中...',
+ mask: true,
+ })
+ await submitCurrentWatchSegment()
+ uni.hideLoading()
+ }
+ uni.navigateBack()
+ } finally {
+ backSubmitting.value = false
+ }
+}
+
+/**
+ * 格式化日期时间,兼容后端 LocalDateTime 解析。
+ */
+function formatDateTime(date: Date) {
+ const year = String(date.getFullYear())
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ const hour = String(date.getHours()).padStart(2, '0')
+ const minute = String(date.getMinutes()).padStart(2, '0')
+ const second = String(date.getSeconds()).padStart(2, '0')
+ return `${year}-${month}-${day} ${hour}:${minute}:${second}`
+}
+
+/**
+ * 归一化数字。
+ */
+function normalizeNumber(value: unknown) {
+ const numberValue = Number(value)
+ if (!Number.isFinite(numberValue)) {
+ return 0
+ }
+ return numberValue
+}
+
+/**
+ * 归一化文本。
+ */
+function normalizeText(value: unknown) {
+ if (typeof value !== 'string') {
+ return ''
+ }
+ return value.trim()
+}
+
+/**
+ * 归一化百分比。
+ */
+function normalizePercent(value: unknown) {
+ const percent = Math.round(Number(value || 0))
+ if (!Number.isFinite(percent)) {
+ return 0
+ }
+ if (percent < 0) {
+ return 0
+ }
+ if (percent > 100) {
+ return 100
+ }
+ return percent
+}
+
+/**
+ * 按当前视频时长归一化播放位置,避免越界。
+ */
+function normalizePlayPosition(value: unknown) {
+ const seconds = Math.max(Math.floor(Number(value || 0)), 0)
+ const maxDuration = mediaDurationSeconds.value > 0
+ ? mediaDurationSeconds.value
+ : normalizeNumber(currentVideo.value?.durationSeconds)
+ if (maxDuration <= 0) {
+ return seconds
+ }
+ if (seconds > maxDuration) {
+ return maxDuration
+ }
+ return seconds
+}
+
+/**
+ * H5 页面进入后台或关闭时补提当前片段,减少浏览器生命周期导致的丢进度。
+ */
+function handleH5VisibilityChange() {
+ if (typeof document === 'undefined' || !document.hidden) {
+ return
+ }
+ clearWatchHeartbeatTimer()
+ submitCurrentWatchSegment()
+}
+
+/**
+ * H5 页面关闭或刷新前补提当前播放片段。
+ */
+function handleH5PageHide() {
+ clearWatchHeartbeatTimer()
+ submitCurrentWatchSegment()
+}
+
+/**
+ * 绑定 H5 浏览器生命周期监听,兼容返回、切后台、刷新等场景。
+ */
+function bindH5PlaybackLifecycle() {
+ if (!isH5 || typeof window === 'undefined' || typeof document === 'undefined') {
+ return
+ }
+ unbindH5PlaybackLifecycle()
+ document.addEventListener('visibilitychange', handleH5VisibilityChange)
+ window.addEventListener('pagehide', handleH5PageHide)
+}
+
+/**
+ * 移除 H5 浏览器生命周期监听,避免重复绑定。
+ */
+function unbindH5PlaybackLifecycle() {
+ if (!isH5 || typeof window === 'undefined' || typeof document === 'undefined') {
+ return
+ }
+ document.removeEventListener('visibilitychange', handleH5VisibilityChange)
+ window.removeEventListener('pagehide', handleH5PageHide)
+}
</script>
<style lang="scss" scoped>
@@ -122,7 +992,6 @@
background-color: #FFFFFF;
}
- /* 自定义导航栏 */
.custom-navbar {
width: 100%;
background-color: #FFFFFF;
@@ -153,7 +1022,6 @@
width: 40rpx;
}
- /* 视频区域 */
.video-section {
width: 100%;
height: 420rpx;
@@ -161,59 +1029,30 @@
background-color: #000;
}
+ .video-player-wrapper,
+ .video-player,
.video-poster {
width: 100%;
height: 100%;
}
- .play-btn-wrap {
+ .video-retry-mask {
position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 100rpx;
- height: 100rpx;
- background: rgba(255, 255, 255, 0.3);
- border-radius: 50%;
+ inset: 0;
display: flex;
+ align-items: center;
justify-content: center;
- align-items: center;
+ background: rgba(0, 0, 0, 0.32);
}
- .play-icon {
- width: 40rpx;
- height: 40rpx;
+ .video-retry-text {
+ font-size: 28rpx;
+ color: #FFFFFF;
+ padding: 20rpx 32rpx;
+ background: rgba(0, 0, 0, 0.55);
+ border-radius: 999rpx;
}
- .video-controls {
- position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- height: 60rpx;
- background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.5) 100%);
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 0 24rpx;
- box-sizing: border-box;
- }
-
- .controls-left, .controls-right {
- display: flex;
- align-items: center;
- }
-
- .control-icon {
- width: 32rpx;
- height: 32rpx;
- background-color: #fff; /* 占位色块,实际应为图标 */
- margin: 0 10rpx;
- border-radius: 4rpx;
- opacity: 0.8;
- }
-
- /* 列表头部 */
.list-header {
padding: 32rpx;
display: flex;
@@ -233,10 +1072,19 @@
color: #86909C;
}
- /* 课程列表 */
.lesson-list {
flex: 1;
height: 0;
+ }
+
+ .empty-box {
+ padding: 180rpx 0;
+ text-align: center;
+ }
+
+ .empty-text {
+ font-size: 28rpx;
+ color: #999999;
}
.lesson-item {
@@ -282,7 +1130,7 @@
font-size: 28rpx;
color: #333333;
font-weight: 500;
-
+
&.active-text {
color: #D32E29;
}
@@ -308,7 +1156,6 @@
height: 30rpx;
}
- /* 进度条 */
.progress-wrap {
width: 100%;
display: flex;
@@ -319,67 +1166,63 @@
.progress-bar {
flex: 1;
height: 8rpx;
- background-color: #F0F0F0;
- border-radius: 4rpx;
+ border-radius: 999rpx;
+ background: #F2F3F5;
overflow: hidden;
- margin-right: 16rpx;
}
.progress-inner {
height: 100%;
- background-color: #D32E29;
- border-radius: 4rpx;
+ border-radius: 999rpx;
+ background: linear-gradient(90deg, #D32E29 0%, #F07A74 100%);
}
.progress-text {
- font-size: 24rpx;
- color: #86909C;
- width: 60rpx;
- text-align: right;
+ margin-left: 12rpx;
+ font-size: 22rpx;
+ color: #D32E29;
}
- /* 底部操作栏 */
.bottom-bar {
- width: 100%;
- height: 120rpx;
+ height: 112rpx;
+ border-top: 1px solid #F5F5F5;
background-color: #FFFFFF;
display: flex;
align-items: center;
padding: 0 32rpx;
box-sizing: border-box;
- border-top: 1px solid #E5E6EB;
- padding-bottom: env(safe-area-inset-bottom);
}
.action-btn {
+ width: 96rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- margin-right: 48rpx;
+ margin-right: 24rpx;
}
.action-icon {
- width: 44rpx;
- height: 44rpx;
- margin-bottom: 6rpx;
+ width: 36rpx;
+ height: 36rpx;
+ margin-bottom: 8rpx;
}
.action-text {
- font-size: 20rpx;
- color: #333333;
+ font-size: 22rpx;
+ color: #666666;
}
.main-btn {
flex: 1;
height: 80rpx;
- background-color: #D32E29;
+ border-radius: 999rpx;
+ background: linear-gradient(90deg, #D32E29 0%, #F07A74 100%);
color: #FFFFFF;
font-size: 30rpx;
- border-radius: 12rpx;
- display: flex;
- justify-content: center;
- align-items: center;
font-weight: 500;
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
-</style>
\ No newline at end of file
+</style>
diff --git a/traditionalApp/pages/myCourseDetail/service.js b/traditionalApp/pages/myCourseDetail/service.js
new file mode 100644
index 0000000..e140b6a
--- /dev/null
+++ b/traditionalApp/pages/myCourseDetail/service.js
@@ -0,0 +1,15 @@
+import request from '@/utils/request.js'
+
+/**
+ * 获取 APP 我的课程详情。
+ */
+export const getMyHealthCourseDetail = (courseId) => {
+ return request.get(`/api/app/my-health-courses/${courseId}`, {}, false, false)
+}
+
+/**
+ * 上报 APP 我的课程播放记录。
+ */
+export const uploadMyHealthCoursePlayRecord = (courseId, videoId, data) => {
+ return request.post(`/api/app/my-health-courses/${courseId}/videos/${videoId}/play-records`, data, false, false)
+}
diff --git a/traditionalApp/pages/myCourses/myCourses.vue b/traditionalApp/pages/myCourses/myCourses.vue
index b4e286f..94b04a2 100644
--- a/traditionalApp/pages/myCourses/myCourses.vue
+++ b/traditionalApp/pages/myCourses/myCourses.vue
@@ -1,6 +1,5 @@
<template>
<view class="container">
- <!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-content">
<image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
@@ -10,18 +9,19 @@
</view>
<scroll-view scroll-y class="courses-list">
- <block v-for="(item, index) in courseList" :key="item.id">
+ <view v-if="!loading && courseList.length === 0" class="empty-box">
+ <text class="empty-text">暂无课程</text>
+ </view>
+ <block v-for="(item, index) in courseList" :key="item.courseId">
<view class="list-item-wrapper">
<view class="swipe-wrap">
- <u-swipe-action :options="swipeOptions" :index="index" @click="handleDelete(index)">
- <view class="course-item-container" @tap="handleItemClick(index)">
+ <u-swipe-action :options="resolveSwipeOptions(item)" :index="index" @click="handleDelete(index)">
+ <view class="course-item-container" @tap="handleItemClick(item)">
<view class="course-item">
- <!-- 图片 -->
<view class="img-wrap">
- <image class="course-img" :src="item.image" mode="aspectFill"></image>
+ <image class="course-img" :src="resolveCoverImage(item.coverImage)" mode="aspectFill"></image>
</view>
- <!-- 课程信息 -->
<view class="course-info">
<view class="title-wrap">
<view class="tag-list">
@@ -36,9 +36,7 @@
<text class="study-current">{{ item.currentLessons }}</text>
<text class="study-total">/{{ item.totalLessons }}</text>
</view>
- <!-- 环形进度条展示区 (模拟图中的环形) -->
<view class="circle-progress-wrap">
- <!-- 这里由于没有具体环形进度组件,用一个简单的带边框圆形模拟,中间放百分比 -->
<view class="circle-border">
<text class="circle-text">{{ item.progress }}%</text>
</view>
@@ -56,43 +54,90 @@
</template>
<script setup lang="ts">
- import { ref, onMounted } from 'vue';
+import { ref } from 'vue'
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import { getMyHealthCourses, removeMyHealthCourse } from './service'
- const statusBarHeight = ref(20);
+interface MyHealthCourseItem {
+ courseId: number
+ title: string
+ coverImage: string
+ hasHealthTag: boolean
+ currentLessons: number
+ totalLessons: number
+ progress: number
+ removable: boolean
+}
- // 模拟数据
- const courseList = ref([
- {
- id: 1,
- title: '拍八虚新手入门版',
- image: '/static/image/home/doctor.png', // 示例图
- hasHealthTag: true,
- currentLessons: 25,
- totalLessons: 50,
- progress: 50
- },
- {
- id: 2,
- title: '拍八虚新手入门版',
- image: '/static/image/home/doctor.png',
- hasHealthTag: true,
- currentLessons: 25,
- totalLessons: 50,
- progress: 50
- },
- {
- id: 3,
- title: '拍八虚新手入门版',
- image: '/static/image/home/doctor.png',
- hasHealthTag: true,
- currentLessons: 25,
- totalLessons: 50,
- progress: 50
+const DEFAULT_COURSE_IMAGE = '/static/image/home/class.png'
+
+const statusBarHeight = ref(20)
+const loading = ref(false)
+const courseList = ref<MyHealthCourseItem[]>([])
+
+/**
+ * 页面加载时初始化状态栏高度。
+ */
+onLoad(() => {
+ const systemInfo = uni.getSystemInfoSync()
+ statusBarHeight.value = systemInfo.statusBarHeight || 20
+})
+
+/**
+ * 页面显示时刷新我的课程列表。
+ */
+onShow(() => {
+ loadMyHealthCourses()
+})
+
+/**
+ * 拉取当前登录用户的我的课程列表。
+ */
+async function loadMyHealthCourses() {
+ loading.value = true
+ try {
+ const response = await getMyHealthCourses()
+ if (response?.code !== 0) {
+ return
}
- ]);
+ courseList.value = normalizeCourseList(response?.data)
+ } catch {
+ // 请求层已统一处理异常提示。
+ } finally {
+ loading.value = false
+ }
+}
- // u-swipe-action 右侧滑出选项
- const swipeOptions = ref([
+/**
+ * 将接口数据归一化为页面渲染结构。
+ */
+function normalizeCourseList(value: unknown): MyHealthCourseItem[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+ return value.map((item) => {
+ const course = (item && typeof item === 'object' ? item : {}) as Record<string, unknown>
+ return {
+ courseId: normalizeNumber(course.courseId),
+ title: normalizeText(course.title) || '养生课程',
+ coverImage: normalizeText(course.coverImage),
+ hasHealthTag: !!course.hasHealthTag,
+ currentLessons: normalizeNumber(course.completedLessons),
+ totalLessons: normalizeNumber(course.totalLessons),
+ progress: normalizePercent(course.progressPercent),
+ removable: !!course.removable,
+ }
+ }).filter((item) => item.courseId > 0)
+}
+
+/**
+ * 根据课程是否允许移除返回滑动按钮配置。
+ */
+function resolveSwipeOptions(item: MyHealthCourseItem) {
+ if (!item.removable) {
+ return []
+ }
+ return [
{
text: '删除',
style: {
@@ -105,41 +150,114 @@
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
- margin: '0 30rpx'
+ margin: '0 30rpx',
+ },
+ },
+ ]
+}
+
+/**
+ * 返回上一页。
+ */
+function goBack() {
+ uni.navigateBack()
+}
+
+/**
+ * 跳转到我的课程详情页。
+ */
+function handleItemClick(item: MyHealthCourseItem) {
+ if (!item.courseId) {
+ return
+ }
+ uni.navigateTo({
+ url: `/pages/myCourseDetail/myCourseDetail?courseId=${item.courseId}`,
+ })
+}
+
+/**
+ * 删除会员加入的课程,已购课程不允许删除。
+ */
+function handleDelete(index: number) {
+ const item = courseList.value[index]
+ if (!item) {
+ return
+ }
+ if (!item.removable) {
+ uni.showToast({
+ title: '已购买课程不可删除',
+ icon: 'none',
+ })
+ return
+ }
+ uni.showModal({
+ title: '提示',
+ content: '确认删除该课程记录?',
+ success: async (res) => {
+ if (!res.confirm) {
+ return
}
- }
- ]);
-
- onMounted(() => {
- const systemInfo = uni.getSystemInfoSync();
- statusBarHeight.value = systemInfo.statusBarHeight || 20;
- });
-
- const goBack = () => {
- uni.navigateBack();
- };
-
- const handleItemClick = (index: number) => {
- uni.navigateTo({
- url: '/pages/myCourseDetail/myCourseDetail'
- });
- };
-
- const handleDelete = (index: number) => {
- uni.showModal({
- title: '提示',
- content: '确认删除该课程记录?',
- success: (res) => {
- if (res.confirm) {
- courseList.value.splice(index, 1);
- uni.showToast({
- title: '已删除',
- icon: 'none'
- });
+ try {
+ const response = await removeMyHealthCourse(item.courseId)
+ if (response?.code !== 0) {
+ return
}
+ courseList.value.splice(index, 1)
+ uni.showToast({
+ title: '已删除',
+ icon: 'none',
+ })
+ } catch {
+ // 请求层已统一处理异常提示。
}
- });
- };
+ },
+ })
+}
+
+/**
+ * 解析课程封面,避免空图导致布局异常。
+ */
+function resolveCoverImage(value?: string) {
+ return normalizeText(value) || DEFAULT_COURSE_IMAGE
+}
+
+/**
+ * 将数字归一化。
+ */
+function normalizeNumber(value: unknown) {
+ const numberValue = Number(value)
+ if (!Number.isFinite(numberValue)) {
+ return 0
+ }
+ return numberValue
+}
+
+/**
+ * 将文本归一化为空字符串。
+ */
+function normalizeText(value: unknown) {
+ if (typeof value !== 'string') {
+ return ''
+ }
+ return value.trim()
+}
+
+/**
+ * 归一化百分比,避免出现无效值。
+ */
+function normalizePercent(value: unknown) {
+ const percent = Math.round(Number(value || 0))
+ if (!Number.isFinite(percent)) {
+ return 0
+ }
+ if (percent < 0) {
+ return 0
+ }
+ if (percent > 100) {
+ return 100
+ }
+ return percent
+}
</script>
<style lang="scss" scoped>
@@ -155,7 +273,6 @@
flex-direction: column;
}
- /* 自定义导航栏 */
.custom-navbar {
width: 100%;
background-color: #FFFFFF;
@@ -189,13 +306,22 @@
width: 40rpx;
}
- /* 课程列表 */
.courses-list {
flex: 1;
height: 0;
padding: 24rpx 32rpx;
box-sizing: border-box;
width: 100%;
+ }
+
+ .empty-box {
+ padding: 200rpx 0;
+ text-align: center;
+ }
+
+ .empty-text {
+ font-size: 28rpx;
+ color: #999999;
}
.list-item-wrapper {
@@ -211,7 +337,6 @@
transition: all 0.3s;
}
- // 修改 u-swipe-action 的样式适应圆形删除按钮设计
:deep(.u-swipe-action) {
background-color: transparent !important;
}
@@ -231,7 +356,7 @@
}
:deep(.u-btn-text) {
- display: none; // 隐藏文字
+ display: none;
}
:deep(.u-swipe-del::after) {
@@ -258,122 +383,104 @@
width: 100%;
height: 200rpx;
background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
+ border-radius: 16rpx;
padding: 24rpx;
box-sizing: border-box;
display: flex;
align-items: center;
- border-radius: 24rpx;
}
.img-wrap {
- position: relative;
width: 152rpx;
- height: 152rpx;
- border-radius: 16rpx;
- margin-right: 24rpx;
+ height: 112rpx;
+ border-radius: 12rpx;
+ overflow: hidden;
flex-shrink: 0;
- background-color: #BDBDBD;
- overflow: visible;
+ margin-right: 24rpx;
}
.course-img {
width: 100%;
height: 100%;
- border-radius: 16rpx;
}
.course-info {
flex: 1;
- height: 152rpx;
+ min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
- min-width: 0;
+ height: 112rpx;
}
.title-wrap {
display: flex;
- align-items: center;
- flex-wrap: wrap;
+ flex-direction: column;
}
.tag-list {
display: flex;
align-items: center;
+ margin-bottom: 12rpx;
}
.tag {
- font-size: 20rpx;
- padding: 2rpx 10rpx;
+ padding: 4rpx 12rpx;
border-radius: 8rpx;
- margin-right: 12rpx;
- color: #FFFFFF;
+ font-size: 20rpx;
+ line-height: 28rpx;
}
.red-tag {
- background-color: #D32E29;
+ background: rgba(211, 46, 41, 0.1);
+ color: #D32E29;
}
.course-title {
- font-size: 32rpx;
+ font-size: 28rpx;
+ line-height: 40rpx;
+ font-weight: 500;
color: #1A1A1A;
- font-weight: bold;
- flex: 1;
- white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ white-space: nowrap;
}
.course-bottom {
display: flex;
+ align-items: center;
justify-content: space-between;
- align-items: flex-end;
}
.study-progress {
font-size: 24rpx;
color: #86909C;
- margin-bottom: 4rpx;
+ }
- .study-label {
- color: #86909C;
- }
-
- .study-current {
- color: #D32E29;
- font-weight: bold;
- }
-
- .study-total {
- color: #86909C;
- }
+ .study-current {
+ color: #D32E29;
}
.circle-progress-wrap {
- width: 80rpx;
- height: 80rpx;
display: flex;
- justify-content: center;
align-items: center;
+ justify-content: center;
}
.circle-border {
- width: 80rpx;
- height: 80rpx;
+ width: 70rpx;
+ height: 70rpx;
border-radius: 50%;
- border: 12rpx solid #E6D4CB; /* 模拟底色圈 */
- border-top-color: #D32E29; /* 模拟进度颜色 */
- border-right-color: #D32E29;
+ border: 4rpx solid #D32E29;
display: flex;
- justify-content: center;
align-items: center;
- transform: rotate(-45deg); /* 调整红色进度条的起始角度以更像图片 */
+ justify-content: center;
+ background-color: #FFFFFF;
}
.circle-text {
font-size: 20rpx;
- color: #1A1A1A;
- font-weight: bold;
- transform: rotate(45deg); /* 把文字转正 */
+ color: #D32E29;
+ font-weight: 500;
}
-</style>
\ No newline at end of file
+</style>
diff --git a/traditionalApp/pages/myCourses/service.js b/traditionalApp/pages/myCourses/service.js
new file mode 100644
index 0000000..573dd32
--- /dev/null
+++ b/traditionalApp/pages/myCourses/service.js
@@ -0,0 +1,15 @@
+import request from '@/utils/request.js'
+
+/**
+ * 获取 APP 我的课程列表。
+ */
+export const getMyHealthCourses = () => {
+ return request.get('/api/app/my-health-courses', {}, false, false)
+}
+
+/**
+ * 删除 APP 我的课程中的会员加课课程。
+ */
+export const removeMyHealthCourse = (courseId) => {
+ return request.delete(`/api/app/my-health-courses/${courseId}`, {}, false, false)
+}
diff --git a/traditionalApp/pages/point/point.vue b/traditionalApp/pages/point/point.vue
index aec2bd7..276928f 100644
--- a/traditionalApp/pages/point/point.vue
+++ b/traditionalApp/pages/point/point.vue
@@ -2,7 +2,7 @@
<view class="container">
<!-- 顶部背景图 -->
<view class="top-bg">
- <image src="https://mxcd.zhinenganguan.com/file/2026-03-30/5d3dd485-5af1-4913-ba22-c27f668c4660.png" mode="widthFix" class="bg-img"></image>
+ <image :src="topBackgroundImage" mode="widthFix" class="bg-img"></image>
</view>
<!-- 自定义导航栏 -->
@@ -25,33 +25,33 @@
<view class="point-main">
<view class="point-number-box">
- <text class="point-current">15</text>
- <text class="point-total">/150</text>
+ <text class="point-current">{{ overview.pointsBalance }}</text>
+ <text class="point-total">/{{ overview.requiredPoints }}</text>
</view>
<view class="point-detail-btn" @tap="goPointDetail">积分明细</view>
</view>
<view class="progress-container">
<view class="progress-bar">
- <view class="progress-inner" :style="{ width: '10%' }"></view>
+ <view class="progress-inner" :style="{ width: `${overview.progressPercent}%` }"></view>
</view>
- <text class="progress-text">40%</text>
+ <text class="progress-text">{{ overview.progressPercent }}%</text>
</view>
<view class="point-date">
- <text>本期时间 2026.01.01-2026.01.01</text>
+ <text>本期时间 {{ displayActivityPeriod }}</text>
</view>
</view>
<!-- 兑换提示 -->
<view class="exchange-card">
- <image class="exchange-img" src="/static/image/point/flower.png" mode="aspectFill"></image>
+ <image class="exchange-img" :src="displayGiftCover" mode="aspectFill"></image>
<view class="exchange-info">
<view class="exchange-title">
- 满<text class="highlight">150</text>积分即可兑换
+ 满<text class="highlight">{{ overview.requiredPoints }}</text>积分即可兑换
</view>
<view class="exchange-desc">礼品将在活动接收发放至您绑定的诊所</view>
- <view class="exchange-desc">还差<text class="highlight">132</text>积分即可领取</view>
+ <view class="exchange-desc">还差<text class="highlight">{{ overview.pointsShortfall }}</text>积分即可领取</view>
</view>
</view>
@@ -78,10 +78,10 @@
<image class="clinic-icon" src="/static/image/point/home.png" mode="aspectFit"></image>
</view>
<view class="address-info">
- <view class="clinic-name">XXXXXXXXX诊所名称</view>
+ <view class="clinic-name">{{ displayClinicName }}</view>
<view class="clinic-address">
<image class="location-icon" src="/static/image/point/location.png" mode="aspectFit"></image>
- <text>四川省成都市锦晖西二街399号</text>
+ <text>{{ displayClinicAddress }}</text>
</view>
</view>
</view>
@@ -91,19 +91,175 @@
</template>
<script setup lang="ts">
- import { ref, onMounted } from 'vue';
+ import { computed, ref } from 'vue';
+ import { onLoad, onShow } from '@dcloudio/uni-app';
+ import { getPointsOverview } from './service';
+
+ interface PointsOverview {
+ pointsBalance: number
+ requiredPoints: number
+ progressPercent: number
+ pointsShortfall: number
+ activityStartAt: string
+ activityEndAt: string
+ giftCover: string
+ giftName: string
+ clinicName: string
+ clinicAddress: string
+ }
+
+ const DEFAULT_TOP_BACKGROUND = 'https://mxcd.zhinenganguan.com/file/2026-03-30/5d3dd485-5af1-4913-ba22-c27f668c4660.png';
+ const DEFAULT_GIFT_IMAGE = '/static/image/point/flower.png';
const statusBarHeight = ref(20);
+ const overview = ref<PointsOverview>(createDefaultOverview());
- onMounted(() => {
+ const topBackgroundImage = computed(() => DEFAULT_TOP_BACKGROUND);
+ const displayGiftCover = computed(() => overview.value.giftCover || DEFAULT_GIFT_IMAGE);
+ const displayActivityPeriod = computed(() => {
+ const startAt = formatDateLabel(overview.value.activityStartAt);
+ const endAt = formatDateLabel(overview.value.activityEndAt);
+ if (!startAt && !endAt) {
+ return '--'
+ }
+ if (!startAt) {
+ return `-- - ${endAt}`
+ }
+ if (!endAt) {
+ return `${startAt} - --`
+ }
+ return `${startAt}-${endAt}`
+ });
+ const displayClinicName = computed(() => overview.value.clinicName || '暂未绑定诊所');
+ const displayClinicAddress = computed(() => overview.value.clinicAddress || '暂未配置诊所地址');
+
+ /**
+ * 创建我的积分页默认数据,避免首屏空对象访问。
+ */
+ function createDefaultOverview(): PointsOverview {
+ return {
+ pointsBalance: 0,
+ requiredPoints: 0,
+ progressPercent: 0,
+ pointsShortfall: 0,
+ activityStartAt: '',
+ activityEndAt: '',
+ giftCover: '',
+ giftName: '',
+ clinicName: '',
+ clinicAddress: '',
+ }
+ }
+
+ /**
+ * 页面加载时初始化状态栏高度。
+ */
+ onLoad(() => {
const systemInfo = uni.getSystemInfoSync();
statusBarHeight.value = systemInfo.statusBarHeight || 20;
});
+ /**
+ * 页面显示时刷新积分概览,保证积分和活动配置实时同步。
+ */
+ onShow(() => {
+ loadPointsOverview();
+ });
+
+ /**
+ * 拉取当前登录用户的积分页面聚合数据。
+ */
+ async function loadPointsOverview() {
+ try {
+ const response = await getPointsOverview();
+ if (response?.code !== 0 || !response.data) {
+ return;
+ }
+ overview.value = normalizeOverview(response.data);
+ } catch {
+ // 请求层已统一处理异常提示。
+ }
+ }
+
+ /**
+ * 将接口数据归一化为页面可直接渲染的结构。
+ */
+ function normalizeOverview(value: unknown): PointsOverview {
+ const source = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>;
+ return {
+ pointsBalance: normalizeNumber(source.pointsBalance),
+ requiredPoints: normalizeNumber(source.requiredPoints),
+ progressPercent: normalizePercent(source.progressPercent),
+ pointsShortfall: normalizeNumber(source.pointsShortfall),
+ activityStartAt: normalizeText(source.activityStartAt),
+ activityEndAt: normalizeText(source.activityEndAt),
+ giftCover: normalizeText(source.giftCover),
+ giftName: normalizeText(source.giftName),
+ clinicName: normalizeText(source.clinicName),
+ clinicAddress: normalizeText(source.clinicAddress),
+ };
+ }
+
+ /**
+ * 将时间字符串格式化为页面使用的 yyyy.MM.dd 形式。
+ */
+ function formatDateLabel(value: string) {
+ const text = normalizeText(value);
+ if (!text) {
+ return '';
+ }
+ const datePart = text.split(' ')[0] || '';
+ return datePart.replace(/-/g, '.');
+ }
+
+ /**
+ * 归一化数字字段。
+ */
+ function normalizeNumber(value: unknown) {
+ const numberValue = Number(value);
+ if (!Number.isFinite(numberValue)) {
+ return 0;
+ }
+ return numberValue;
+ }
+
+ /**
+ * 归一化百分比,避免出现越界显示。
+ */
+ function normalizePercent(value: unknown) {
+ const percent = Math.round(Number(value || 0));
+ if (!Number.isFinite(percent)) {
+ return 0;
+ }
+ if (percent < 0) {
+ return 0;
+ }
+ if (percent > 100) {
+ return 100;
+ }
+ return percent;
+ }
+
+ /**
+ * 归一化文本,避免模板中出现 null 或 undefined。
+ */
+ function normalizeText(value: unknown) {
+ if (typeof value !== 'string') {
+ return '';
+ }
+ return value.trim();
+ }
+
+ /**
+ * 返回上一页。
+ */
const goBack = () => {
uni.navigateBack();
};
+ /**
+ * 跳转到积分明细页。
+ */
const goPointDetail = () => {
uni.navigateTo({
url: '/pages/pointDetail/pointDetail'
@@ -429,4 +585,4 @@
margin-right: 8rpx;
}
}
-</style>
\ No newline at end of file
+</style>
diff --git a/traditionalApp/pages/point/service.js b/traditionalApp/pages/point/service.js
new file mode 100644
index 0000000..d2ce573
--- /dev/null
+++ b/traditionalApp/pages/point/service.js
@@ -0,0 +1,8 @@
+import request from '@/utils/request.js'
+
+/**
+ * 获取 APP 我的积分页面聚合数据。
+ */
+export const getPointsOverview = () => {
+ return request.get('/api/app/points/overview', {}, false, false)
+}
diff --git a/traditionalApp/pages/pointDetail/pointDetail.vue b/traditionalApp/pages/pointDetail/pointDetail.vue
index fc3e931..41c10da 100644
--- a/traditionalApp/pages/pointDetail/pointDetail.vue
+++ b/traditionalApp/pages/pointDetail/pointDetail.vue
@@ -18,59 +18,241 @@
</view>
<!-- 列表区 -->
- <scroll-view scroll-y class="list-container">
- <block v-for="(item, index) in listData" :key="index">
+ <scroll-view scroll-y class="list-container" @scrolltolower="handleScrollToLower">
+ <view v-if="!loading && listData.length === 0" class="empty-box">
+ <text class="empty-text">暂无积分明细</text>
+ </view>
+ <block v-for="item in listData" :key="item.id">
<view class="list-item">
<view class="item-left">
<text class="item-title">{{ item.title }}</text>
<text class="item-time">{{ item.time }}</text>
</view>
<view class="item-right">
- <text :class="['item-score', item.type === 'income' ? 'income' : 'expense']">
- {{ item.type === 'income' ? '+' : '' }}{{ item.score }}
+ <text :class="['item-score', resolveScoreClass(item)]">
+ {{ formatPoints(item) }}
</text>
</view>
</view>
</block>
+ <view v-if="loading" class="footer-box">
+ <text class="footer-text">加载中...</text>
+ </view>
+ <view v-else-if="loadingMore" class="footer-box">
+ <text class="footer-text">加载更多中...</text>
+ </view>
+ <view v-else-if="finished && listData.length > 0" class="footer-box">
+ <text class="footer-text">没有更多了</text>
+ </view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
- import { ref, onMounted } from 'vue';
+import { ref } from 'vue'
+import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
+import { getPointsLedgerPage } from './service'
- const statusBarHeight = ref(20);
- const currentTab = ref(0);
+interface PointsLedgerItem {
+ id: number | null
+ title: string
+ time: string
+ incomeExpenseType: string
+ pointsDelta: number
+}
- const allData = [
- { title: '观看课程【课程名称课程名称】', time: '2025.12.20 00:20', score: 50, type: 'income' },
- { title: '兑换礼品【礼品名称礼品名称礼品名称】', time: '2025.12.20 00:20', score: -50, type: 'expense' },
- { title: '观看课程【课程名称课程名称】', time: '2025.12.20 00:20', score: 50, type: 'income' },
- { title: '兑换礼品【礼品名称礼品名称礼品名称】', time: '2025.12.20 00:20', score: -50, type: 'expense' }
- ];
+const statusBarHeight = ref(20)
+const currentTab = ref(0)
+const listData = ref<PointsLedgerItem[]>([])
+const loading = ref(false)
+const loadingMore = ref(false)
+const finished = ref(false)
+const page = ref(1)
+const pageSize = ref(20)
- const listData = ref(allData);
+/**
+ * 页面加载时初始化状态栏高度。
+ */
+onLoad(() => {
+ const systemInfo = uni.getSystemInfoSync()
+ statusBarHeight.value = systemInfo.statusBarHeight || 20
+})
- onMounted(() => {
- const systemInfo = uni.getSystemInfoSync();
- statusBarHeight.value = systemInfo.statusBarHeight || 20;
- });
+/**
+ * 页面显示时重置分页并刷新积分明细。
+ */
+onShow(() => {
+ loadLedgerPage(true)
+})
- const goBack = () => {
- uni.navigateBack();
- };
+/**
+ * 页面触底时继续请求下一页。
+ */
+onReachBottom(() => {
+ loadLedgerPage(false)
+})
- const switchTab = (index: number) => {
- currentTab.value = index;
- if (index === 0) {
- listData.value = allData;
- } else if (index === 1) {
- listData.value = allData.filter(item => item.type === 'income');
- } else {
- listData.value = allData.filter(item => item.type === 'expense');
+/**
+ * 拉取积分明细分页数据,并在切换筛选时重置列表。
+ */
+async function loadLedgerPage(reset = false) {
+ if (loading.value || loadingMore.value) {
+ return
+ }
+ if (!reset && finished.value) {
+ return
+ }
+ if (reset) {
+ page.value = 1
+ finished.value = false
+ loading.value = true
+ } else {
+ loadingMore.value = true
+ }
+ try {
+ const response = await getPointsLedgerPage({
+ page: page.value,
+ pageSize: pageSize.value,
+ incomeExpenseType: resolveIncomeExpenseType(currentTab.value),
+ })
+ if (response?.code !== 0) {
+ return
}
- };
+ const records = normalizeList(response?.data?.list)
+ const total = Number(response?.data?.total || 0)
+ // 切换 Tab 后需要覆盖旧数据;滚动分页时则拼接新结果。
+ listData.value = reset ? records : listData.value.concat(records)
+ const currentSize = listData.value.length
+ finished.value = records.length < pageSize.value || (total > 0 && currentSize >= total)
+ if (!finished.value) {
+ page.value += 1
+ }
+ } catch {
+ // 请求层会统一处理登录失效与异常提示,这里不重复弹窗。
+ } finally {
+ loading.value = false
+ loadingMore.value = false
+ }
+}
+
+/**
+ * 返回上一页。
+ */
+function goBack() {
+ uni.navigateBack()
+}
+
+/**
+ * 切换积分明细类型筛选,并从第一页重新查询。
+ */
+function switchTab(index: number) {
+ if (currentTab.value === index) {
+ return
+ }
+ currentTab.value = index
+ loadLedgerPage(true)
+}
+
+/**
+ * 列表滚动到底部时复用分页加载逻辑。
+ */
+function handleScrollToLower() {
+ loadLedgerPage(false)
+}
+
+/**
+ * 根据当前 Tab 解析接口筛选字段。
+ */
+function resolveIncomeExpenseType(index: number) {
+ if (index === 1) {
+ return 'INCOME'
+ }
+ if (index === 2) {
+ return 'EXPENSE'
+ }
+ return ''
+}
+
+/**
+ * 将接口返回的明细列表归一化为页面展示结构。
+ */
+function normalizeList(value: unknown): PointsLedgerItem[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+ return value.map((item) => {
+ const record = (item && typeof item === 'object' ? item : {}) as Record<string, unknown>
+ return {
+ id: normalizeOptionalNumber(record.id),
+ title: normalizeOptionalText(record.title) || '积分变动【-】',
+ time: formatDateTime(record.operatedAt),
+ incomeExpenseType: normalizeOptionalText(record.incomeExpenseType),
+ pointsDelta: normalizeNumber(record.pointsDelta),
+ }
+ })
+}
+
+/**
+ * 将接口时间格式化为页面所需的展示文案。
+ */
+function formatDateTime(value: unknown) {
+ const normalized = normalizeOptionalText(value)
+ if (!normalized) {
+ return '-'
+ }
+ return normalized.slice(0, 16).replace(/-/g, '.').replace('T', ' ')
+}
+
+/**
+ * 将积分数值格式化为带符号的展示文本。
+ */
+function formatPoints(item: PointsLedgerItem) {
+ const points = Math.abs(normalizeNumber(item.pointsDelta))
+ if (resolveScoreClass(item) === 'expense') {
+ return `-${points}`
+ }
+ return `+${points}`
+}
+
+/**
+ * 解析积分变动的颜色类型,收入显示绿色,支出显示红色。
+ */
+function resolveScoreClass(item: PointsLedgerItem) {
+ return item.incomeExpenseType === 'EXPENSE' ? 'expense' : 'income'
+}
+
+/**
+ * 将可选文本归一化为空字符串。
+ */
+function normalizeOptionalText(value: unknown) {
+ if (typeof value !== 'string') {
+ return ''
+ }
+ return value.trim()
+}
+
+/**
+ * 将可选数字归一化为数字或空值。
+ */
+function normalizeOptionalNumber(value: unknown) {
+ const parsedValue = Number(value)
+ if (!Number.isFinite(parsedValue)) {
+ return null
+ }
+ return parsedValue
+}
+
+/**
+ * 将任意积分值归一化为数值,避免展示 NaN。
+ */
+function normalizeNumber(value: unknown) {
+ const parsedValue = Number(value)
+ if (!Number.isFinite(parsedValue)) {
+ return 0
+ }
+ return parsedValue
+}
</script>
<style lang="scss" scoped>
@@ -152,6 +334,18 @@
height: 0;
}
+ .empty-box {
+ padding: 120rpx 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .empty-text {
+ font-size: 28rpx;
+ color: #999999;
+ }
+
.list-item {
width: 100%;
background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
@@ -201,4 +395,16 @@
color: #D32E29;
}
}
-</style>
\ No newline at end of file
+
+ .footer-box {
+ padding: 16rpx 0 32rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .footer-text {
+ font-size: 24rpx;
+ color: #999999;
+ }
+</style>
diff --git a/traditionalApp/pages/pointDetail/service.js b/traditionalApp/pages/pointDetail/service.js
new file mode 100644
index 0000000..7635023
--- /dev/null
+++ b/traditionalApp/pages/pointDetail/service.js
@@ -0,0 +1,8 @@
+import request from '@/utils/request.js'
+
+/**
+ * 获取 APP 当前登录用户的积分明细分页列表。
+ */
+export const getPointsLedgerPage = (data) => {
+ return request.get('/api/app/points/ledger', data, false, false)
+}
diff --git a/traditionalApp/pages/vipRecord/service.js b/traditionalApp/pages/vipRecord/service.js
new file mode 100644
index 0000000..36facce
--- /dev/null
+++ b/traditionalApp/pages/vipRecord/service.js
@@ -0,0 +1,8 @@
+import request from '@/utils/request.js'
+
+/**
+ * 获取当前登录用户的会员购买记录列表。
+ */
+export const getVipPurchaseOrders = (data) => {
+ return request.get('/api/app/member/purchase-orders', data, false, false)
+}
diff --git a/traditionalApp/pages/vipRecord/vipRecord.vue b/traditionalApp/pages/vipRecord/vipRecord.vue
index afb2469..c1bd1be 100644
--- a/traditionalApp/pages/vipRecord/vipRecord.vue
+++ b/traditionalApp/pages/vipRecord/vipRecord.vue
@@ -1,6 +1,5 @@
<template>
<view class="container">
- <!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-content">
<image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
@@ -9,75 +8,24 @@
</view>
</view>
- <!-- 记录列表 -->
<scroll-view scroll-y class="record-list">
- <!-- 记录项 1 -->
- <view class="record-item">
- <view class="item-left">
- <view class="title-box">
- <text class="item-title">月卡</text>
- </view>
- <text class="item-time">2025.12.20 00:20</text>
- </view>
- <view class="item-right">
- <view class="price-box">
- <text class="price-symbol">¥</text>
- <text class="price-num">8888.25</text>
- </view>
- <text class="pay-method">微信支付</text>
- </view>
+ <view v-if="records.length === 0" class="empty-box">
+ <text class="empty-text">暂无购买记录</text>
</view>
-
- <!-- 记录项 2 (带退款标签) -->
- <view class="record-item">
+ <view v-for="item in records" :key="item.id || item.orderNo" class="record-item">
<view class="item-left">
<view class="title-box">
- <text class="item-title">月卡</text>
- <view class="refund-tag">退款 ¥888.89</view>
+ <text class="item-title">{{ item.memberTypeText }}</text>
+ <view v-if="item.showRefundTag" class="refund-tag">退款 ¥{{ formatPrice(item.refundAmount) }}</view>
</view>
- <text class="item-time">2025.12.20 00:20</text>
+ <text class="item-time">{{ formatDateTime(item.paidAt) }}</text>
</view>
<view class="item-right">
<view class="price-box">
<text class="price-symbol">¥</text>
- <text class="price-num">8888.25</text>
+ <text class="price-num">{{ formatPrice(item.orderAmount) }}</text>
</view>
- <text class="pay-method">微信支付</text>
- </view>
- </view>
-
- <!-- 记录项 3 -->
- <view class="record-item">
- <view class="item-left">
- <view class="title-box">
- <text class="item-title">月卡</text>
- </view>
- <text class="item-time">2025.12.20 00:20</text>
- </view>
- <view class="item-right">
- <view class="price-box">
- <text class="price-symbol">¥</text>
- <text class="price-num">8888.25</text>
- </view>
- <text class="pay-method">微信支付</text>
- </view>
- </view>
-
- <!-- 记录项 4 (带退款标签) -->
- <view class="record-item">
- <view class="item-left">
- <view class="title-box">
- <text class="item-title">月卡</text>
- <view class="refund-tag">退款 ¥888.89</view>
- </view>
- <text class="item-time">2025.12.20 00:20</text>
- </view>
- <view class="item-right">
- <view class="price-box">
- <text class="price-symbol">¥</text>
- <text class="price-num">8888.25</text>
- </view>
- <text class="pay-method">微信支付</text>
+ <text class="pay-method">{{ item.paymentMethodText }}</text>
</view>
</view>
</scroll-view>
@@ -85,144 +33,265 @@
</template>
<script setup lang="ts">
- import { ref, onMounted } from 'vue';
+import { ref } from 'vue'
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import { getVipPurchaseOrders } from './service'
- const statusBarHeight = ref(20);
+interface VipPurchaseOrderItem {
+ id?: number | null
+ orderNo?: string
+ memberType?: string
+ memberTypeText?: string
+ orderAmount?: number | string | null
+ paymentMethod?: string
+ paymentMethodText?: string
+ status?: string
+ refundAmount?: number | string | null
+ showRefundTag?: boolean
+ paidAt?: string
+}
- onMounted(() => {
- const systemInfo = uni.getSystemInfoSync();
- statusBarHeight.value = systemInfo.statusBarHeight || 20;
- });
+const statusBarHeight = ref(20)
+const records = ref<VipPurchaseOrderItem[]>([])
- const goBack = () => {
- uni.navigateBack();
- };
+/**
+ * 页面加载时初始化状态栏高度。
+ */
+onLoad(() => {
+ const systemInfo = uni.getSystemInfoSync()
+ statusBarHeight.value = systemInfo.statusBarHeight || 20
+})
+
+/**
+ * 页面回显时刷新会员购买记录列表。
+ */
+onShow(() => {
+ loadVipPurchaseOrders()
+})
+
+/**
+ * 拉取当前登录用户的会员购买记录第一页数据。
+ */
+async function loadVipPurchaseOrders() {
+ try {
+ const response = await getVipPurchaseOrders({
+ page: 1,
+ pageSize: 20,
+ })
+ if (response?.code !== 0) {
+ return
+ }
+ records.value = normalizeRecords(response?.data?.list)
+ } catch {
+ // 请求层会统一处理未登录和异常提示,这里不重复 toast。
+ }
+}
+
+/**
+ * 将接口返回的记录列表归一化为页面渲染结构。
+ */
+function normalizeRecords(value: unknown): VipPurchaseOrderItem[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+ return value.map((item) => {
+ const record = (item && typeof item === 'object' ? item : {}) as Partial<VipPurchaseOrderItem>
+ return {
+ id: Number.isFinite(Number(record.id)) ? Number(record.id) : null,
+ orderNo: normalizeOptionalText(record.orderNo),
+ memberType: normalizeOptionalText(record.memberType),
+ memberTypeText: normalizeOptionalText(record.memberTypeText) || '会员',
+ orderAmount: record.orderAmount ?? null,
+ paymentMethod: normalizeOptionalText(record.paymentMethod),
+ paymentMethodText: normalizeOptionalText(record.paymentMethodText) || '-',
+ status: normalizeOptionalText(record.status),
+ refundAmount: record.refundAmount ?? null,
+ showRefundTag: !!record.showRefundTag,
+ paidAt: normalizeOptionalText(record.paidAt),
+ }
+ })
+}
+
+/**
+ * 返回上一页。
+ */
+function goBack() {
+ uni.navigateBack()
+}
+
+/**
+ * 将金额统一格式化为两位小数。
+ */
+function formatPrice(value: number | string | null | undefined) {
+ const amount = Number(value || 0)
+ if (!Number.isFinite(amount)) {
+ return '0.00'
+ }
+ return amount.toFixed(2)
+}
+
+/**
+ * 将接口时间格式化为页面所需的展示文案。
+ */
+function formatDateTime(value?: string) {
+ const normalized = normalizeOptionalText(value)
+ if (!normalized) {
+ return '-'
+ }
+ return normalized.slice(0, 16).replace(/-/g, '.').replace('T', ' ')
+}
+
+/**
+ * 将可选文本归一化为空字符串。
+ */
+function normalizeOptionalText(value: unknown) {
+ if (typeof value !== 'string') {
+ return ''
+ }
+ return value.trim()
+}
</script>
<style lang="scss" scoped>
- page {
- background-color: #FFFFFF;
- }
+page {
+ background-color: #FFFFFF;
+}
- .container {
- width: 750rpx;
- min-height: 100vh;
- background-color: #FFFFFF;
- display: flex;
- flex-direction: column;
- }
+.container {
+ width: 750rpx;
+ min-height: 100vh;
+ background-color: #FFFFFF;
+ display: flex;
+ flex-direction: column;
+}
- /* 自定义导航栏 */
- .custom-navbar {
- width: 100%;
- background-color: #FFFFFF;
- position: relative;
- z-index: 100;
- }
+.custom-navbar {
+ width: 100%;
+ background-color: #FFFFFF;
+ position: relative;
+ z-index: 100;
+}
- .nav-content {
- height: 88rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 32rpx;
- }
+.nav-content {
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 32rpx;
+}
- .back-icon {
- width: 40rpx;
- height: 40rpx;
- }
+.back-icon {
+ width: 40rpx;
+ height: 40rpx;
+}
- .nav-title {
- font-size: 36rpx;
- color: #333333;
- font-weight: bold;
- }
+.nav-title {
+ font-size: 36rpx;
+ color: #333333;
+ font-weight: bold;
+}
- .nav-right-placeholder {
- width: 40rpx; /* 与 back-icon 宽度一致,保持标题居中 */
- }
+.nav-right-placeholder {
+ width: 40rpx;
+}
- /* 记录列表 */
- .record-list {
- flex: 1;
- padding: 24rpx 32rpx;
- box-sizing: border-box;
- }
+.record-list {
+ flex: 1;
+ padding: 24rpx 32rpx;
+ box-sizing: border-box;
+}
- .record-item {
- width: 686rpx;
- height: 140rpx;
- background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
- border-radius: 16rpx;
- margin-bottom: 20rpx;
- padding: 0 32rpx;
- box-sizing: border-box;
- display: flex;
- justify-content: space-between;
- align-items: center;
- /* Add subtle border/shadow if needed to match design perfectly */
- border: 1rpx solid rgba(249, 246, 239, 0.5);
- }
+.record-item {
+ width: 686rpx;
+ height: 140rpx;
+ background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
+ border-radius: 16rpx;
+ margin-bottom: 20rpx;
+ padding: 0 32rpx;
+ box-sizing: border-box;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border: 1rpx solid rgba(249, 246, 239, 0.5);
+}
- .item-left {
- display: flex;
- flex-direction: column;
- justify-content: center;
- }
+.item-left {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ flex: 1;
+ min-width: 0;
+}
- .title-box {
- display: flex;
- align-items: center;
- margin-bottom: 12rpx;
- }
+.title-box {
+ display: flex;
+ align-items: center;
+ margin-bottom: 12rpx;
+}
- .item-title {
- font-size: 32rpx;
- color: #333333;
- font-weight: bold;
- }
+.item-title {
+ font-size: 32rpx;
+ color: #333333;
+ font-weight: bold;
+ flex-shrink: 0;
+}
- .refund-tag {
- margin-left: 16rpx;
- padding: 2rpx 12rpx;
- background-color: rgba(211, 46, 41, 0.1);
- color: #D32E29;
- font-size: 20rpx;
- border-radius: 4rpx;
- }
+.refund-tag {
+ margin-left: 16rpx;
+ padding: 2rpx 12rpx;
+ background-color: rgba(211, 46, 41, 0.1);
+ color: #D32E29;
+ font-size: 20rpx;
+ border-radius: 4rpx;
+ max-width: 280rpx;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
- .item-time {
- font-size: 26rpx;
- color: #86909C;
- }
+.item-time {
+ font-size: 26rpx;
+ color: #86909C;
+}
- .item-right {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- justify-content: center;
- }
+.item-right {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ justify-content: center;
+ margin-left: 24rpx;
+}
- .price-box {
- display: flex;
- align-items: baseline;
- color: #D32E29;
- margin-bottom: 12rpx;
- }
+.price-box {
+ display: flex;
+ align-items: baseline;
+ color: #D32E29;
+ margin-bottom: 12rpx;
+}
- .price-symbol {
- font-size: 28rpx;
- font-weight: bold;
- }
+.price-symbol {
+ font-size: 28rpx;
+ font-weight: bold;
+}
- .price-num {
- font-size: 36rpx;
- font-weight: bold;
- }
+.price-num {
+ font-size: 36rpx;
+ font-weight: bold;
+}
- .pay-method {
- font-size: 26rpx;
- color: #86909C;
- }
+.pay-method {
+ font-size: 26rpx;
+ color: #86909C;
+}
+
+.empty-box {
+ padding-top: 160rpx;
+ display: flex;
+ justify-content: center;
+}
+
+.empty-text {
+ font-size: 28rpx;
+ color: #86909C;
+}
</style>
diff --git a/traditionalApp/utils/download-task-storage.js b/traditionalApp/utils/download-task-storage.js
new file mode 100644
index 0000000..4a13c98
--- /dev/null
+++ b/traditionalApp/utils/download-task-storage.js
@@ -0,0 +1,142 @@
+const STORAGE_PREFIX = 'appHealthCourseDownloadTasks_'
+const DOWNLOAD_TASK_CHANGE_EVENT = 'app-download-task-change'
+
+/**
+ * 构建当前登录用户的下载任务缓存键。
+ */
+export function getDownloadTaskStorageKey(userId) {
+ const normalizedUserId = Number(userId || 0)
+ return `${STORAGE_PREFIX}${Number.isFinite(normalizedUserId) ? normalizedUserId : 0}`
+}
+
+/**
+ * 读取当前登录用户的下载中任务列表。
+ */
+export function listDownloadTasks(userId) {
+ const storageKey = getDownloadTaskStorageKey(userId)
+ const value = uni.getStorageSync(storageKey)
+ if (!Array.isArray(value)) {
+ return []
+ }
+ return value
+ .map((item) => normalizeDownloadTask(item))
+ .filter((item) => !!item)
+}
+
+/**
+ * 写入或刷新一条下载任务。
+ */
+export function upsertDownloadTask(userId, task) {
+ const nextTask = normalizeDownloadTask(task)
+ if (!nextTask) {
+ return
+ }
+ const taskList = listDownloadTasks(userId)
+ const targetIndex = taskList.findIndex((item) => item.clientTaskId === nextTask.clientTaskId)
+ if (targetIndex >= 0) {
+ taskList.splice(targetIndex, 1, nextTask)
+ } else {
+ taskList.unshift(nextTask)
+ }
+ saveDownloadTasks(userId, taskList)
+}
+
+/**
+ * 批量移除指定下载任务。
+ */
+export function removeDownloadTasks(userId, clientTaskIds) {
+ if (!Array.isArray(clientTaskIds) || clientTaskIds.length === 0) {
+ return
+ }
+ const taskIdSet = new Set(clientTaskIds.map((item) => String(item)))
+ const taskList = listDownloadTasks(userId).filter((item) => !taskIdSet.has(String(item.clientTaskId)))
+ saveDownloadTasks(userId, taskList)
+}
+
+/**
+ * 移除单条下载任务。
+ */
+export function clearDownloadTask(userId, clientTaskId) {
+ removeDownloadTasks(userId, [clientTaskId])
+}
+
+/**
+ * 获取下载任务变更事件名。
+ */
+export function getDownloadTaskChangeEventName() {
+ return DOWNLOAD_TASK_CHANGE_EVENT
+}
+
+/**
+ * 统一落盘下载任务并通知页面刷新。
+ */
+function saveDownloadTasks(userId, taskList) {
+ const storageKey = getDownloadTaskStorageKey(userId)
+ uni.setStorageSync(storageKey, Array.isArray(taskList) ? taskList : [])
+ uni.$emit(DOWNLOAD_TASK_CHANGE_EVENT)
+}
+
+/**
+ * 归一化下载任务结构,避免页面渲染出现脏数据。
+ */
+function normalizeDownloadTask(value) {
+ if (!value || typeof value !== 'object') {
+ return null
+ }
+ const clientTaskId = normalizeText(value.clientTaskId)
+ if (!clientTaskId) {
+ return null
+ }
+ return {
+ clientTaskId,
+ courseId: normalizeNumber(value.courseId),
+ courseVideoId: normalizeNumber(value.courseVideoId),
+ title: normalizeText(value.title),
+ subtitle: normalizeText(value.subtitle),
+ coverImage: normalizeText(value.coverImage),
+ durationSeconds: normalizeNumber(value.durationSeconds),
+ durationText: normalizeText(value.durationText),
+ videoUrl: normalizeText(value.videoUrl),
+ progress: normalizeProgress(value.progress),
+ status: 'downloading',
+ updatedAt: normalizeText(value.updatedAt) || new Date().toISOString(),
+ }
+}
+
+/**
+ * 归一化可选文本。
+ */
+function normalizeText(value) {
+ if (typeof value !== 'string') {
+ return ''
+ }
+ return value.trim()
+}
+
+/**
+ * 归一化数字字段。
+ */
+function normalizeNumber(value) {
+ const numberValue = Number(value)
+ if (!Number.isFinite(numberValue)) {
+ return 0
+ }
+ return numberValue
+}
+
+/**
+ * 归一化下载进度,保证范围在 0 到 100 之间。
+ */
+function normalizeProgress(value) {
+ const progressValue = Math.round(Number(value || 0))
+ if (!Number.isFinite(progressValue)) {
+ return 0
+ }
+ if (progressValue < 0) {
+ return 0
+ }
+ if (progressValue > 100) {
+ return 100
+ }
+ return progressValue
+}
--
Gitblit v1.7.1