tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppHealthCourseController.java
New file @@ -0,0 +1,36 @@ package com.tcm.training.admin.app.course.controller; import com.tcm.training.admin.app.course.service.AppHealthCourseService; import com.tcm.training.admin.app.course.vo.AppHealthCourseDetailVO; 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.PathVariable; 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 AppHealthCourseController { private final AppHealthCourseService appHealthCourseService; /** * 注入 APP 养生课程详情服务。 */ public AppHealthCourseController(AppHealthCourseService appHealthCourseService) { this.appHealthCourseService = appHealthCourseService; } /** * 获取 APP 养生课程详情。 */ @GetMapping("/health-courses/{courseId}") public ApiResponse<AppHealthCourseDetailVO> detail(@PathVariable("courseId") Long courseId) { return ApiResponse.ok(appHealthCourseService.detail(courseId)); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java
New file @@ -0,0 +1,263 @@ package com.tcm.training.admin.app.course.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.tcm.training.admin.app.course.vo.AppHealthCourseDetailVO; import com.tcm.training.admin.app.course.vo.AppHealthCourseExpertVO; import com.tcm.training.admin.app.course.vo.AppHealthCourseVideoVO; import com.tcm.training.admin.live.entity.Expert; import com.tcm.training.admin.live.entity.HealthCourseVideo; import com.tcm.training.admin.live.mapper.ExpertMapper; import com.tcm.training.admin.live.mapper.HealthCourseVideoMapper; import com.tcm.training.admin.live.service.HealthCourseAppService; import com.tcm.training.admin.live.vo.HealthCourseDetailVO; import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * APP 养生课程详情聚合服务。 */ @Service public class AppHealthCourseService { private static final int COURSE_STATUS_ENABLED = 1; private static final int VIDEO_STATUS_ENABLED = 1; private final HealthCourseAppService healthCourseAppService; private final HealthCourseVideoMapper healthCourseVideoMapper; private final ExpertMapper expertMapper; /** * 注入 APP 养生课程详情所需依赖。 */ public AppHealthCourseService(HealthCourseAppService healthCourseAppService, HealthCourseVideoMapper healthCourseVideoMapper, ExpertMapper expertMapper) { this.healthCourseAppService = healthCourseAppService; this.healthCourseVideoMapper = healthCourseVideoMapper; this.expertMapper = expertMapper; } /** * 获取 APP 养生课程详情页数据。 */ public AppHealthCourseDetailVO detail(Long courseId) { HealthCourseDetailVO courseDetail = healthCourseAppService.detail(courseId); if (courseDetail.getStatus() == null || courseDetail.getStatus().intValue() != COURSE_STATUS_ENABLED) { throw new IllegalArgumentException("养生课程不存在"); } List<HealthCourseVideo> enabledVideos = listEnabledVideos(courseId); AppHealthCourseDetailVO detailVO = new AppHealthCourseDetailVO(); detailVO.setId(courseDetail.getId()); detailVO.setTitle(trimToEmpty(courseDetail.getTitle())); detailVO.setBannerImages(resolveBannerImages(courseDetail)); detailVO.setLevelName(trimToEmpty(courseDetail.getLevelName())); detailVO.setDifficultyName(trimToEmpty(courseDetail.getDifficultyName())); detailVO.setSalePrice(defaultPrice(courseDetail.getSalePrice())); detailVO.setLearnUserCount(defaultNumber(courseDetail.getLearnUserCount())); detailVO.setSummary(trimToEmpty(courseDetail.getSummary())); detailVO.setTargetUsers(trimToEmpty(courseDetail.getTargetUsers())); detailVO.setContent(trimToEmpty(courseDetail.getContent())); detailVO.setVideoCount(Integer.valueOf(enabledVideos.size())); detailVO.setTotalDurationSeconds(resolveTotalDurationSeconds(courseDetail, enabledVideos)); detailVO.setTotalDurationText(formatCourseDuration(detailVO.getTotalDurationSeconds())); detailVO.setExpert(resolveExpert(courseDetail.getExpert() == null ? null : courseDetail.getExpert().getId())); List<AppHealthCourseVideoVO> videoVOList = toVideoVOList(enabledVideos); detailVO.setVideos(videoVOList); detailVO.setFreeVideoCount(countFreeVideos(videoVOList)); detailVO.setPaidVideoCount(countPaidVideos(videoVOList)); return detailVO; } /** * 查询课程下启用的视频章节。 */ private List<HealthCourseVideo> listEnabledVideos(Long courseId) { List<HealthCourseVideo> records = healthCourseVideoMapper.selectList(new LambdaQueryWrapper<HealthCourseVideo>() .eq(HealthCourseVideo::getCourseId, courseId) .eq(HealthCourseVideo::getDeleted, 0) .eq(HealthCourseVideo::getStatus, VIDEO_STATUS_ENABLED) .orderByAsc(HealthCourseVideo::getSort) .orderByAsc(HealthCourseVideo::getId)); return records == null ? Collections.<HealthCourseVideo>emptyList() : records; } /** * 组装课程详情页需要展示的轮播图列表。 */ private List<String> resolveBannerImages(HealthCourseDetailVO courseDetail) { List<String> bannerImages = new ArrayList<String>(); if (courseDetail.getBannerImgs() != null) { for (String bannerImage : courseDetail.getBannerImgs()) { String normalizedImage = trimToEmpty(bannerImage); if (!normalizedImage.isEmpty()) { bannerImages.add(normalizedImage); } } } // 课程未配置轮播图时,回退到封面图,避免前端轮播组件出现空白页。 if (bannerImages.isEmpty()) { String coverImage = trimToEmpty(courseDetail.getCoverImg()); if (!coverImage.isEmpty()) { bannerImages.add(coverImage); } } return bannerImages; } /** * 组装课程详情页专家卡片信息。 */ private AppHealthCourseExpertVO resolveExpert(Long expertId) { if (expertId == null) { return null; } Expert expert = expertMapper.selectOne(new LambdaQueryWrapper<Expert>() .eq(Expert::getId, expertId) .eq(Expert::getDeleted, 0) .eq(Expert::getStatus, COURSE_STATUS_ENABLED) .last("LIMIT 1")); if (expert == null) { return null; } AppHealthCourseExpertVO expertVO = new AppHealthCourseExpertVO(); expertVO.setId(expert.getId()); expertVO.setName(trimToEmpty(expert.getName())); expertVO.setAvatar(trimToEmpty(expert.getAvatar())); expertVO.setSpecialty(trimToEmpty(expert.getSpecialty())); return expertVO; } /** * 将启用视频列表转换为 APP 详情页可直接渲染的数据结构。 */ private List<AppHealthCourseVideoVO> toVideoVOList(List<HealthCourseVideo> videos) { if (videos == null || videos.isEmpty()) { return Collections.emptyList(); } List<AppHealthCourseVideoVO> result = new ArrayList<AppHealthCourseVideoVO>(videos.size()); for (HealthCourseVideo video : videos) { AppHealthCourseVideoVO videoVO = new AppHealthCourseVideoVO(); videoVO.setId(video.getId()); videoVO.setChapterName(trimToEmpty(video.getChapterName())); videoVO.setDurationSeconds(defaultNumber(video.getDurationSeconds())); videoVO.setDurationText(formatVideoDuration(videoVO.getDurationSeconds())); videoVO.setSalePrice(defaultPrice(video.getSalePrice())); // 前端课程列表直接按该字段切分免费/付费列表,避免再次复制业务判断。 videoVO.setFreeAccess(Boolean.valueOf(isFreeVideo(videoVO.getSalePrice()))); result.add(videoVO); } return result; } /** * 统计免费章节数量。 */ private Integer countFreeVideos(List<AppHealthCourseVideoVO> videos) { int count = 0; for (AppHealthCourseVideoVO video : videos) { if (Boolean.TRUE.equals(video.getFreeAccess())) { count++; } } return Integer.valueOf(count); } /** * 统计付费章节数量。 */ private Integer countPaidVideos(List<AppHealthCourseVideoVO> videos) { int count = 0; for (AppHealthCourseVideoVO video : videos) { if (!Boolean.TRUE.equals(video.getFreeAccess())) { count++; } } return Integer.valueOf(count); } /** * 解析课程总时长,优先复用课程表已汇总的时长字段。 */ private Integer resolveTotalDurationSeconds(HealthCourseDetailVO courseDetail, List<HealthCourseVideo> videos) { Integer totalDurationSeconds = courseDetail.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 isFreeVideo(BigDecimal salePrice) { return salePrice == null || salePrice.compareTo(BigDecimal.ZERO) <= 0; } /** * 兜底空价格字段,避免前端价格区展示异常。 */ private BigDecimal defaultPrice(BigDecimal price) { return price == null ? BigDecimal.ZERO : price; } /** * 兜底空数字字段。 */ private Integer defaultNumber(Integer value) { return value == null ? 0 : value; } /** * 将总时长秒数格式化为适合页头展示的简要文案。 */ private String formatCourseDuration(Integer durationSeconds) { int totalSeconds = defaultNumber(durationSeconds); if (totalSeconds <= 0) { return "0分钟"; } int hours = totalSeconds / 3600; int minutes = (totalSeconds % 3600) / 60; if (hours > 0 && minutes > 0) { return hours + "小时" + minutes + "分钟"; } if (hours > 0) { return hours + "小时"; } if (minutes > 0) { return minutes + "分钟"; } return totalSeconds + "秒"; } /** * 将章节时长格式化为 `HH:MM:SS`,便于与当前 APP 设计稿对齐。 */ private String formatVideoDuration(Integer durationSeconds) { int totalSeconds = defaultNumber(durationSeconds); int hours = totalSeconds / 3600; int minutes = (totalSeconds % 3600) / 60; int seconds = totalSeconds % 60; return String.format("%02d:%02d:%02d", hours, minutes, seconds); } /** * 将可空文本转换为空字符串。 */ private String trimToEmpty(String value) { if (value == null) { return ""; } String trimmed = value.trim(); return trimmed.isEmpty() ? "" : trimmed; } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseDetailVO.java
New file @@ -0,0 +1,49 @@ package com.tcm.training.admin.app.course.vo; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; import java.util.List; /** * APP 养生课程详情响应。 */ @Getter @Setter public class AppHealthCourseDetailVO { private Long id; private String title; private List<String> bannerImages; private String levelName; private String difficultyName; private BigDecimal salePrice; private Integer learnUserCount; private String summary; private String targetUsers; private String content; private Integer videoCount; private Integer totalDurationSeconds; private String totalDurationText; private AppHealthCourseExpertVO expert; private Integer freeVideoCount; private Integer paidVideoCount; private List<AppHealthCourseVideoVO> videos; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseExpertVO.java
New file @@ -0,0 +1,20 @@ package com.tcm.training.admin.app.course.vo; import lombok.Getter; import lombok.Setter; /** * APP 养生课程专家卡片响应。 */ @Getter @Setter public class AppHealthCourseExpertVO { private Long id; private String name; private String avatar; private String specialty; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseVideoVO.java
New file @@ -0,0 +1,26 @@ package com.tcm.training.admin.app.course.vo; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; /** * APP 养生课程章节响应。 */ @Getter @Setter public class AppHealthCourseVideoVO { private Long id; private String chapterName; private Integer durationSeconds; private String durationText; private BigDecimal salePrice; private Boolean freeAccess; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/home/controller/AppHomeFeaturedContentController.java
New file @@ -0,0 +1,35 @@ package com.tcm.training.admin.app.home.controller; import com.tcm.training.admin.app.home.service.AppHomeFeaturedContentService; import com.tcm.training.admin.app.home.vo.AppHomeFeaturedContentVO; 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/home", "/api/v1/app/home"}) public class AppHomeFeaturedContentController { private final AppHomeFeaturedContentService appHomeFeaturedContentService; /** * 注入 APP 首页精选课程介绍模块服务。 */ public AppHomeFeaturedContentController(AppHomeFeaturedContentService appHomeFeaturedContentService) { this.appHomeFeaturedContentService = appHomeFeaturedContentService; } /** * 获取 APP 首页精选课程介绍模块数据。 */ @GetMapping("/featured-content") public ApiResponse<AppHomeFeaturedContentVO> getFeaturedContent() { return ApiResponse.ok(appHomeFeaturedContentService.getFeaturedContent()); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/home/service/AppHomeFeaturedContentService.java
New file @@ -0,0 +1,179 @@ package com.tcm.training.admin.app.home.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.tcm.training.admin.app.home.vo.AppHomeFeaturedContentVO; import com.tcm.training.admin.app.home.vo.AppHomeFeaturedHealthCourseVO; import com.tcm.training.admin.app.home.vo.AppHomeFeaturedHealthPopularizationVO; import com.tcm.training.admin.live.entity.HealthCourse; import com.tcm.training.admin.live.entity.HealthPopularization; import com.tcm.training.admin.live.entity.LiveCourseCategory; import com.tcm.training.admin.live.mapper.HealthCourseMapper; import com.tcm.training.admin.live.mapper.HealthPopularizationMapper; import com.tcm.training.admin.live.mapper.LiveCourseCategoryMapper; import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; /** * APP 首页精选课程介绍模块聚合服务。 */ @Service public class AppHomeFeaturedContentService { private static final int STATUS_ENABLED = 1; private static final int HOME_RECOMMEND_ENABLED = 1; private static final int HOME_CARD_LIMIT = 2; private final HealthCourseMapper healthCourseMapper; private final HealthPopularizationMapper healthPopularizationMapper; private final LiveCourseCategoryMapper liveCourseCategoryMapper; /** * 注入首页精选模块所需的课程、科普与分类查询能力。 */ public AppHomeFeaturedContentService(HealthCourseMapper healthCourseMapper, HealthPopularizationMapper healthPopularizationMapper, LiveCourseCategoryMapper liveCourseCategoryMapper) { this.healthCourseMapper = healthCourseMapper; this.healthPopularizationMapper = healthPopularizationMapper; this.liveCourseCategoryMapper = liveCourseCategoryMapper; } /** * 查询 APP 首页精选课程介绍模块数据。 */ public AppHomeFeaturedContentVO getFeaturedContent() { AppHomeFeaturedContentVO vo = new AppHomeFeaturedContentVO(); List<HealthCourse> healthCourses = loadHomeRecommendedHealthCourses(); vo.setHealthCourses(toHealthCourseCards(healthCourses)); vo.setHealthPopularizations(toPopularizationCards(loadHomeRecommendedPopularizations())); return vo; } /** * 查询首页推荐的养生课程。 */ private List<HealthCourse> loadHomeRecommendedHealthCourses() { List<HealthCourse> records = healthCourseMapper.selectList(new LambdaQueryWrapper<HealthCourse>() .eq(HealthCourse::getDeleted, 0) .eq(HealthCourse::getStatus, STATUS_ENABLED) .eq(HealthCourse::getIsHomeRecommend, HOME_RECOMMEND_ENABLED) .orderByAsc(HealthCourse::getSort) .orderByDesc(HealthCourse::getId) .last("LIMIT " + HOME_CARD_LIMIT)); return records == null ? Collections.<HealthCourse>emptyList() : records; } /** * 查询首页推荐的健康科普内容。 */ private List<HealthPopularization> loadHomeRecommendedPopularizations() { List<HealthPopularization> records = healthPopularizationMapper.selectList(new LambdaQueryWrapper<HealthPopularization>() .eq(HealthPopularization::getDeleted, 0) .eq(HealthPopularization::getStatus, STATUS_ENABLED) .eq(HealthPopularization::getIsHomeRecommend, HOME_RECOMMEND_ENABLED) .orderByAsc(HealthPopularization::getSort) .orderByDesc(HealthPopularization::getId) .last("LIMIT " + HOME_CARD_LIMIT)); return records == null ? Collections.<HealthPopularization>emptyList() : records; } /** * 将首页推荐养生课程转换为首页卡片响应。 */ private List<AppHomeFeaturedHealthCourseVO> toHealthCourseCards(List<HealthCourse> courses) { if (courses == null || courses.isEmpty()) { return Collections.emptyList(); } Map<Long, String> categoryNameMap = loadCategoryNameMap(courses); List<AppHomeFeaturedHealthCourseVO> result = new ArrayList<AppHomeFeaturedHealthCourseVO>(courses.size()); for (HealthCourse course : courses) { AppHomeFeaturedHealthCourseVO vo = new AppHomeFeaturedHealthCourseVO(); vo.setId(course.getId()); vo.setTitle(trimToEmpty(course.getTitle())); vo.setCoverImage(trimToEmpty(course.getCoverImg())); vo.setLevelName(categoryNameMap.get(course.getLevelCategoryId())); vo.setDifficultyName(categoryNameMap.get(course.getDifficultyCategoryId())); vo.setSalePrice(course.getSalePrice() == null ? BigDecimal.ZERO : course.getSalePrice()); vo.setLearnUserCount(defaultNumber(course.getLearnUserCount())); result.add(vo); } return result; } /** * 批量加载首页课程卡片需要展示的分类名称。 */ private Map<Long, String> loadCategoryNameMap(List<HealthCourse> courses) { List<Long> categoryIds = new ArrayList<Long>(courses.size() * 2); for (HealthCourse course : courses) { addCategoryId(categoryIds, course.getLevelCategoryId()); addCategoryId(categoryIds, course.getDifficultyCategoryId()); } if (categoryIds.isEmpty()) { return Collections.emptyMap(); } List<LiveCourseCategory> categories = liveCourseCategoryMapper.selectList(new LambdaQueryWrapper<LiveCourseCategory>() .in(LiveCourseCategory::getId, categoryIds) .eq(LiveCourseCategory::getDeleted, 0)); Map<Long, String> nameMap = new HashMap<Long, String>(categories.size()); for (LiveCourseCategory category : categories) { nameMap.put(category.getId(), trimToEmpty(category.getName())); } return nameMap; } /** * 向分类主键列表中追加有效主键,避免空值干扰批量查询。 */ private void addCategoryId(List<Long> categoryIds, Long categoryId) { if (categoryId != null && !categoryIds.contains(categoryId)) { categoryIds.add(categoryId); } } /** * 将首页推荐健康科普转换为首页宫格卡片响应。 */ private List<AppHomeFeaturedHealthPopularizationVO> toPopularizationCards(List<HealthPopularization> items) { if (items == null || items.isEmpty()) { return Collections.emptyList(); } List<AppHomeFeaturedHealthPopularizationVO> result = new ArrayList<AppHomeFeaturedHealthPopularizationVO>(items.size()); for (HealthPopularization item : items) { AppHomeFeaturedHealthPopularizationVO vo = new AppHomeFeaturedHealthPopularizationVO(); vo.setId(item.getId()); vo.setTitle(trimToEmpty(item.getTitle())); vo.setCoverImage(trimToEmpty(item.getCoverImg())); vo.setContentType(item.getContentType()); vo.setClickCount(defaultNumber(item.getClickCount())); result.add(vo); } return result; } /** * 兜底空数字字段,避免前端展示出现空值。 */ private Integer defaultNumber(Integer value) { return value == null ? 0 : value; } /** * 将可空文本转换为前端可直接消费的空字符串。 */ private String trimToEmpty(String value) { if (value == null) { return ""; } String trimmed = value.trim(); return trimmed.isEmpty() ? "" : trimmed; } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/home/vo/AppHomeFeaturedContentVO.java
New file @@ -0,0 +1,18 @@ package com.tcm.training.admin.app.home.vo; import lombok.Getter; import lombok.Setter; import java.util.List; /** * APP 首页精选课程介绍模块聚合响应。 */ @Getter @Setter public class AppHomeFeaturedContentVO { private List<AppHomeFeaturedHealthCourseVO> healthCourses; private List<AppHomeFeaturedHealthPopularizationVO> healthPopularizations; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/home/vo/AppHomeFeaturedHealthCourseVO.java
New file @@ -0,0 +1,28 @@ package com.tcm.training.admin.app.home.vo; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; /** * APP 首页精选养生课程卡片响应。 */ @Getter @Setter public class AppHomeFeaturedHealthCourseVO { private Long id; private String title; private String coverImage; private String levelName; private String difficultyName; private BigDecimal salePrice; private Integer learnUserCount; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/home/vo/AppHomeFeaturedHealthPopularizationVO.java
New file @@ -0,0 +1,22 @@ package com.tcm.training.admin.app.home.vo; import lombok.Getter; import lombok.Setter; /** * APP 首页精选健康科普卡片响应。 */ @Getter @Setter public class AppHomeFeaturedHealthPopularizationVO { private Long id; private String title; private String coverImage; private Integer contentType; private Integer clickCount; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/popularization/controller/AppHealthPopularizationController.java
New file @@ -0,0 +1,36 @@ package com.tcm.training.admin.app.popularization.controller; import com.tcm.training.admin.app.popularization.service.AppHealthPopularizationService; import com.tcm.training.admin.app.popularization.vo.AppHealthPopularizationDetailVO; 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.PathVariable; 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 AppHealthPopularizationController { private final AppHealthPopularizationService appHealthPopularizationService; /** * 注入 APP 健康科普详情服务。 */ public AppHealthPopularizationController(AppHealthPopularizationService appHealthPopularizationService) { this.appHealthPopularizationService = appHealthPopularizationService; } /** * 获取 APP 健康科普详情。 */ @GetMapping("/health-popularizations/{popularizationId}") public ApiResponse<AppHealthPopularizationDetailVO> detail(@PathVariable("popularizationId") Long popularizationId) { return ApiResponse.ok(appHealthPopularizationService.detail(popularizationId)); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/popularization/service/AppHealthPopularizationService.java
New file @@ -0,0 +1,78 @@ package com.tcm.training.admin.app.popularization.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.tcm.training.admin.app.popularization.vo.AppHealthPopularizationDetailVO; import com.tcm.training.admin.live.entity.HealthPopularization; import com.tcm.training.admin.live.mapper.HealthPopularizationMapper; import org.springframework.stereotype.Service; /** * APP 健康科普详情聚合服务。 */ @Service public class AppHealthPopularizationService { private static final int STATUS_ENABLED = 1; private final HealthPopularizationMapper healthPopularizationMapper; /** * 注入 APP 健康科普详情所需依赖。 */ public AppHealthPopularizationService(HealthPopularizationMapper healthPopularizationMapper) { this.healthPopularizationMapper = healthPopularizationMapper; } /** * 获取 APP 健康科普详情页数据。 */ public AppHealthPopularizationDetailVO detail(Long popularizationId) { HealthPopularization item = requireEnabledItem(popularizationId); AppHealthPopularizationDetailVO detailVO = new AppHealthPopularizationDetailVO(); detailVO.setId(item.getId()); detailVO.setTitle(trimToEmpty(item.getTitle())); detailVO.setCoverImage(trimToEmpty(item.getCoverImg())); detailVO.setContentType(item.getContentType()); detailVO.setContentTypeText(resolveContentTypeText(item.getContentType())); detailVO.setContent(trimToEmpty(item.getContent())); detailVO.setVideoUrl(trimToEmpty(item.getVideoUrl())); detailVO.setVideoFileName(trimToEmpty(item.getVideoFileName())); return detailVO; } /** * 查询启用中的健康科普详情,避免 APP 访问到后台下线内容。 */ private HealthPopularization requireEnabledItem(Long popularizationId) { HealthPopularization item = healthPopularizationMapper.selectOne(new LambdaQueryWrapper<HealthPopularization>() .eq(HealthPopularization::getId, popularizationId) .eq(HealthPopularization::getDeleted, 0) .eq(HealthPopularization::getStatus, STATUS_ENABLED) .last("LIMIT 1")); if (item == null) { throw new IllegalArgumentException("健康科普不存在"); } return item; } /** * 解析内容类型文案,便于前端按类型渲染页面。 */ private String resolveContentTypeText(Integer contentType) { if (contentType != null && contentType.intValue() == HealthPopularization.CONTENT_TYPE_VIDEO) { return "视频"; } return "图文"; } /** * 将可空文本统一转换为空字符串。 */ private String trimToEmpty(String value) { if (value == null) { return ""; } String trimmed = value.trim(); return trimmed.isEmpty() ? "" : trimmed; } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/popularization/vo/AppHealthPopularizationDetailVO.java
New file @@ -0,0 +1,28 @@ package com.tcm.training.admin.app.popularization.vo; import lombok.Getter; import lombok.Setter; /** * APP 健康科普详情响应。 */ @Getter @Setter public class AppHealthPopularizationDetailVO { private Long id; private String title; private String coverImage; private Integer contentType; private String contentTypeText; private String content; private String videoUrl; private String videoFileName; } traditionalApp/pages/articleDetail/articleDetail.vue
@@ -10,38 +10,159 @@ </view> <!-- 视频播放器区域 --> <view class="video-container"> <video <view v-if="isVideoType" class="video-container"> <video class="video-player" src="https://img.cdn.aliyun.dcloud.net.cn/guide/uniapp/%E7%AC%AC1%E8%AE%B2%EF%BC%88uni-app%E4%BA%A7%E5%93%81%E4%BB%8B%E7%BB%8D%EF%BC%89-%20DCloud%E5%AE%98%E6%96%B9%E8%A7%86%E9%A2%91%E6%95%99%E7%A8%8B@20181126-lite.m4v" :src="detail.videoUrl" controls autoplay="false" :autoplay="false" ></video> </view> <!-- 图文封面区域 --> <view v-else-if="detail.coverImage" class="cover-container"> <image class="cover-image" :src="detail.coverImage" mode="aspectFill"></image> </view> <!-- 内容区域 --> <view class="content-area"> <text class="content-title">(一)特殊食品及食盐管理-保健食品生产经营管理01</text> <text class="content-title">{{ detail.title || '健康科普详情' }}</text> <rich-text v-if="!isVideoType" :nodes="contentNodes" class="rich-content"></rich-text> <view v-if="loading" class="status-text">加载中...</view> <view v-else-if="!detail.id" class="status-text">暂无详情内容</view> </view> </view> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue'; import { computed, onMounted, ref } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { getHealthPopularizationDetail } from './service' const statusBarHeight = ref(0); interface PopularizationDetail { id?: number | null title: string coverImage: string contentType?: number | null contentTypeText: string content: string videoUrl: string videoFileName: string } const statusBarHeight = ref(0) const loading = ref(false) const popularizationId = ref('') const detail = ref<PopularizationDetail>(createDefaultDetail()) /** * 创建健康科普详情页默认数据,避免接口返回前页面闪空。 */ function createDefaultDetail(): PopularizationDetail { return { id: null, title: '', coverImage: '', contentType: null, contentTypeText: '', content: '', videoUrl: '', videoFileName: '', } } /** * 根据内容类型判断当前详情是否属于视频详情页。 */ const isVideoType = computed(() => Number(detail.value.contentType) === 2) /** * 将富文本正文转换为 `rich-text` 可直接消费的节点字符串。 */ const contentNodes = computed(() => buildRichTextNodes(detail.value.content, '暂无详情内容')) /** * 初始化导航栏所需的状态栏高度。 */ onMounted(() => { uni.getSystemInfo({ success: (res) => { statusBarHeight.value = res.statusBarHeight || 0; statusBarHeight.value = res.statusBarHeight || 0 } }); }); }) }) const goBack = () => { uni.navigateBack(); }; /** * 页面加载时读取科普主键并拉取详情接口。 */ onLoad((options) => { popularizationId.value = typeof options?.id === 'string' ? options.id : '' loadDetail() }) /** * 拉取健康科普详情接口并更新页面状态。 */ async function loadDetail() { if (!popularizationId.value) { detail.value = createDefaultDetail() return } loading.value = true try { const response = await getHealthPopularizationDetail(popularizationId.value) if (response?.code !== 0 || !response.data) { showLoadFailedToast() return } // 图文和视频共用一个详情接口,前端只按内容类型切换展示结构。 detail.value = { ...createDefaultDetail(), ...response.data, } } catch { showLoadFailedToast() } finally { loading.value = false } } /** * 详情加载失败时给出统一提示。 */ function showLoadFailedToast() { uni.showToast({ title: '健康科普详情加载失败', icon: 'none', }) } /** * 将富文本内容转换为 `rich-text` 可直接渲染的节点。 */ function buildRichTextNodes(content?: string, fallbackText?: string) { if (typeof content === 'string' && content.trim()) { return content } if (typeof fallbackText === 'string' && fallbackText.trim()) { return `<p>${fallbackText.trim()}</p>` } return '<p></p>' } /** * 返回上一页;若没有历史栈,则回到首页避免停留在空白上下文。 */ function goBack() { const currentPages = getCurrentPages() if (currentPages.length > 1) { uni.navigateBack() return } uni.switchTab({ url: '/pages/home/home' }) } </script> <style> @@ -91,6 +212,16 @@ height: 100%; } .cover-container { width: 100%; height: 402rpx; } .cover-image { width: 100%; height: 100%; } .content-area { padding: 32rpx; background-color: #F5F6F8; @@ -102,4 +233,19 @@ color: #333; line-height: 48rpx; } .rich-content { margin-top: 24rpx; width: 100%; font-size: 28rpx; line-height: 1.8; color: #333; } .status-text { margin-top: 24rpx; font-size: 26rpx; line-height: 40rpx; color: #86909C; } </style> traditionalApp/pages/articleDetail/service.js
New file @@ -0,0 +1,8 @@ import request from '@/utils/request.js' /** * 获取 APP 健康科普详情。 */ export const getHealthPopularizationDetail = (popularizationId) => { return request.get(`/api/app/health-popularizations/${popularizationId}`, {}, false, false) } traditionalApp/pages/courseDetail/courseDetail.vue
@@ -16,7 +16,7 @@ </swiper-item> </swiper> <!-- 图片指示器 --> <view class="indicator"> <view v-if="swiperList.length > 0" class="indicator"> <text>{{ currentSwiperIndex + 1 }}/{{ swiperList.length }}</text> </view> </view> @@ -33,28 +33,25 @@ <template v-if="currentTab === 'course'"> <!-- 基本信息模块 --> <view class="info-section"> <view class="info-title">中医营养师-全链路培养计划直播课</view> <view class="info-title">{{ courseDetail.title || '养生课程详情' }}</view> <view class="tags-box"> <text class="tag">养生</text> <text class="tag">养生</text> <view v-if="displayTags.length > 0" class="tags-box"> <text v-for="tag in displayTags" :key="tag" class="tag">{{ tag }}</text> </view> <view class="price-stats-box"> <view class="price-box"> <text class="price-symbol">¥</text> <text class="price-integer">8888</text> <text class="price-decimal">.25</text> <text class="price-integer">{{ priceParts.integer }}</text> <text class="price-decimal">.{{ priceParts.decimal }}</text> </view> <view class="stats-box"> <image class="person-icon" src="/static/image/home/person.png" mode="aspectFit"></image> <text class="stats-text">观看人数1.2W</text> <text class="stats-text">{{ formatLearningCount(courseDetail.learnUserCount) }}人正在学</text> </view> </view> <view class="desc-text"> 课程介绍:详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案详情文案 </view> <view class="desc-text">{{ courseDetail.summary || '暂无课程介绍' }}</view> <!-- 会员横幅 --> <view class="vip-banner"> @@ -63,7 +60,7 @@ <view class="vip-left"> <view class="vip-price-box"> <text class="vip-price-symbol">¥</text> <text class="vip-price-num">888.98</text> <text class="vip-price-num">{{ formattedPrice }}</text> <text class="vip-price-suffix">起</text> </view> <text class="vip-desc">开通会员尊享养生课程</text> @@ -76,61 +73,55 @@ <!-- 课程列表模块 (也在课程tab中展示) --> <view class="course-list-section module-section"> <view class="list-header"> <text class="list-title">课程列表 <text class="list-subtitle">共12课 | 15小时</text></text> <text class="expand-text">展开全部</text> <text class="list-title"> 课程列表 <text class="list-subtitle">共{{ courseDetail.videoCount || 0 }}课 | {{ courseDetail.totalDurationText || '0分钟' }}</text> </text> <text class="expand-text">全部章节</text> </view> <image class="free-divider" src="/static/image/healthCourses/free.png" mode="widthFix"></image> <template v-if="freeVideos.length > 0"> <image class="free-divider" src="/static/image/healthCourses/free.png" mode="widthFix"></image> <view class="list-items"> <view class="list-item"> <text class="item-title">1、食品安全风险分级管理 (1)</text> <text class="item-time">00:20:42</text> </view> <view class="list-item active"> <view class="item-title-box"> <image class="stat-icon" src="/static/image/healthCourses/stat.png" mode="aspectFit"></image> <text class="item-title active-text">1、食品安全风险分级管理 (1)</text> <view class="list-items"> <view class="list-item" v-for="(item, index) in freeVideos" :key="item.id"> <text class="item-title">{{ index + 1 }}、{{ item.chapterName }}</text> <text class="item-time">{{ item.durationText }}</text> </view> <text class="item-time active-text">00:20:42</text> </view> <view class="list-item"> <text class="item-title">1、食品安全风险分级管理 (1)</text> <text class="item-time">00:20:42</text> </view> <view class="list-item"> <text class="item-title">1、食品安全风险分级管理 (1)</text> <text class="item-time">00:20:42</text> </view> </view> </template> <image class="pay-divider" src="/static/image/healthCourses/payToUnlock.png" mode="widthFix"></image> <template v-if="paidVideos.length > 0"> <image class="pay-divider" src="/static/image/healthCourses/payToUnlock.png" mode="widthFix"></image> <view class="list-items"> <view class="list-item" v-for="i in 4" :key="i"> <text class="item-title">1、食品安全风险分级管理 (1)</text> <view class="list-items"> <view class="list-item" v-for="(item, index) in paidVideos" :key="item.id"> <text class="item-title">{{ freeVideos.length + index + 1 }}、{{ item.chapterName }}</text> <view class="item-right-box"> <text class="item-time">00:20:42</text> <view class="cost-btn">¥88.98</view> <text class="item-time">{{ item.durationText }}</text> <view class="cost-btn">¥{{ formatPrice(item.salePrice) }}</view> </view> </view> </view> </view> </template> <view v-if="!loading && allVideos.length === 0" class="empty-text">暂无课程章节</view> </view> <!-- 适用人群模块 --> <view class="module-section"> <view v-if="courseDetail.targetUsers" class="module-section"> <view class="module-title">适用人群</view> <view class="module-desc">适用人群适用人群适用人群适用人群适用人群适用人群适用人群适用人群适用人群</view> <view class="module-desc">{{ courseDetail.targetUsers }}</view> </view> <!-- 授课专家模块 --> <view class="module-section" @tap="goToExpertDetail"> <view v-if="courseDetail.expert" class="module-section" @tap="goToExpertDetail"> <view class="module-title">授课专家</view> <view class="expert-card"> <image class="expert-avatar" src="/static/image/home/avatr.png" mode="aspectFill"></image> <image class="expert-avatar" :src="courseDetail.expert.avatar || '/static/image/home/avatr.png'" mode="aspectFill"></image> <view class="expert-info"> <view class="expert-name">张雨泽</view> <view class="expert-desc">授课专家介绍授课专家介绍授课专家介绍授课专家介绍授课专家介绍</view> <view class="expert-name">{{ courseDetail.expert.name || '专家老师' }}</view> <view class="expert-desc">{{ courseDetail.expert.specialty || '点击查看专家介绍' }}</view> </view> </view> </view> @@ -138,11 +129,7 @@ <!-- 课程详情模块 --> <view class="module-section last-module"> <view class="module-title">课程详情</view> <view class="detail-content"> 北冥有鱼,其名为鲲。鲲之大,不知其几千里也。化而为鸟,其名为鹏。鹏之背,不知其几千里也。怒而飞,其翼若垂天之云。是鸟也,海运则将徙于南冥。南冥者,天池也。 <br><br> 北冥有鱼,其名为鲲。鲲之大,不知其几千里也。化而为鸟,其名为鹏。鹏之背,不知其几千里也。怒而飞,其翼若垂天之云。是鸟也,海运则将徙于南冥。南冥者,天池也。 </view> <rich-text :nodes="courseContentNodes" class="detail-content rich-detail-content"></rich-text> </view> </template> @@ -150,47 +137,43 @@ <!-- 课程列表模块 --> <view class="course-list-section"> <view class="list-header"> <text class="list-title">课程列表 <text class="list-subtitle">共12课 | 15小时</text></text> <text class="expand-text">展开全部</text> <text class="list-title"> 课程列表 <text class="list-subtitle">共{{ courseDetail.videoCount || 0 }}课 | {{ courseDetail.totalDurationText || '0分钟' }}</text> </text> <text class="expand-text">全部章节</text> </view> <image class="free-divider" src="/static/image/healthCourses/free.png" mode="widthFix"></image> <template v-if="freeVideos.length > 0"> <image class="free-divider" src="/static/image/healthCourses/free.png" mode="widthFix"></image> <view class="list-items"> <view class="list-item"> <text class="item-title">1、食品安全风险分级管理 (1)</text> <text class="item-time">00:20:42</text> </view> <view class="list-item active"> <view class="item-title-box"> <image class="stat-icon" src="/static/image/healthCourses/stat.png" mode="aspectFit"></image> <text class="item-title active-text">1、食品安全风险分级管理 (1)</text> <view class="list-items"> <view class="list-item" v-for="(item, index) in freeVideos" :key="item.id"> <text class="item-title">{{ index + 1 }}、{{ item.chapterName }}</text> <text class="item-time">{{ item.durationText }}</text> </view> <text class="item-time active-text">00:20:42</text> </view> <view class="list-item"> <text class="item-title">1、食品安全风险分级管理 (1)</text> <text class="item-time">00:20:42</text> </view> <view class="list-item"> <text class="item-title">1、食品安全风险分级管理 (1)</text> <text class="item-time">00:20:42</text> </view> </view> </template> <image class="pay-divider" src="/static/image/healthCourses/payToUnlock.png" mode="widthFix"></image> <template v-if="paidVideos.length > 0"> <image class="pay-divider" src="/static/image/healthCourses/payToUnlock.png" mode="widthFix"></image> <view class="list-items"> <view class="list-item" v-for="i in 4" :key="i"> <text class="item-title">1、食品安全风险分级管理 (1)</text> <view class="list-items"> <view class="list-item" v-for="(item, index) in paidVideos" :key="item.id"> <text class="item-title">{{ freeVideos.length + index + 1 }}、{{ item.chapterName }}</text> <view class="item-right-box"> <text class="item-time">00:20:42</text> <view class="cost-btn">¥88.98</view> <text class="item-time">{{ item.durationText }}</text> <view class="cost-btn">¥{{ formatPrice(item.salePrice) }}</view> </view> </view> </view> </view> </template> <view v-if="!loading && allVideos.length === 0" class="empty-text">暂无课程章节</view> </view> </template> <view v-if="loading" class="loading-text">加载中...</view> </scroll-view> <!-- 底部悬浮按钮 --> @@ -215,28 +198,240 @@ </template> <script setup lang="ts"> import { ref } from 'vue'; import { computed, ref } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { getHealthCourseDetail } from './service' const currentTab = ref('course'); // 'course' 或 'list' const currentSwiperIndex = ref(0); const swiperList = ref([ 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg', 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg' ]); interface CourseExpert { id?: number | null name?: string avatar?: string specialty?: string } const goBack = () => { uni.navigateBack(); }; interface CourseVideo { id: number chapterName: string durationSeconds: number durationText: string salePrice: number | string freeAccess: boolean } const onSwiperChange = (e: any) => { currentSwiperIndex.value = e.detail.current; }; interface CourseDetail { id?: number | null title: string bannerImages: string[] levelName: string difficultyName: string salePrice: number | string learnUserCount: number summary: string targetUsers: string content: string videoCount: number totalDurationSeconds: number totalDurationText: string expert: CourseExpert | null freeVideoCount: number paidVideoCount: number videos: CourseVideo[] } const goToExpertDetail = () => { interface SwiperChangeEvent { detail?: { current?: number } } const currentTab = ref('course') const currentSwiperIndex = ref(0) const loading = ref(false) const courseId = ref('') const courseDetail = ref<CourseDetail>(createDefaultCourseDetail()) /** * 创建课程详情页默认数据,避免接口返回前页面闪空。 */ function createDefaultCourseDetail(): CourseDetail { return { id: null, title: '', bannerImages: [], levelName: '', difficultyName: '', salePrice: 0, learnUserCount: 0, summary: '', targetUsers: '', content: '', videoCount: 0, totalDurationSeconds: 0, totalDurationText: '', expert: null, freeVideoCount: 0, paidVideoCount: 0, videos: [], } } const swiperList = computed(() => { if (courseDetail.value.bannerImages.length > 0) { return courseDetail.value.bannerImages } return ['/static/image/home/class.png'] }) const displayTags = computed(() => { const tags: string[] = [] if (courseDetail.value.levelName) { tags.push(courseDetail.value.levelName) } if (courseDetail.value.difficultyName) { tags.push(courseDetail.value.difficultyName) } return tags }) const allVideos = computed(() => courseDetail.value.videos || []) const freeVideos = computed(() => { return allVideos.value.filter((item) => item.freeAccess) }) const paidVideos = computed(() => { return allVideos.value.filter((item) => !item.freeAccess) }) const formattedPrice = computed(() => formatPrice(courseDetail.value.salePrice)) const priceParts = computed(() => { const [integer, decimal] = formattedPrice.value.split('.') return { integer: integer || '0', decimal: decimal || '00', } }) const courseContentNodes = computed(() => buildRichTextNodes(courseDetail.value.content, '暂无课程详情')) /** * 页面加载时读取课程主键并拉取详情接口。 */ onLoad((options) => { courseId.value = typeof options?.id === 'string' ? options.id : '' loadCourseDetail() }) /** * 拉取课程详情接口并合并到页面状态。 */ async function loadCourseDetail() { if (!courseId.value) { courseDetail.value = createDefaultCourseDetail() return } loading.value = true try { const response = await getHealthCourseDetail(courseId.value) if (response?.code !== 0 || !response.data) { showLoadFailedToast() return } // 后端已按免费/付费规则整理好数据,前端仅负责展示,避免重复业务分叉。 courseDetail.value = { ...createDefaultCourseDetail(), ...response.data, bannerImages: Array.isArray(response.data.bannerImages) ? response.data.bannerImages : [], videos: Array.isArray(response.data.videos) ? response.data.videos : [], } if (currentSwiperIndex.value >= swiperList.value.length) { currentSwiperIndex.value = 0 } } catch { showLoadFailedToast() } finally { loading.value = false } } /** * 课程详情加载失败时给出统一提示。 */ function showLoadFailedToast() { uni.showToast({ title: '课程详情加载失败', icon: 'none', }) } /** * 返回上一页;若当前页没有历史栈,则回到首页避免留在空页。 */ function goBack() { const currentPages = getCurrentPages() if (currentPages.length > 1) { uni.navigateBack() return } uni.switchTab({ url: '/pages/home/home' }) } /** * 同步轮播当前索引,驱动右下角页码指示器。 */ function onSwiperChange(event: SwiperChangeEvent) { currentSwiperIndex.value = event.detail?.current || 0 } /** * 跳转专家介绍页,并携带专家主键加载真实内容。 */ function goToExpertDetail() { if (!courseDetail.value.expert?.id) { return } uni.navigateTo({ url: '/pages/protocol/protocol?type=expert' }); }; url: `/pages/protocol/protocol?type=expert&id=${courseDetail.value.expert.id}` }) } /** * 构建适配当前价格展示区的两位小数字符串。 */ function formatPrice(price: number | string | undefined) { const normalizedNumber = Number(price || 0) if (!Number.isFinite(normalizedNumber)) { return '0.00' } return normalizedNumber.toFixed(2) } /** * 格式化学习人数,保持首页列表已有展示习惯。 */ function formatLearningCount(count?: number) { const normalizedCount = Number(count || 0) if (normalizedCount >= 10000) { return `${(normalizedCount / 10000).toFixed(1).replace(/\.0$/, '')}W` } return `${normalizedCount}` } /** * 将富文本正文转换为 `rich-text` 可直接渲染的节点。 */ function buildRichTextNodes(content?: string, fallbackText?: string) { if (typeof content === 'string' && content.trim()) { return content } if (typeof fallbackText === 'string' && fallbackText.trim()) { return `<p>${fallbackText.trim()}</p>` } return '<p></p>' } </script> <style lang="scss" scoped> @@ -566,6 +761,10 @@ color: #1A1A1A; line-height: 1.6; } .rich-detail-content { width: 100%; } } .course-list-section { @@ -668,6 +867,14 @@ } } .loading-text, .empty-text { padding: 40rpx 32rpx; text-align: center; font-size: 26rpx; color: #999; } .bottom-bar { position: fixed; bottom: 0; @@ -730,4 +937,4 @@ border-radius: 16rpx; } } </style> </style> traditionalApp/pages/courseDetail/service.js
New file @@ -0,0 +1,8 @@ import request from '@/utils/request.js' /** * 获取 APP 养生课程详情。 */ export const getHealthCourseDetail = (courseId) => { return request.get(`/api/app/health-courses/${courseId}`, {}, false, false) } traditionalApp/pages/home/home.vue
@@ -61,60 +61,39 @@ </view> <!-- Course Section --> <view class="section"> <view v-if="featuredHealthCourseList.length || featuredPopularizationList.length" class="section"> <view class="section-header"> <text class="section-title">精选课程介绍</text> <image src="/static/image/home/conentHead.png" class="header-deco"></image> <image src="/static/image/home/circle.png" style="left: 238rpx !important;" class="header-circle" mode="aspectFill"></image> </view> <view class="course-list"> <!-- Item 1 --> <view class="course-item"> <image src="/static/image/home/class.png" mode="aspectFill" class="course-img"></image> <view v-if="featuredHealthCourseList.length" class="course-list"> <view v-for="item in featuredHealthCourseList" :key="item.id" class="course-item" @tap="goCourseDetail(item.id)"> <image :src="item.coverImage || defaultCourseCover" mode="aspectFill" class="course-img"></image> <view class="course-info"> <view class="course-top"> <text class="tag-yangsheng">养生</text> <text class="course-name">拍八虚新手入门版</text> <text v-if="resolveCourseTag(item)" class="tag-yangsheng">{{ resolveCourseTag(item) }}</text> <text class="course-name">{{ item.title }}</text> </view> <view class="course-bottom"> <text class="price"><text class="price-symbol">¥</text>8888.25</text> <text class="price"><text class="price-symbol">¥</text>{{ formatPrice(item.salePrice) }}</text> <view class="people"> <image src="/static/image/home/person.png" class="person-icon"></image> <text>8888人正在学</text> </view> </view> </view> </view> <!-- Item 2 --> <view class="course-item"> <image src="/static/image/home/class.png" mode="aspectFill" class="course-img"></image> <view class="course-info"> <view class="course-top"> <text class="tag-yangsheng">养生</text> <text class="course-name">拍八虚新手入门版</text> </view> <view class="course-bottom"> <text class="price"><text class="price-symbol">¥</text>8888.25</text> <view class="people"> <image src="/static/image/home/person.png" class="person-icon"></image> <text>8888人正在学</text> <text>{{ formatLearningCount(item.learnUserCount) }}人正在学</text> </view> </view> </view> </view> </view> </view> <!-- Articles Section --> <view class="article-grid"> <view class="article-card"> <image src="/static/image/home/zixun.png" mode="aspectFill" class="article-bg"></image> <view class="article-title">资讯标题资讯标题资讯标...</view> </view> <view class="article-card"> <image src="/static/image/home/zixun.png" mode="aspectFill" class="article-bg"></image> <view class="article-title">资讯标题资讯标题资讯标...</view> <!-- Articles Section --> <view v-if="featuredPopularizationList.length" class="article-grid"> <view v-for="item in featuredPopularizationList" :key="item.id" class="article-card" @tap="goPopularizationDetail(item.id)"> <image :src="item.coverImage || defaultPopularizationCover" mode="aspectFill" class="article-bg"></image> <view class="article-title">{{ item.title }}</view> </view> </view> </view> </view> @@ -127,7 +106,7 @@ import UpdatePopup from "@/components/updatePopup.vue"; import { ref, onMounted, computed } from "vue"; import { onPageScroll, onShow } from "@dcloudio/uni-app"; import { getBannerList, getHomeLiveCourseList } from "./service"; import { getBannerList, getHomeFeaturedContent, getHomeLiveCourseList } from "./service"; interface HomeBannerItem { id: number | string; @@ -149,13 +128,40 @@ nextStartTime?: string; } interface HomeFeaturedHealthCourseItem { id: number | string; title: string; coverImage?: string; levelName?: string; difficultyName?: string; salePrice?: number | string; learnUserCount?: number; } interface HomeFeaturedPopularizationItem { id: number | string; title: string; coverImage?: string; contentType?: number; clickCount?: number; } interface HomeFeaturedContentResponse { healthCourses?: HomeFeaturedHealthCourseItem[]; healthPopularizations?: HomeFeaturedPopularizationItem[]; } const statusBarHeight = ref(0); const scrollY = ref(0); const showUpdatePopup = ref(false); const bannerList = ref<HomeBannerItem[]>([]); const bannerActiveIndex = ref(0); const liveCourseList = ref<HomeLiveCourseItem[]>([]); const featuredHealthCourseList = ref<HomeFeaturedHealthCourseItem[]>([]); const featuredPopularizationList = ref<HomeFeaturedPopularizationItem[]>([]); const defaultLiveCover = '/static/image/home/class.png'; const defaultCourseCover = '/static/image/home/class.png'; const defaultPopularizationCover = '/static/image/home/zixun.png'; /** * 初始化导航栏所需系统信息。 @@ -174,6 +180,7 @@ onShow(() => { loadBannerList(); loadHomeLiveCourseList(); loadFeaturedContent(); }); /** @@ -236,6 +243,65 @@ } catch { liveCourseList.value = []; } }; /** * 请求首页精选课程介绍模块数据。 */ const loadFeaturedContent = async () => { try { const res = await getHomeFeaturedContent(); if (res?.code !== 0 || !res.data) { featuredHealthCourseList.value = []; featuredPopularizationList.value = []; return; } const responseData = res.data as HomeFeaturedContentResponse; featuredHealthCourseList.value = Array.isArray(responseData.healthCourses) ? responseData.healthCourses.filter((item: HomeFeaturedHealthCourseItem) => !!item?.id) : []; featuredPopularizationList.value = Array.isArray(responseData.healthPopularizations) ? responseData.healthPopularizations.filter((item: HomeFeaturedPopularizationItem) => !!item?.id) : []; } catch { featuredHealthCourseList.value = []; featuredPopularizationList.value = []; } }; /** * 将课程价格格式化为首页展示文案。 */ const formatPrice = (price?: number | string) => { const numericPrice = Number(price || 0); if (!Number.isFinite(numericPrice)) { return '0.00'; } return numericPrice.toFixed(2); }; /** * 将学习人数格式化为首页展示文案。 */ const formatLearningCount = (count?: number) => { const safeCount = typeof count === 'number' && count > 0 ? count : 0; if (safeCount >= 10000) { return `${(Math.round(safeCount / 100) / 100).toFixed(2)}W`; } return `${safeCount}`; }; /** * 解析首页养生课程卡片标签,优先展示等级,其次展示难度。 */ const resolveCourseTag = (item: HomeFeaturedHealthCourseItem) => { if (item.levelName) { return item.levelName; } if (item.difficultyName) { return item.difficultyName; } return ''; }; /** @@ -350,6 +416,26 @@ }; /** * 跳转养生课程详情页。 */ const goCourseDetail = (courseId?: number | string | null) => { uni.navigateTo({ url: courseId ? `/pages/courseDetail/courseDetail?id=${courseId}` : '/pages/courseDetail/courseDetail' }); }; /** * 跳转健康科普详情页。 */ const goPopularizationDetail = (popularizationId?: number | string | null) => { uni.navigateTo({ url: popularizationId ? `/pages/articleDetail/articleDetail?id=${popularizationId}` : '/pages/articleDetail/articleDetail' }); }; /** * 跳转搜索页。 */ const goSearch = () => { traditionalApp/pages/home/service.js
@@ -15,6 +15,13 @@ } /** * 获取 APP 首页精选课程介绍模块数据。 */ export const getHomeFeaturedContent = () => { return request.get('/api/app/home/featured-content', {}, false, false) } /** * 获取首页攻略列表。 */ export const getGuideList = (params) => {