“mitao”
2026-03-24 1c77e3491aaaffec3ada7721c231d72ead578588
feat:课程分类管理、养身课程
34个文件已添加
3534 ■■■■■ 已修改文件
backend/src/main/java/com/tcm/training/admin/live/controller/HealthCourseController.java 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/controller/LiveCourseCategoryController.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseSaveRequest.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseStatusRequest.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseVideoSaveRequest.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/dto/LiveCourseCategorySaveRequest.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/entity/HealthCourse.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/entity/HealthCourseVideo.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/entity/LiveCourseCategory.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/excel/HealthCourseExportRow.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseMapper.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseVideoMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/mapper/LiveCourseCategoryMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java 461 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/service/LiveCourseCategoryAppService.java 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseDetailRow.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseDetailVO.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListItemVO.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListRow.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseStatusVO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseVideoDetailVO.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseVideoItemVO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/vo/LiveCourseCategoryDetailVO.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/vo/LiveCourseCategoryItemVO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/mapper/HealthCourseMapper.xml 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/category/index.tsx 273 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/category/service.ts 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/category/style.less 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health/detail.tsx 360 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health/index.tsx 313 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health/manage.tsx 302 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health/service.ts 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health/style.less 337 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health/video-manage.tsx 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/live/controller/HealthCourseController.java
New file
@@ -0,0 +1,120 @@
package com.tcm.training.admin.live.controller;
import com.tcm.training.admin.common.ApiResponse;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.live.dto.HealthCourseSaveRequest;
import com.tcm.training.admin.live.dto.HealthCourseStatusRequest;
import com.tcm.training.admin.live.dto.HealthCourseVideoSaveRequest;
import com.tcm.training.admin.live.service.HealthCourseAppService;
import com.tcm.training.admin.live.vo.HealthCourseDetailVO;
import com.tcm.training.admin.live.vo.HealthCourseListItemVO;
import com.tcm.training.admin.live.vo.HealthCourseStatusVO;
import com.tcm.training.admin.live.vo.HealthCourseVideoDetailVO;
import com.tcm.training.admin.live.vo.HealthCourseVideoItemVO;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.io.IOException;
@Validated
@RestController
@RequestMapping({"/api/v1", "/api"})
public class HealthCourseController {
    private final HealthCourseAppService healthCourseAppService;
    public HealthCourseController(HealthCourseAppService healthCourseAppService) {
        this.healthCourseAppService = healthCourseAppService;
    }
    @GetMapping("/health-courses")
    public ApiResponse<PageResponse<HealthCourseListItemVO>> page(@RequestParam(required = false, defaultValue = "1") int page,
                                                                  @RequestParam(required = false, defaultValue = "10") int pageSize,
                                                                  @RequestParam(required = false) String keyword,
                                                                  @RequestParam(required = false) Long levelCategoryId,
                                                                  @RequestParam(required = false) Long difficultyCategoryId,
                                                                  @RequestParam(required = false) Integer status) {
        return ApiResponse.ok(healthCourseAppService.page(page, pageSize, keyword, levelCategoryId, difficultyCategoryId, status));
    }
    @GetMapping("/health-courses/export")
    public void export(HttpServletResponse response,
                       @RequestParam(required = false) String keyword,
                       @RequestParam(required = false) Long levelCategoryId,
                       @RequestParam(required = false) Long difficultyCategoryId,
                       @RequestParam(required = false) Integer status) throws IOException {
        healthCourseAppService.export(response, keyword, levelCategoryId, difficultyCategoryId, status);
    }
    @PostMapping("/health-courses")
    public ApiResponse<HealthCourseDetailVO> create(@Valid @RequestBody HealthCourseSaveRequest req) {
        return ApiResponse.ok(healthCourseAppService.create(req));
    }
    @GetMapping("/health-courses/{id}")
    public ApiResponse<HealthCourseDetailVO> detail(@PathVariable("id") Long id) {
        return ApiResponse.ok(healthCourseAppService.detail(id));
    }
    @PutMapping("/health-courses/{id}")
    public ApiResponse<HealthCourseDetailVO> update(@PathVariable("id") Long id, @Valid @RequestBody HealthCourseSaveRequest req) {
        return ApiResponse.ok(healthCourseAppService.update(id, req));
    }
    @PatchMapping("/health-courses/{id}/status")
    public ApiResponse<HealthCourseStatusVO> patchStatus(@PathVariable("id") Long id, @Valid @RequestBody HealthCourseStatusRequest req) {
        return ApiResponse.ok(healthCourseAppService.patchStatus(id, req));
    }
    @DeleteMapping("/health-courses/{id}")
    public ApiResponse<Void> delete(@PathVariable("id") Long id) {
        healthCourseAppService.delete(id);
        return ApiResponse.ok(null);
    }
    @GetMapping("/health-courses/{courseId}/videos")
    public ApiResponse<PageResponse<HealthCourseVideoItemVO>> pageVideos(@PathVariable("courseId") Long courseId,
                                                                         @RequestParam(required = false, defaultValue = "1") int page,
                                                                         @RequestParam(required = false, defaultValue = "10") int pageSize,
                                                                         @RequestParam(required = false) String chapterName,
                                                                         @RequestParam(required = false) Integer saleType) {
        return ApiResponse.ok(healthCourseAppService.pageVideos(courseId, page, pageSize, chapterName, saleType));
    }
    @GetMapping("/health-courses/{courseId}/videos/{videoId}")
    public ApiResponse<HealthCourseVideoDetailVO> detailVideo(@PathVariable("courseId") Long courseId,
                                                              @PathVariable("videoId") Long videoId) {
        return ApiResponse.ok(healthCourseAppService.detailVideo(courseId, videoId));
    }
    @PostMapping("/health-courses/{courseId}/videos")
    public ApiResponse<HealthCourseVideoDetailVO> createVideo(@PathVariable("courseId") Long courseId,
                                                              @Valid @RequestBody HealthCourseVideoSaveRequest req) {
        return ApiResponse.ok(healthCourseAppService.createVideo(courseId, req));
    }
    @PutMapping("/health-courses/{courseId}/videos/{videoId}")
    public ApiResponse<HealthCourseVideoDetailVO> updateVideo(@PathVariable("courseId") Long courseId,
                                                              @PathVariable("videoId") Long videoId,
                                                              @Valid @RequestBody HealthCourseVideoSaveRequest req) {
        return ApiResponse.ok(healthCourseAppService.updateVideo(courseId, videoId, req));
    }
    @DeleteMapping("/health-courses/{courseId}/videos/{videoId}")
    public ApiResponse<Void> deleteVideo(@PathVariable("courseId") Long courseId,
                                         @PathVariable("videoId") Long videoId) {
        healthCourseAppService.deleteVideo(courseId, videoId);
        return ApiResponse.ok(null);
    }
}
backend/src/main/java/com/tcm/training/admin/live/controller/LiveCourseCategoryController.java
New file
@@ -0,0 +1,62 @@
package com.tcm.training.admin.live.controller;
import com.tcm.training.admin.common.ApiResponse;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.live.dto.LiveCourseCategorySaveRequest;
import com.tcm.training.admin.live.service.LiveCourseCategoryAppService;
import com.tcm.training.admin.live.vo.LiveCourseCategoryDetailVO;
import com.tcm.training.admin.live.vo.LiveCourseCategoryItemVO;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
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;
@Validated
@RestController
@RequestMapping({"/api/v1", "/api"})
public class LiveCourseCategoryController {
    private final LiveCourseCategoryAppService liveCourseCategoryAppService;
    public LiveCourseCategoryController(LiveCourseCategoryAppService liveCourseCategoryAppService) {
        this.liveCourseCategoryAppService = liveCourseCategoryAppService;
    }
    @GetMapping("/live-course-categories")
    public ApiResponse<PageResponse<LiveCourseCategoryItemVO>> page(@RequestParam(required = false, defaultValue = "1") int page,
                                                                    @RequestParam(required = false, defaultValue = "10") int pageSize,
                                                                    @RequestParam String type) {
        return ApiResponse.ok(liveCourseCategoryAppService.page(page, pageSize, type));
    }
    @GetMapping("/live-course-categories/{id}")
    public ApiResponse<LiveCourseCategoryDetailVO> detail(@PathVariable("id") Long id) {
        return ApiResponse.ok(liveCourseCategoryAppService.detail(id));
    }
    @PostMapping("/live-course-categories")
    public ApiResponse<LiveCourseCategoryDetailVO> create(@RequestParam String type,
                                                          @Valid @RequestBody LiveCourseCategorySaveRequest req) {
        return ApiResponse.ok(liveCourseCategoryAppService.create(type, req));
    }
    @PutMapping("/live-course-categories/{id}")
    public ApiResponse<LiveCourseCategoryDetailVO> update(@PathVariable("id") Long id,
                                                          @Valid @RequestBody LiveCourseCategorySaveRequest req) {
        return ApiResponse.ok(liveCourseCategoryAppService.update(id, req));
    }
    @DeleteMapping("/live-course-categories/{id}")
    public ApiResponse<Void> delete(@PathVariable("id") Long id) {
        liveCourseCategoryAppService.delete(id);
        return ApiResponse.ok(null);
    }
}
backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseSaveRequest.java
New file
@@ -0,0 +1,61 @@
package com.tcm.training.admin.live.dto;
import com.tcm.training.admin.common.xss.XssSafeHtml;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
@Getter
@Setter
@NoArgsConstructor
public class HealthCourseSaveRequest {
    @NotBlank(message = "请输入课程名称")
    @Size(max = 128, message = "课程名称长度不能超过128个字符")
    private String title;
    @NotNull(message = "请选择养身等级")
    private Long levelCategoryId;
    @NotNull(message = "请选择难度")
    private Long difficultyCategoryId;
    @NotNull(message = "请选择授课专家")
    private Long expertId;
    @NotNull(message = "请输入课程售价")
    @DecimalMin(value = "0.00", message = "课程售价不能小于0")
    private BigDecimal salePrice;
    @Size(max = 500, message = "课程简介长度不能超过500个字符")
    private String summary;
    @Size(max = 255, message = "适用人群长度不能超过255个字符")
    private String targetUsers;
    @NotBlank(message = "请上传课程封面图")
    private String coverImg;
    @NotNull(message = "请上传课程宣传图")
    private List<String> bannerImgs;
    @NotBlank(message = "请输入课程介绍")
    @XssSafeHtml
    private String content;
    @NotNull(message = "请输入基础购买量")
    private Integer baseSalesCount;
    @NotNull(message = "请选择是否启用")
    private Integer status;
    @NotNull(message = "请选择是否首页推荐")
    private Integer isHomeRecommend;
}
backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseStatusRequest.java
New file
@@ -0,0 +1,16 @@
package com.tcm.training.admin.live.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.validation.constraints.NotNull;
@Getter
@Setter
@NoArgsConstructor
public class HealthCourseStatusRequest {
    @NotNull(message = "请选择状态")
    private Integer status;
}
backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseVideoSaveRequest.java
New file
@@ -0,0 +1,46 @@
package com.tcm.training.admin.live.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.math.BigDecimal;
@Getter
@Setter
@NoArgsConstructor
public class HealthCourseVideoSaveRequest {
    @NotBlank(message = "请输入小节名称")
    @Size(max = 128, message = "小节名称长度不能超过128个字符")
    private String chapterName;
    @Size(max = 500, message = "小节备注长度不能超过500个字符")
    private String chapterRemark;
    @NotBlank(message = "请输入视频地址")
    private String videoUrl;
    @NotNull(message = "请输入视频时长")
    @Min(value = 0, message = "视频时长不能小于0")
    private Integer durationSeconds;
    @NotNull(message = "请选择售价类型")
    private Integer saleType;
    @NotNull(message = "请输入售价")
    @DecimalMin(value = "0.00", message = "售价不能小于0")
    private BigDecimal salePrice;
    @NotNull(message = "请输入播放次数")
    @Min(value = 0, message = "播放次数不能小于0")
    private Integer playCount;
    @NotNull(message = "请选择状态")
    private Integer status;
}
backend/src/main/java/com/tcm/training/admin/live/dto/LiveCourseCategorySaveRequest.java
New file
@@ -0,0 +1,22 @@
package com.tcm.training.admin.live.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Getter
@Setter
@NoArgsConstructor
public class LiveCourseCategorySaveRequest {
    @NotBlank(message = "请输入分类名称")
    @Size(max = 5, message = "分类名称长度不能超过5个字符")
    private String name;
    @Min(value = 0, message = "排序不能小于0")
    private Integer sort;
}
backend/src/main/java/com/tcm/training/admin/live/entity/HealthCourse.java
New file
@@ -0,0 +1,93 @@
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 com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@TableName(value = "tcm_health_course", autoResultMap = true)
@Getter
@Setter
@NoArgsConstructor
public class HealthCourse {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String title;
    @TableField("level_category_id")
    private Long levelCategoryId;
    @TableField("difficulty_category_id")
    private Long difficultyCategoryId;
    @TableField("expert_id")
    private Long expertId;
    @TableField("sale_price")
    private BigDecimal salePrice;
    private String summary;
    @TableField("target_users")
    private String targetUsers;
    @TableField("cover_img")
    private String coverImg;
    @TableField(value = "banner_imgs", typeHandler = JacksonTypeHandler.class)
    private List<String> bannerImgs;
    private String content;
    @TableField("base_sales_count")
    private Integer baseSalesCount;
    @TableField("real_sales_count")
    private Integer realSalesCount;
    @TableField("learn_user_count")
    private Integer learnUserCount;
    @TableField("favorite_count")
    private Integer favoriteCount;
    @TableField("total_duration_seconds")
    private Integer totalDurationSeconds;
    @TableField("video_count")
    private Integer videoCount;
    private Integer status;
    @TableField("is_home_recommend")
    private Integer isHomeRecommend;
    private Integer sort;
    @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;
}
backend/src/main/java/com/tcm/training/admin/live/entity/HealthCourseVideo.java
New file
@@ -0,0 +1,67 @@
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.math.BigDecimal;
import java.time.LocalDateTime;
@TableName("tcm_health_course_video")
@Getter
@Setter
@NoArgsConstructor
public class HealthCourseVideo {
    @TableId(type = IdType.AUTO)
    private Long id;
    @TableField("course_id")
    private Long courseId;
    @TableField("chapter_name")
    private String chapterName;
    @TableField("chapter_remark")
    private String chapterRemark;
    @TableField("video_url")
    private String videoUrl;
    @TableField("duration_seconds")
    private Integer durationSeconds;
    @TableField("sale_type")
    private Integer saleType;
    @TableField("sale_price")
    private BigDecimal salePrice;
    @TableField("play_count")
    private Integer playCount;
    private Integer sort;
    private Integer status;
    @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;
}
backend/src/main/java/com/tcm/training/admin/live/entity/LiveCourseCategory.java
New file
@@ -0,0 +1,44 @@
package com.tcm.training.admin.live.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
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 com.baomidou.mybatisplus.annotation.IdType;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@TableName("tcm_live_course_category")
@Getter
@Setter
@NoArgsConstructor
public class LiveCourseCategory {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String type;
    private String name;
    private Integer sort;
    @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;
}
backend/src/main/java/com/tcm/training/admin/live/excel/HealthCourseExportRow.java
New file
@@ -0,0 +1,44 @@
package com.tcm.training.admin.live.excel;
import cn.idev.excel.annotation.ExcelProperty;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
public class HealthCourseExportRow {
    @ExcelProperty("序号")
    private Integer serialNo;
    @ExcelProperty("课程名称")
    private String title;
    @ExcelProperty("养身等级")
    private String levelName;
    @ExcelProperty("难度")
    private String difficultyName;
    @ExcelProperty("售价")
    private BigDecimal salePrice;
    @ExcelProperty("销量")
    private Integer salesCount;
    @ExcelProperty("学习人数")
    private Integer learnUserCount;
    @ExcelProperty("共计课时")
    private String totalDuration;
    @ExcelProperty("收藏量")
    private Integer favoriteCount;
    @ExcelProperty("授课专家")
    private String expertName;
    @ExcelProperty("状态")
    private String statusText;
}
backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseMapper.java
New file
@@ -0,0 +1,28 @@
package com.tcm.training.admin.live.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.tcm.training.admin.live.entity.HealthCourse;
import com.tcm.training.admin.live.vo.HealthCourseDetailRow;
import com.tcm.training.admin.live.vo.HealthCourseListRow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface HealthCourseMapper extends BaseMapper<HealthCourse> {
    IPage<HealthCourseListRow> selectCoursePage(IPage<HealthCourseListRow> page,
                                                @Param("keyword") String keyword,
                                                @Param("levelCategoryId") Long levelCategoryId,
                                                @Param("difficultyCategoryId") Long difficultyCategoryId,
                                                @Param("status") Integer status);
    List<HealthCourseListRow> selectCourseList(@Param("keyword") String keyword,
                                               @Param("levelCategoryId") Long levelCategoryId,
                                               @Param("difficultyCategoryId") Long difficultyCategoryId,
                                               @Param("status") Integer status);
    HealthCourseDetailRow selectCourseDetail(@Param("id") Long id);
}
backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseVideoMapper.java
New file
@@ -0,0 +1,9 @@
package com.tcm.training.admin.live.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tcm.training.admin.live.entity.HealthCourseVideo;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HealthCourseVideoMapper extends BaseMapper<HealthCourseVideo> {
}
backend/src/main/java/com/tcm/training/admin/live/mapper/LiveCourseCategoryMapper.java
New file
@@ -0,0 +1,9 @@
package com.tcm.training.admin.live.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tcm.training.admin.live.entity.LiveCourseCategory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LiveCourseCategoryMapper extends BaseMapper<LiveCourseCategory> {
}
backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java
New file
@@ -0,0 +1,461 @@
package com.tcm.training.admin.live.service;
import cn.idev.excel.FastExcel;
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.common.Jsons;
import com.tcm.training.admin.common.PageResponse;
import com.tcm.training.admin.live.dto.HealthCourseSaveRequest;
import com.tcm.training.admin.live.dto.HealthCourseStatusRequest;
import com.tcm.training.admin.live.dto.HealthCourseVideoSaveRequest;
import com.tcm.training.admin.live.entity.Expert;
import com.tcm.training.admin.live.entity.HealthCourse;
import com.tcm.training.admin.live.entity.HealthCourseVideo;
import com.tcm.training.admin.live.entity.LiveCourseCategory;
import com.tcm.training.admin.live.excel.HealthCourseExportRow;
import com.tcm.training.admin.live.mapper.ExpertMapper;
import com.tcm.training.admin.live.mapper.HealthCourseMapper;
import com.tcm.training.admin.live.mapper.HealthCourseVideoMapper;
import com.tcm.training.admin.live.mapper.LiveCourseCategoryMapper;
import com.tcm.training.admin.live.vo.ExpertVO;
import com.tcm.training.admin.live.vo.HealthCourseDetailRow;
import com.tcm.training.admin.live.vo.HealthCourseDetailVO;
import com.tcm.training.admin.live.vo.HealthCourseListItemVO;
import com.tcm.training.admin.live.vo.HealthCourseListRow;
import com.tcm.training.admin.live.vo.HealthCourseStatusVO;
import com.tcm.training.admin.live.vo.HealthCourseVideoDetailVO;
import com.tcm.training.admin.live.vo.HealthCourseVideoItemVO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Service
public class HealthCourseAppService {
    private final HealthCourseMapper healthCourseMapper;
    private final HealthCourseVideoMapper healthCourseVideoMapper;
    private final LiveCourseCategoryMapper liveCourseCategoryMapper;
    private final ExpertMapper expertMapper;
    public HealthCourseAppService(HealthCourseMapper healthCourseMapper,
                                  HealthCourseVideoMapper healthCourseVideoMapper,
                                  LiveCourseCategoryMapper liveCourseCategoryMapper,
                                  ExpertMapper expertMapper) {
        this.healthCourseMapper = healthCourseMapper;
        this.healthCourseVideoMapper = healthCourseVideoMapper;
        this.liveCourseCategoryMapper = liveCourseCategoryMapper;
        this.expertMapper = expertMapper;
    }
    public PageResponse<HealthCourseListItemVO> page(int page,
                                                     int pageSize,
                                                     String keyword,
                                                     Long levelCategoryId,
                                                     Long difficultyCategoryId,
                                                     Integer status) {
        IPage<HealthCourseListRow> result = healthCourseMapper.selectCoursePage(
                new Page<HealthCourseListRow>(page, pageSize),
                trimToNull(keyword),
                levelCategoryId,
                difficultyCategoryId,
                status
        );
        List<HealthCourseListItemVO> list = new ArrayList<HealthCourseListItemVO>();
        for (HealthCourseListRow row : result.getRecords()) {
            list.add(toCourseListItem(row));
        }
        return PageResponse.of(list, result.getTotal(), result.getCurrent(), result.getSize());
    }
    public HealthCourseDetailVO detail(Long id) {
        HealthCourseDetailRow row = healthCourseMapper.selectCourseDetail(id);
        if (row == null) {
            throw new IllegalArgumentException("课程不存在");
        }
        return toCourseDetail(row);
    }
    @Transactional
    public HealthCourseDetailVO create(HealthCourseSaveRequest req) {
        validateCategories(req.getLevelCategoryId(), req.getDifficultyCategoryId());
        validateExpert(req.getExpertId());
        HealthCourse course = new HealthCourse();
        fillCourse(course, req);
        course.setRealSalesCount(0);
        course.setLearnUserCount(0);
        course.setFavoriteCount(0);
        course.setTotalDurationSeconds(0);
        course.setVideoCount(0);
        course.setSort(0);
        course.setDeleted(0);
        healthCourseMapper.insert(course);
        return detail(course.getId());
    }
    @Transactional
    public HealthCourseDetailVO update(Long id, HealthCourseSaveRequest req) {
        validateCategories(req.getLevelCategoryId(), req.getDifficultyCategoryId());
        validateExpert(req.getExpertId());
        HealthCourse course = requireCourse(id);
        fillCourse(course, req);
        healthCourseMapper.updateById(course);
        return detail(id);
    }
    @Transactional
    public HealthCourseStatusVO patchStatus(Long id, HealthCourseStatusRequest req) {
        HealthCourse course = requireCourse(id);
        course.setStatus(req.getStatus());
        healthCourseMapper.updateById(course);
        return new HealthCourseStatusVO(id, req.getStatus());
    }
    @Transactional
    public void delete(Long id) {
        requireCourse(id);
        List<HealthCourseVideo> videos = healthCourseVideoMapper.selectList(new LambdaQueryWrapper<HealthCourseVideo>()
                .eq(HealthCourseVideo::getCourseId, id)
                .eq(HealthCourseVideo::getDeleted, 0));
        for (HealthCourseVideo video : videos) {
            healthCourseVideoMapper.deleteById(video.getId());
        }
        healthCourseMapper.deleteById(id);
    }
    public void export(HttpServletResponse response,
                       String keyword,
                       Long levelCategoryId,
                       Long difficultyCategoryId,
                       Integer status) throws IOException {
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        String filename = URLEncoder.encode("养身课程", "UTF-8").replace("+", "%20");
        response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + filename + ".xlsx");
        List<HealthCourseListRow> rows = healthCourseMapper.selectCourseList(
                trimToNull(keyword),
                levelCategoryId,
                difficultyCategoryId,
                status
        );
        List<HealthCourseExportRow> exports = new ArrayList<HealthCourseExportRow>();
        for (int i = 0; i < rows.size(); i++) {
            HealthCourseListRow row = rows.get(i);
            HealthCourseExportRow export = new HealthCourseExportRow();
            export.setSerialNo(i + 1);
            export.setTitle(defaultText(row.getTitle()));
            export.setLevelName(defaultText(row.getLevelName()));
            export.setDifficultyName(defaultText(row.getDifficultyName()));
            export.setSalePrice(row.getSalePrice() == null ? BigDecimal.ZERO : row.getSalePrice());
            export.setSalesCount(resolveSalesCount(row.getBaseSalesCount(), row.getRealSalesCount()));
            export.setLearnUserCount(defaultNumber(row.getLearnUserCount()));
            export.setTotalDuration(formatDuration(defaultNumber(row.getTotalDurationSeconds())));
            export.setFavoriteCount(defaultNumber(row.getFavoriteCount()));
            export.setExpertName(defaultText(row.getExpertName()));
            export.setStatusText(statusText(row.getStatus()));
            exports.add(export);
        }
        FastExcel.write(response.getOutputStream(), HealthCourseExportRow.class)
                .sheet("养身课程")
                .doWrite(exports);
    }
    public PageResponse<HealthCourseVideoItemVO> pageVideos(Long courseId,
                                                            int page,
                                                            int pageSize,
                                                            String chapterName,
                                                            Integer saleType) {
        requireCourse(courseId);
        LambdaQueryWrapper<HealthCourseVideo> qw = new LambdaQueryWrapper<HealthCourseVideo>()
                .eq(HealthCourseVideo::getCourseId, courseId)
                .eq(HealthCourseVideo::getDeleted, 0)
                .orderByAsc(HealthCourseVideo::getSort)
                .orderByAsc(HealthCourseVideo::getId);
        if (chapterName != null && !chapterName.trim().isEmpty()) {
            qw.like(HealthCourseVideo::getChapterName, chapterName.trim());
        }
        if (saleType != null) {
            qw.eq(HealthCourseVideo::getSaleType, saleType);
        }
        IPage<HealthCourseVideo> result = healthCourseVideoMapper.selectPage(new Page<HealthCourseVideo>(page, pageSize), qw);
        List<HealthCourseVideoItemVO> list = new ArrayList<HealthCourseVideoItemVO>();
        for (HealthCourseVideo item : result.getRecords()) {
            list.add(toVideoItem(item));
        }
        return PageResponse.of(list, result.getTotal(), result.getCurrent(), result.getSize());
    }
    public HealthCourseVideoDetailVO detailVideo(Long courseId, Long videoId) {
        return toVideoDetail(requireVideo(courseId, videoId));
    }
    @Transactional
    public HealthCourseVideoDetailVO createVideo(Long courseId, HealthCourseVideoSaveRequest req) {
        requireCourse(courseId);
        validateVideoSale(req);
        HealthCourseVideo next = new HealthCourseVideo();
        next.setCourseId(courseId);
        fillVideo(next, req);
        next.setSort(resolveNextVideoSort(courseId));
        next.setDeleted(0);
        healthCourseVideoMapper.insert(next);
        refreshCourseStats(courseId);
        return detailVideo(courseId, next.getId());
    }
    @Transactional
    public HealthCourseVideoDetailVO updateVideo(Long courseId, Long videoId, HealthCourseVideoSaveRequest req) {
        validateVideoSale(req);
        HealthCourseVideo video = requireVideo(courseId, videoId);
        fillVideo(video, req);
        healthCourseVideoMapper.updateById(video);
        refreshCourseStats(courseId);
        return detailVideo(courseId, videoId);
    }
    @Transactional
    public void deleteVideo(Long courseId, Long videoId) {
        requireVideo(courseId, videoId);
        healthCourseVideoMapper.deleteById(videoId);
        refreshCourseStats(courseId);
    }
    private void fillCourse(HealthCourse course, HealthCourseSaveRequest req) {
        course.setTitle(req.getTitle().trim());
        course.setLevelCategoryId(req.getLevelCategoryId());
        course.setDifficultyCategoryId(req.getDifficultyCategoryId());
        course.setExpertId(req.getExpertId());
        course.setSalePrice(req.getSalePrice());
        course.setSummary(trimToNull(req.getSummary()));
        course.setTargetUsers(trimToNull(req.getTargetUsers()));
        course.setCoverImg(req.getCoverImg().trim());
        course.setBannerImgs(req.getBannerImgs());
        course.setContent(req.getContent().trim());
        course.setBaseSalesCount(req.getBaseSalesCount() == null ? 0 : req.getBaseSalesCount());
        course.setStatus(req.getStatus());
        course.setIsHomeRecommend(req.getIsHomeRecommend());
    }
    private void fillVideo(HealthCourseVideo video, HealthCourseVideoSaveRequest req) {
        video.setChapterName(req.getChapterName().trim());
        video.setChapterRemark(trimToNull(req.getChapterRemark()));
        video.setVideoUrl(req.getVideoUrl().trim());
        video.setDurationSeconds(req.getDurationSeconds());
        video.setSaleType(req.getSaleType());
        video.setSalePrice(req.getSaleType() != null && req.getSaleType() == 0 ? BigDecimal.ZERO : req.getSalePrice());
        video.setPlayCount(req.getPlayCount());
        video.setStatus(req.getStatus());
    }
    private void validateCategories(Long levelCategoryId, Long difficultyCategoryId) {
        LiveCourseCategory level = requireCategory(levelCategoryId, LiveCourseCategoryAppService.TYPE_LEVEL, "养身等级");
        LiveCourseCategory difficulty = requireCategory(difficultyCategoryId, LiveCourseCategoryAppService.TYPE_DIFFICULTY, "难度");
        if (level == null || difficulty == null) {
            throw new IllegalArgumentException("分类不存在");
        }
    }
    private LiveCourseCategory requireCategory(Long id, String type, String label) {
        LiveCourseCategory category = liveCourseCategoryMapper.selectOne(new LambdaQueryWrapper<LiveCourseCategory>()
                .eq(LiveCourseCategory::getId, id)
                .eq(LiveCourseCategory::getType, type)
                .eq(LiveCourseCategory::getDeleted, 0)
                .last("limit 1"));
        if (category == null) {
            throw new IllegalArgumentException(label + "不存在");
        }
        return category;
    }
    private void validateExpert(Long expertId) {
        Expert expert = expertMapper.selectOne(new LambdaQueryWrapper<Expert>()
                .eq(Expert::getId, expertId)
                .eq(Expert::getDeleted, 0)
                .last("limit 1"));
        if (expert == null) {
            throw new IllegalArgumentException("授课专家不存在");
        }
        if (expert.getStatus() == null || expert.getStatus() != 1) {
            throw new IllegalArgumentException("授课专家已禁用");
        }
    }
    private void validateVideoSale(HealthCourseVideoSaveRequest req) {
        if (req.getSaleType() == null || (req.getSaleType() != 0 && req.getSaleType() != 1)) {
            throw new IllegalArgumentException("售价类型不合法");
        }
        if (req.getSaleType() == 0 && req.getSalePrice() != null && req.getSalePrice().compareTo(BigDecimal.ZERO) != 0) {
            throw new IllegalArgumentException("免费课程售价必须为0");
        }
    }
    private int resolveNextVideoSort(Long courseId) {
        HealthCourseVideo latest = healthCourseVideoMapper.selectOne(new LambdaQueryWrapper<HealthCourseVideo>()
                .eq(HealthCourseVideo::getCourseId, courseId)
                .eq(HealthCourseVideo::getDeleted, 0)
                .orderByDesc(HealthCourseVideo::getSort)
                .last("limit 1"));
        if (latest == null || latest.getSort() == null) {
            return 10;
        }
        return latest.getSort() + 10;
    }
    private void refreshCourseStats(Long courseId) {
        HealthCourse course = requireCourse(courseId);
        List<HealthCourseVideo> videos = healthCourseVideoMapper.selectList(new LambdaQueryWrapper<HealthCourseVideo>()
                .eq(HealthCourseVideo::getCourseId, courseId)
                .eq(HealthCourseVideo::getDeleted, 0));
        int totalDurationSeconds = 0;
        for (HealthCourseVideo video : videos) {
            totalDurationSeconds += video.getDurationSeconds() == null ? 0 : video.getDurationSeconds();
        }
        course.setVideoCount(videos.size());
        course.setTotalDurationSeconds(totalDurationSeconds);
        healthCourseMapper.updateById(course);
    }
    private HealthCourse requireCourse(Long id) {
        HealthCourse course = healthCourseMapper.selectById(id);
        if (course == null) {
            throw new IllegalArgumentException("课程不存在");
        }
        return course;
    }
    private HealthCourseVideo requireVideo(Long courseId, Long videoId) {
        requireCourse(courseId);
        HealthCourseVideo video = healthCourseVideoMapper.selectById(videoId);
        if (video == null) {
            throw new IllegalArgumentException("视频课程不存在");
        }
        if (!courseId.equals(video.getCourseId())) {
            throw new IllegalArgumentException("视频课程不属于当前课程");
        }
        return video;
    }
    private HealthCourseListItemVO toCourseListItem(HealthCourseListRow row) {
        HealthCourseListItemVO vo = new HealthCourseListItemVO();
        vo.setId(row.getId());
        vo.setTitle(row.getTitle());
        vo.setLevelName(row.getLevelName());
        vo.setDifficultyName(row.getDifficultyName());
        vo.setSalePrice(row.getSalePrice() == null ? BigDecimal.ZERO : row.getSalePrice());
        vo.setSalesCount(resolveSalesCount(row.getBaseSalesCount(), row.getRealSalesCount()));
        vo.setLearnUserCount(defaultNumber(row.getLearnUserCount()));
        vo.setTotalDurationSeconds(defaultNumber(row.getTotalDurationSeconds()));
        vo.setFavoriteCount(defaultNumber(row.getFavoriteCount()));
        vo.setExpert(new ExpertVO(row.getExpertId(), row.getExpertName(), row.getExpertAvatar()));
        vo.setCoverImg(row.getCoverImg());
        vo.setStatus(row.getStatus());
        vo.setIsHomeRecommend(row.getIsHomeRecommend());
        vo.setCreatedAt(row.getCreatedAt());
        return vo;
    }
    private HealthCourseDetailVO toCourseDetail(HealthCourseDetailRow row) {
        HealthCourseDetailVO vo = new HealthCourseDetailVO();
        vo.setId(row.getId());
        vo.setTitle(row.getTitle());
        vo.setLevelCategoryId(row.getLevelCategoryId());
        vo.setLevelName(row.getLevelName());
        vo.setDifficultyCategoryId(row.getDifficultyCategoryId());
        vo.setDifficultyName(row.getDifficultyName());
        vo.setExpert(new ExpertVO(row.getExpertId(), row.getExpertName(), row.getExpertAvatar()));
        vo.setSalePrice(row.getSalePrice() == null ? BigDecimal.ZERO : row.getSalePrice());
        vo.setSummary(row.getSummary());
        vo.setTargetUsers(row.getTargetUsers());
        vo.setCoverImg(row.getCoverImg());
        vo.setBannerImgs(Jsons.parseStringList(row.getBannerImgs()));
        vo.setContent(row.getContent());
        vo.setBaseSalesCount(defaultNumber(row.getBaseSalesCount()));
        vo.setRealSalesCount(defaultNumber(row.getRealSalesCount()));
        vo.setSalesCount(resolveSalesCount(row.getBaseSalesCount(), row.getRealSalesCount()));
        vo.setLearnUserCount(defaultNumber(row.getLearnUserCount()));
        vo.setFavoriteCount(defaultNumber(row.getFavoriteCount()));
        vo.setTotalDurationSeconds(defaultNumber(row.getTotalDurationSeconds()));
        vo.setVideoCount(defaultNumber(row.getVideoCount()));
        vo.setStatus(row.getStatus());
        vo.setIsHomeRecommend(row.getIsHomeRecommend());
        vo.setSort(row.getSort());
        vo.setCreatedAt(row.getCreatedAt());
        vo.setUpdatedAt(row.getUpdatedAt());
        return vo;
    }
    private HealthCourseVideoItemVO toVideoItem(HealthCourseVideo item) {
        HealthCourseVideoItemVO vo = new HealthCourseVideoItemVO();
        vo.setId(item.getId());
        vo.setCourseId(item.getCourseId());
        vo.setChapterName(item.getChapterName());
        vo.setChapterRemark(item.getChapterRemark());
        vo.setVideoUrl(item.getVideoUrl());
        vo.setDurationSeconds(item.getDurationSeconds());
        vo.setSaleType(item.getSaleType());
        vo.setSalePrice(item.getSalePrice());
        vo.setPlayCount(item.getPlayCount());
        vo.setSort(item.getSort());
        vo.setStatus(item.getStatus());
        vo.setCreatedAt(item.getCreatedAt());
        return vo;
    }
    private HealthCourseVideoDetailVO toVideoDetail(HealthCourseVideo item) {
        HealthCourseVideoDetailVO vo = new HealthCourseVideoDetailVO();
        vo.setId(item.getId());
        vo.setCourseId(item.getCourseId());
        vo.setChapterName(item.getChapterName());
        vo.setChapterRemark(item.getChapterRemark());
        vo.setVideoUrl(item.getVideoUrl());
        vo.setDurationSeconds(item.getDurationSeconds());
        vo.setSaleType(item.getSaleType());
        vo.setSalePrice(item.getSalePrice());
        vo.setPlayCount(item.getPlayCount());
        vo.setSort(item.getSort());
        vo.setStatus(item.getStatus());
        vo.setCreatedAt(item.getCreatedAt());
        vo.setUpdatedAt(item.getUpdatedAt());
        return vo;
    }
    private Integer resolveSalesCount(Integer baseSalesCount, Integer realSalesCount) {
        return defaultNumber(baseSalesCount) + defaultNumber(realSalesCount);
    }
    private Integer defaultNumber(Integer value) {
        return value == null ? 0 : value;
    }
    private String formatDuration(Integer seconds) {
        int totalSeconds = seconds == null ? 0 : seconds;
        int minutes = totalSeconds / 60;
        int remainSeconds = totalSeconds % 60;
        return minutes + ":" + String.format("%02d", remainSeconds);
    }
    private String statusText(Integer status) {
        return status != null && status == 1 ? "启用" : "禁用";
    }
    private String defaultText(String text) {
        return text == null || text.trim().isEmpty() ? "-" : text;
    }
    private String trimToNull(String text) {
        if (text == null) {
            return null;
        }
        String trimmed = text.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }
}
backend/src/main/java/com/tcm/training/admin/live/service/LiveCourseCategoryAppService.java
New file
@@ -0,0 +1,149 @@
package com.tcm.training.admin.live.service;
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.common.PageResponse;
import com.tcm.training.admin.live.dto.LiveCourseCategorySaveRequest;
import com.tcm.training.admin.live.entity.LiveCourseCategory;
import com.tcm.training.admin.live.mapper.LiveCourseCategoryMapper;
import com.tcm.training.admin.live.vo.LiveCourseCategoryDetailVO;
import com.tcm.training.admin.live.vo.LiveCourseCategoryItemVO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
public class LiveCourseCategoryAppService {
    public static final String TYPE_LEVEL = "LEVEL";
    public static final String TYPE_DIFFICULTY = "DIFFICULTY";
    private final LiveCourseCategoryMapper liveCourseCategoryMapper;
    public LiveCourseCategoryAppService(LiveCourseCategoryMapper liveCourseCategoryMapper) {
        this.liveCourseCategoryMapper = liveCourseCategoryMapper;
    }
    public PageResponse<LiveCourseCategoryItemVO> page(int page, int pageSize, String type) {
        String normalizedType = normalizeType(type);
        LambdaQueryWrapper<LiveCourseCategory> qw = new LambdaQueryWrapper<LiveCourseCategory>()
                .eq(LiveCourseCategory::getType, normalizedType)
                .orderByAsc(LiveCourseCategory::getSort)
                .orderByAsc(LiveCourseCategory::getId);
        IPage<LiveCourseCategory> p = liveCourseCategoryMapper.selectPage(new Page<LiveCourseCategory>(page, pageSize), qw);
        List<LiveCourseCategoryItemVO> list = new ArrayList<LiveCourseCategoryItemVO>();
        for (LiveCourseCategory item : p.getRecords()) {
            list.add(new LiveCourseCategoryItemVO(
                    item.getId(),
                    item.getType(),
                    item.getName(),
                    item.getSort(),
                    item.getCreatedAt()
            ));
        }
        return PageResponse.of(list, p.getTotal(), p.getCurrent(), p.getSize());
    }
    public LiveCourseCategoryDetailVO detail(Long id) {
        return toDetailVO(requireCategory(id));
    }
    @Transactional
    public LiveCourseCategoryDetailVO create(String type, LiveCourseCategorySaveRequest req) {
        String normalizedType = normalizeType(type);
        String name = normalizeName(req.getName());
        validateUniqueName(normalizedType, name, null);
        LiveCourseCategory entity = new LiveCourseCategory();
        entity.setType(normalizedType);
        entity.setName(name);
        entity.setSort(resolveSort(req.getSort()));
        entity.setDeleted(0);
        liveCourseCategoryMapper.insert(entity);
        return detail(entity.getId());
    }
    @Transactional
    public LiveCourseCategoryDetailVO update(Long id, LiveCourseCategorySaveRequest req) {
        LiveCourseCategory entity = requireCategory(id);
        String name = normalizeName(req.getName());
        validateUniqueName(entity.getType(), name, id);
        entity.setName(name);
        entity.setSort(resolveSort(req.getSort()));
        liveCourseCategoryMapper.updateById(entity);
        return detail(id);
    }
    @Transactional
    public void delete(Long id) {
        requireCategory(id);
        liveCourseCategoryMapper.deleteById(id);
    }
    private LiveCourseCategory requireCategory(Long id) {
        LiveCourseCategory entity = liveCourseCategoryMapper.selectOne(new LambdaQueryWrapper<LiveCourseCategory>()
                .eq(LiveCourseCategory::getId, id)
                .eq(LiveCourseCategory::getDeleted, 0)
                .last("limit 1"));
        if (entity == null) {
            throw new IllegalArgumentException("分类不存在");
        }
        return entity;
    }
    private void validateUniqueName(String type, String name, Long excludeId) {
        LambdaQueryWrapper<LiveCourseCategory> qw = new LambdaQueryWrapper<LiveCourseCategory>()
                .eq(LiveCourseCategory::getType, type)
                .eq(LiveCourseCategory::getName, name)
                .eq(LiveCourseCategory::getDeleted, 0);
        if (excludeId != null) {
            qw.ne(LiveCourseCategory::getId, excludeId);
        }
        Long count = liveCourseCategoryMapper.selectCount(qw);
        if (count != null && count > 0) {
            throw new IllegalArgumentException(resolveLabel(type) + "名称已存在");
        }
    }
    private String normalizeType(String type) {
        if (type == null) {
            throw new IllegalArgumentException("分类类型不能为空");
        }
        String normalized = type.trim().toUpperCase();
        if (!TYPE_LEVEL.equals(normalized) && !TYPE_DIFFICULTY.equals(normalized)) {
            throw new IllegalArgumentException("分类类型不合法");
        }
        return normalized;
    }
    private String normalizeName(String name) {
        String normalized = name == null ? "" : name.trim();
        if (normalized.isEmpty()) {
            throw new IllegalArgumentException("请输入分类名称");
        }
        return normalized;
    }
    private Integer resolveSort(Integer sort) {
        return sort == null ? 0 : sort;
    }
    private String resolveLabel(String type) {
        return TYPE_LEVEL.equals(type) ? "养身等级" : "难度";
    }
    private LiveCourseCategoryDetailVO toDetailVO(LiveCourseCategory entity) {
        LiveCourseCategoryDetailVO vo = new LiveCourseCategoryDetailVO();
        vo.setId(entity.getId());
        vo.setType(entity.getType());
        vo.setName(entity.getName());
        vo.setSort(entity.getSort());
        vo.setCreatedAt(entity.getCreatedAt());
        vo.setUpdatedAt(entity.getUpdatedAt());
        return vo;
    }
}
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseDetailRow.java
New file
@@ -0,0 +1,40 @@
package com.tcm.training.admin.live.vo;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
public class HealthCourseDetailRow {
    private Long id;
    private String title;
    private Long levelCategoryId;
    private String levelName;
    private Long difficultyCategoryId;
    private String difficultyName;
    private Long expertId;
    private String expertName;
    private String expertAvatar;
    private BigDecimal salePrice;
    private String summary;
    private String targetUsers;
    private String coverImg;
    private String bannerImgs;
    private String content;
    private Integer baseSalesCount;
    private Integer realSalesCount;
    private Integer learnUserCount;
    private Integer favoriteCount;
    private Integer totalDurationSeconds;
    private Integer videoCount;
    private Integer status;
    private Integer isHomeRecommend;
    private Integer sort;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseDetailVO.java
New file
@@ -0,0 +1,40 @@
package com.tcm.training.admin.live.vo;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Getter
@Setter
@NoArgsConstructor
public class HealthCourseDetailVO {
    private Long id;
    private String title;
    private Long levelCategoryId;
    private String levelName;
    private Long difficultyCategoryId;
    private String difficultyName;
    private ExpertVO expert;
    private BigDecimal salePrice;
    private String summary;
    private String targetUsers;
    private String coverImg;
    private List<String> bannerImgs;
    private String content;
    private Integer baseSalesCount;
    private Integer realSalesCount;
    private Integer salesCount;
    private Integer learnUserCount;
    private Integer favoriteCount;
    private Integer totalDurationSeconds;
    private Integer videoCount;
    private Integer status;
    private Integer isHomeRecommend;
    private Integer sort;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListItemVO.java
New file
@@ -0,0 +1,28 @@
package com.tcm.training.admin.live.vo;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
public class HealthCourseListItemVO {
    private Long id;
    private String title;
    private String levelName;
    private String difficultyName;
    private BigDecimal salePrice;
    private Integer salesCount;
    private Integer learnUserCount;
    private Integer totalDurationSeconds;
    private Integer favoriteCount;
    private ExpertVO expert;
    private String coverImg;
    private Integer status;
    private Integer isHomeRecommend;
    private LocalDateTime createdAt;
}
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListRow.java
New file
@@ -0,0 +1,31 @@
package com.tcm.training.admin.live.vo;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
public class HealthCourseListRow {
    private Long id;
    private String title;
    private String levelName;
    private String difficultyName;
    private BigDecimal salePrice;
    private Integer baseSalesCount;
    private Integer realSalesCount;
    private Integer learnUserCount;
    private Integer totalDurationSeconds;
    private Integer favoriteCount;
    private Long expertId;
    private String expertName;
    private String expertAvatar;
    private String coverImg;
    private Integer status;
    private Integer isHomeRecommend;
    private LocalDateTime createdAt;
}
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseStatusVO.java
New file
@@ -0,0 +1,18 @@
package com.tcm.training.admin.live.vo;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class HealthCourseStatusVO {
    private Long id;
    private Integer status;
    public HealthCourseStatusVO(Long id, Integer status) {
        this.id = id;
        this.status = status;
    }
}
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseVideoDetailVO.java
New file
@@ -0,0 +1,27 @@
package com.tcm.training.admin.live.vo;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
public class HealthCourseVideoDetailVO {
    private Long id;
    private Long courseId;
    private String chapterName;
    private String chapterRemark;
    private String videoUrl;
    private Integer durationSeconds;
    private Integer saleType;
    private BigDecimal salePrice;
    private Integer playCount;
    private Integer sort;
    private Integer status;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseVideoItemVO.java
New file
@@ -0,0 +1,26 @@
package com.tcm.training.admin.live.vo;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
public class HealthCourseVideoItemVO {
    private Long id;
    private Long courseId;
    private String chapterName;
    private String chapterRemark;
    private String videoUrl;
    private Integer durationSeconds;
    private Integer saleType;
    private BigDecimal salePrice;
    private Integer playCount;
    private Integer sort;
    private Integer status;
    private LocalDateTime createdAt;
}
backend/src/main/java/com/tcm/training/admin/live/vo/LiveCourseCategoryDetailVO.java
New file
@@ -0,0 +1,19 @@
package com.tcm.training.admin.live.vo;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
public class LiveCourseCategoryDetailVO {
    private Long id;
    private String type;
    private String name;
    private Integer sort;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
backend/src/main/java/com/tcm/training/admin/live/vo/LiveCourseCategoryItemVO.java
New file
@@ -0,0 +1,26 @@
package com.tcm.training.admin.live.vo;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
public class LiveCourseCategoryItemVO {
    private Long id;
    private String type;
    private String name;
    private Integer sort;
    private LocalDateTime createdAt;
    public LiveCourseCategoryItemVO(Long id, String type, String name, Integer sort, LocalDateTime createdAt) {
        this.id = id;
        this.type = type;
        this.name = name;
        this.sort = sort;
        this.createdAt = createdAt;
    }
}
backend/src/main/resources/mapper/HealthCourseMapper.xml
New file
@@ -0,0 +1,80 @@
<?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.HealthCourseMapper">
    <sql id="HealthCourseSelect">
        SELECT
            c.id,
            c.title,
            c.level_category_id AS levelCategoryId,
            lc.name AS levelName,
            c.difficulty_category_id AS difficultyCategoryId,
            dc.name AS difficultyName,
            c.expert_id AS expertId,
            e.name AS expertName,
            e.avatar AS expertAvatar,
            c.sale_price AS salePrice,
            c.summary,
            c.target_users AS targetUsers,
            c.cover_img AS coverImg,
            c.banner_imgs AS bannerImgs,
            c.content,
            c.base_sales_count AS baseSalesCount,
            c.real_sales_count AS realSalesCount,
            c.learn_user_count AS learnUserCount,
            c.favorite_count AS favoriteCount,
            c.total_duration_seconds AS totalDurationSeconds,
            c.video_count AS videoCount,
            c.status,
            c.is_home_recommend AS isHomeRecommend,
            c.sort,
            c.created_at AS createdAt,
            c.updated_at AS updatedAt
        FROM tcm_health_course c
        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
        WHERE c.deleted = 0
    </sql>
    <select id="selectCoursePage" resultType="com.tcm.training.admin.live.vo.HealthCourseListRow">
        <include refid="HealthCourseSelect"/>
        <if test="keyword != null and keyword.trim() != ''">
            AND c.title LIKE CONCAT('%', #{keyword}, '%')
        </if>
        <if test="levelCategoryId != null">
            AND c.level_category_id = #{levelCategoryId}
        </if>
        <if test="difficultyCategoryId != null">
            AND c.difficulty_category_id = #{difficultyCategoryId}
        </if>
        <if test="status != null">
            AND c.status = #{status}
        </if>
        ORDER BY c.sort ASC, c.id DESC
    </select>
    <select id="selectCourseList" resultType="com.tcm.training.admin.live.vo.HealthCourseListRow">
        <include refid="HealthCourseSelect"/>
        <if test="keyword != null and keyword.trim() != ''">
            AND c.title LIKE CONCAT('%', #{keyword}, '%')
        </if>
        <if test="levelCategoryId != null">
            AND c.level_category_id = #{levelCategoryId}
        </if>
        <if test="difficultyCategoryId != null">
            AND c.difficulty_category_id = #{difficultyCategoryId}
        </if>
        <if test="status != null">
            AND c.status = #{status}
        </if>
        ORDER BY c.sort ASC, c.id DESC
    </select>
    <select id="selectCourseDetail" resultType="com.tcm.training.admin.live.vo.HealthCourseDetailRow">
        <include refid="HealthCourseSelect"/>
        AND c.id = #{id}
    </select>
</mapper>
tcm-training-admin/src/pages/live/category/index.tsx
New file
@@ -0,0 +1,273 @@
import {
  createLiveCategory,
  deleteLiveCategory,
  getLiveCategoryDetail,
  getLiveCategoryPage,
  updateLiveCategory,
} from './service';
import { PlusOutlined } from '@ant-design/icons';
import { history, useAccess } from '@umijs/max';
import { Button, Form, Input, InputNumber, message, Modal, Pagination, Space, Table, Tabs } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import React, { useEffect, useState } from 'react';
import './style.less';
type CategoryType = 'LEVEL' | 'DIFFICULTY';
type CategoryItem = {
  id: number;
  type: CategoryType;
  name: string;
  sort: number;
  createdAt: string;
};
type EditValues = {
  name: string;
  sort: number;
};
const TAB_OPTIONS: Array<{ key: CategoryType; label: string }> = [
  { key: 'LEVEL', label: '养身等级' },
  { key: 'DIFFICULTY', label: '难度' },
];
const LiveCategoryPage: React.FC = () => {
  const access = useAccess();
  const [editForm] = Form.useForm<EditValues>();
  const [activeType, setActiveType] = useState<CategoryType>('LEVEL');
  const [list, setList] = useState<CategoryItem[]>([]);
  const [loading, setLoading] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const [editOpen, setEditOpen] = useState(false);
  const [editingId, setEditingId] = useState<number | null>(null);
  const [pagination, setPagination] = useState({ page: 1, pageSize: 10, total: 0 });
  const canView = access['live:category:view'];
  const canAdd = access['live:category:add'];
  const canEdit = access['live:category:edit'];
  const canDelete = access['live:category:delete'];
  const loadList = async (
    nextType: CategoryType = activeType,
    page: number = pagination.page,
    pageSize: number = pagination.pageSize,
  ) => {
    setLoading(true);
    try {
      const res = await getLiveCategoryPage({
        type: nextType,
        page,
        pageSize,
      });
      setList(res.data.list || []);
      setPagination({
        page: Number(res.data.page || page),
        pageSize: Number(res.data.pageSize || pageSize),
        total: Number(res.data.total || 0),
      });
    } finally {
      setLoading(false);
    }
  };
  useEffect(() => {
    if (!canView) {
      history.replace('/Welcome');
      return;
    }
    loadList('LEVEL', 1, 10);
  }, [canView]);
  const openCreateModal = () => {
    setEditingId(null);
    editForm.resetFields();
    editForm.setFieldsValue({ sort: 0 });
    setEditOpen(true);
  };
  const openEditModal = async (record: CategoryItem) => {
    const res = await getLiveCategoryDetail(record.id);
    setEditingId(record.id);
    editForm.resetFields();
    editForm.setFieldsValue({
      name: res.data.name,
      sort: res.data.sort,
    });
    setEditOpen(true);
  };
  const handleSubmit = async () => {
    const values = await editForm.validateFields();
    setSubmitting(true);
    try {
      if (editingId) {
        await updateLiveCategory(editingId, values);
        message.success('保存成功');
      } else {
        await createLiveCategory(activeType, values);
        message.success('新增成功');
      }
      setEditOpen(false);
      editForm.resetFields();
      await loadList(activeType, editingId ? pagination.page : 1, pagination.pageSize);
    } finally {
      setSubmitting(false);
    }
  };
  const handleDelete = (record: CategoryItem) => {
    Modal.confirm({
      title: '确定要删除该分类吗?',
      content: '删除后将无法恢复,请谨慎操作。',
      okText: '删除',
      cancelText: '取消',
      onOk: async () => {
        await deleteLiveCategory(record.id);
        const nextList = list.filter((item) => item.id !== record.id);
        setList(nextList);
        message.success('删除成功');
        const nextPage =
          nextList.length === 0 && pagination.page > 1 ? pagination.page - 1 : pagination.page;
        await loadList(activeType, nextPage, pagination.pageSize);
      },
    });
  };
  const columns: ColumnsType<CategoryItem> = [
    {
      title: '排序',
      dataIndex: 'sort',
      width: 120,
    },
    {
      title: '分类名称',
      dataIndex: 'name',
    },
    {
      title: '添加时间',
      dataIndex: 'createdAt',
      width: 200,
    },
    {
      title: '操作',
      width: 180,
      render: (_, record) => (
        <Space size={16} className="live-category-table__actions">
          {canEdit ? (
            <button type="button" onClick={() => openEditModal(record)}>
              编辑
            </button>
          ) : null}
          {canDelete ? (
            <button type="button" onClick={() => handleDelete(record)}>
              删除
            </button>
          ) : null}
        </Space>
      ),
    },
  ];
  return (
    <div className="live-category-page">
      <div className="live-category-page__panel">
        <div className="live-category-page__title">分类管理</div>
        <div className="live-category-tabs-card">
          <Tabs
            activeKey={activeType}
            items={TAB_OPTIONS.map((item) => ({
              key: item.key,
              label: item.label,
            }))}
            onChange={(key) => {
              const nextType = key as CategoryType;
              setActiveType(nextType);
              setPagination((prev) => ({
                ...prev,
                page: 1,
              }));
              loadList(nextType, 1, pagination.pageSize);
            }}
          />
        </div>
        {canAdd ? (
          <div className="live-category-toolbar">
            <Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
              添加
            </Button>
          </div>
        ) : null}
        <div className="live-category-table-card">
          <Table<CategoryItem>
            rowKey="id"
            columns={columns}
            dataSource={list}
            loading={loading}
            pagination={false}
          />
          <div className="live-category-pagination">
            <Pagination
              current={pagination.page}
              pageSize={pagination.pageSize}
              total={pagination.total}
              showSizeChanger
              showTotal={(total) => `共 ${total} 条`}
              onChange={(page, pageSize) => {
                loadList(activeType, page, pageSize);
              }}
            />
          </div>
        </div>
      </div>
      <Modal
        title={editingId ? '编辑' : '新建'}
        open={editOpen}
        width={520}
        onCancel={() => {
          setEditOpen(false);
          editForm.resetFields();
        }}
        footer={null}
        destroyOnClose
        className="live-category-edit-modal"
      >
        <Form form={editForm} layout="vertical" className="live-category-edit-form">
          <Form.Item
            label="名称"
            name="name"
            rules={[
              { required: true, message: '请输入分类名称' },
              { max: 5, message: '请输入5个字以内' },
            ]}
          >
            <Input placeholder="请输入5个字以内" maxLength={5} />
          </Form.Item>
          <Form.Item
            label="排序"
            name="sort"
            rules={[{ required: true, message: '请输入排序' }]}
            extra="数字越小排在越前面"
          >
            <InputNumber min={0} precision={0} placeholder="请输入排序" style={{ width: '100%' }} />
          </Form.Item>
          <div className="live-category-edit-form__footer">
            <Button
              onClick={() => {
                setEditOpen(false);
                editForm.resetFields();
              }}
            >
              取消
            </Button>
            <Button type="primary" loading={submitting} onClick={handleSubmit}>
              确定
            </Button>
          </div>
        </Form>
      </Modal>
    </div>
  );
};
export default LiveCategoryPage;
tcm-training-admin/src/pages/live/category/service.ts
New file
@@ -0,0 +1,35 @@
import { request } from '@umijs/max';
export async function getLiveCategoryPage(params: Record<string, any>) {
  return request('/live-course-categories', {
    method: 'GET',
    params,
  });
}
export async function getLiveCategoryDetail(id: number | string) {
  return request(`/live-course-categories/${id}`, {
    method: 'GET',
  });
}
export async function createLiveCategory(type: string, data: Record<string, any>) {
  return request('/live-course-categories', {
    method: 'POST',
    params: { type },
    data,
  });
}
export async function updateLiveCategory(id: number | string, data: Record<string, any>) {
  return request(`/live-course-categories/${id}`, {
    method: 'PUT',
    data,
  });
}
export async function deleteLiveCategory(id: number | string) {
  return request(`/live-course-categories/${id}`, {
    method: 'DELETE',
  });
}
tcm-training-admin/src/pages/live/category/style.less
New file
@@ -0,0 +1,97 @@
.live-category-page {
  padding: 18px 20px 24px;
  min-height: calc(100vh - 48px);
  background: #f2f5f9;
}
.live-category-page__panel {
  padding: 18px 18px 24px;
  border-radius: 10px;
  background: #fff;
  box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
}
.live-category-page__title {
  margin-bottom: 16px;
  color: #303133;
  font-size: 20px;
  font-weight: 500;
}
.live-category-tabs-card {
  margin-bottom: 16px;
  padding: 0 12px;
  border: 1px solid #e7edf4;
  border-radius: 6px;
}
.live-category-tabs-card .ant-tabs-nav {
  margin-bottom: 0;
}
.live-category-toolbar {
  display: flex;
  justify-content: flex-end;
  margin-bottom: 12px;
}
.live-category-table-card {
  overflow: hidden;
  border: 1px solid #e7edf4;
  border-radius: 6px;
}
.live-category-table-card .ant-table-thead > tr > th {
  background: #f8fafc;
  color: #303133;
  font-weight: 500;
}
.live-category-table-card .ant-table-tbody > tr > td {
  color: #303133;
}
.live-category-table__actions {
  button {
    padding: 0;
    border: none;
    background: none;
    color: #1677ff;
    cursor: pointer;
  }
}
.live-category-pagination {
  display: flex;
  justify-content: flex-end;
  padding: 16px 18px;
}
.live-category-edit-modal .ant-modal-content {
  padding: 18px 24px 20px;
}
.live-category-edit-form .ant-form-item {
  margin-bottom: 18px;
}
.live-category-edit-form__footer {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 10px;
}
@media (max-width: 768px) {
  .live-category-page {
    padding: 12px;
  }
  .live-category-tabs-card {
    padding: 0 8px;
  }
  .live-category-pagination {
    justify-content: center;
  }
}
tcm-training-admin/src/pages/live/health/detail.tsx
New file
@@ -0,0 +1,360 @@
import { history, useAccess } from '@umijs/max';
import { Button, Form, Image, Input, Pagination, Select, Space, Table, Tag, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import React, { useEffect, useMemo, useState } from 'react';
import {
  deleteHealthCourse,
  deleteHealthCourseVideo,
  getHealthCourseDetail,
  getHealthCourseVideos,
  updateHealthCourseStatus,
} from './service';
import './style.less';
type CourseDetail = {
  id: number;
  title: string;
  levelName?: string;
  difficultyName?: string;
  salePrice?: number;
  summary?: string;
  targetUsers?: string;
  coverImg?: string;
  bannerImgs?: string[];
  content?: string;
  baseSalesCount?: number;
  salesCount?: number;
  learnUserCount?: number;
  favoriteCount?: number;
  totalDurationSeconds?: number;
  status: number;
  expert?: {
    id: number;
    name: string;
    avatar?: string;
  };
};
type VideoSearchValues = {
  chapterName?: string;
  saleType?: number;
};
type VideoItem = {
  id: number;
  chapterName?: string;
  chapterRemark?: string;
  durationSeconds?: number;
  saleType?: number;
  salePrice?: number;
  playCount?: number;
};
const saleTypeOptions = [
  { label: '全部', value: undefined },
  { label: '免费', value: 0 },
  { label: '收费', value: 1 },
];
const formatDuration = (seconds?: number) => {
  const total = Number(seconds || 0);
  const minutes = Math.floor(total / 60);
  const remainSeconds = total % 60;
  return `${minutes}:${String(remainSeconds).padStart(2, '0')}`;
};
const HealthCourseDetailPage: React.FC = () => {
  const access = useAccess();
  const [course, setCourse] = useState<CourseDetail | null>(null);
  const [videoSearchForm] = Form.useForm<VideoSearchValues>();
  const [videoList, setVideoList] = useState<VideoItem[]>([]);
  const [videoLoading, setVideoLoading] = useState(false);
  const [videoPagination, setVideoPagination] = useState({ page: 1, pageSize: 10, total: 0 });
  const [activeTab, setActiveTab] = useState<'info' | 'videos'>(
    history.location.search.includes('tab=videos') ? 'videos' : 'info',
  );
  const courseId = useMemo(() => history.location.pathname.split('/').pop() || '', []);
  const canView = access['live:health-course:detail'];
  const canEdit = access['live:health-course:edit'];
  const canDelete = access['live:health-course:delete'];
  const canStatus = access['live:health-course:status'];
  const canVideoAdd = access['live:health-course:video:add'];
  const canVideoEdit = access['live:health-course:video:edit'];
  const canVideoDelete = access['live:health-course:video:delete'];
  const loadCourse = async () => {
    const res = await getHealthCourseDetail(courseId);
    setCourse(res.data);
  };
  const loadVideos = async (page = videoPagination.page, pageSize = videoPagination.pageSize) => {
    setVideoLoading(true);
    try {
      const values = videoSearchForm.getFieldsValue();
      const res = await getHealthCourseVideos(courseId, {
        page,
        pageSize,
        chapterName: values.chapterName?.trim() || undefined,
        saleType: values.saleType,
      });
      setVideoList(res.data.list || []);
      setVideoPagination({
        page: Number(res.data.page || page),
        pageSize: Number(res.data.pageSize || pageSize),
        total: Number(res.data.total || 0),
      });
    } finally {
      setVideoLoading(false);
    }
  };
  useEffect(() => {
    if (!canView) {
      history.replace('/Welcome');
      return;
    }
    loadCourse();
    loadVideos(1, 10);
  }, [canView]);
  const handleToggleStatus = async () => {
    if (!course) {
      return;
    }
    const nextStatus = course.status === 1 ? 0 : 1;
    await updateHealthCourseStatus(course.id, { status: nextStatus });
    message.success(nextStatus === 1 ? '启用成功' : '禁用成功');
    await loadCourse();
  };
  const handleDeleteCourse = async () => {
    if (!course) {
      return;
    }
    await deleteHealthCourse(course.id);
    message.success('删除成功');
    history.push('/live/course/health');
  };
  const handleDeleteVideo = async (record: VideoItem) => {
    await deleteHealthCourseVideo(courseId, record.id);
    message.success('删除成功');
    await loadCourse();
    await loadVideos(
      videoList.length === 1 && videoPagination.page > 1 ? videoPagination.page - 1 : videoPagination.page,
      videoPagination.pageSize,
    );
  };
  const videoColumns: ColumnsType<VideoItem> = [
    {
      title: '序号',
      width: 72,
      render: (_, __, index) => (videoPagination.page - 1) * videoPagination.pageSize + index + 1,
    },
    {
      title: '小节名称',
      dataIndex: 'chapterName',
      width: 180,
      render: (value) => value || '-',
    },
    {
      title: '小节备注',
      dataIndex: 'chapterRemark',
      render: (value) => value || '-',
    },
    {
      title: '视频时长',
      dataIndex: 'durationSeconds',
      width: 110,
      render: (value) => formatDuration(value),
    },
    {
      title: '售价',
      width: 100,
      render: (_, record) =>
        record.saleType === 0 ? '免费' : `¥ ${Number(record.salePrice || 0).toFixed(2)}`,
    },
    {
      title: '播放次数',
      dataIndex: 'playCount',
      width: 100,
      render: (value) => `${value || 0}次`,
    },
    {
      title: '操作',
      width: 180,
      render: (_, record) => (
        <Space size={12} className="health-course-table__actions">
          {canVideoEdit ? (
            <button
              type="button"
              onClick={() => history.push(`/live/course/health/${courseId}/videos/${record.id}/edit`)}
            >
              编辑
            </button>
          ) : null}
          {canVideoDelete ? (
            <button type="button" onClick={() => handleDeleteVideo(record)}>
              删除
            </button>
          ) : null}
        </Space>
      ),
    },
  ];
  return (
    <div className="health-course-page">
      <div className="health-course-page__panel">
        <div className="health-course-page__title">课程详情</div>
        <div className="health-course-detail-header">
          <div className="health-course-detail-header__main">
            {course?.coverImg ? (
              <Image src={course.coverImg} width={96} height={64} preview={false} />
            ) : (
              <div className="health-course-table__cover-placeholder">{course?.title?.slice(0, 2) || '-'}</div>
            )}
            <div className="health-course-detail-header__meta">
              <h2>{course?.title || '-'}</h2>
              <div className="health-course-detail-header__tags">
                <span>{course?.levelName || '-'}</span>
                <span>{course?.difficultyName || '-'}</span>
              </div>
              <div className="health-course-detail-header__stats">
                <span>¥ {Number(course?.salePrice || 0).toFixed(2)}</span>
                <span>共计课时:{formatDuration(course?.totalDurationSeconds)}</span>
                <span>累计学习人数:{course?.learnUserCount || 0}人</span>
              </div>
            </div>
          </div>
          <div className="health-course-detail-header__actions">
            {canStatus ? (
              <Button onClick={handleToggleStatus}>{course?.status === 1 ? '禁用' : '启用'}</Button>
            ) : null}
            {canEdit ? (
              <Button type="primary" onClick={() => history.push(`/live/course/health/${courseId}/edit`)}>
                编辑
              </Button>
            ) : null}
            {canDelete ? (
              <Button danger onClick={handleDeleteCourse}>
                删除
              </Button>
            ) : null}
          </div>
        </div>
        <div className="health-course-detail-tabs">
          <button
            type="button"
            className={activeTab === 'info' ? 'is-active' : ''}
            onClick={() => {
              setActiveTab('info');
              history.replace(`/live/course/health/${courseId}`);
            }}
          >
            课程信息
          </button>
          <button
            type="button"
            className={activeTab === 'videos' ? 'is-active' : ''}
            onClick={() => {
              setActiveTab('videos');
              history.replace(`/live/course/health/${courseId}?tab=videos`);
            }}
          >
            视频课程
          </button>
        </div>
        {activeTab === 'info' ? (
          <div className="health-course-detail-card">
            <div className="health-course-detail-card__grid">
              <div>授课专家:{course?.expert?.name || '-'}</div>
              <div>课程介绍:{course?.summary || '-'}</div>
              <div>适用人群:{course?.targetUsers || '-'}</div>
              <div>状态:{course?.status === 1 ? '启用' : '禁用'}</div>
              <div>基础购买量:{course?.baseSalesCount || 0}</div>
              <div>销量:{course?.salesCount || 0}</div>
              <div>收藏量:{course?.favoriteCount || 0}</div>
            </div>
            <div className="health-course-detail-card__images">
              {(course?.bannerImgs || []).map((item) => (
                <Image key={item} src={item} width={120} height={80} preview={false} />
              ))}
            </div>
            <div className="health-course-detail-card__content">
              {course?.content ? (
                <div dangerouslySetInnerHTML={{ __html: course.content }} />
              ) : (
                <span>-</span>
              )}
            </div>
          </div>
        ) : (
          <div className="health-course-detail-card">
            <div className="health-course-search-card">
              <Form form={videoSearchForm} layout="horizontal" className="health-course-search-form health-course-search-form--video">
                <Form.Item label="小节名称" name="chapterName">
                  <Input placeholder="请输入" allowClear />
                </Form.Item>
                <Form.Item label="售价" name="saleType">
                  <Select
                    placeholder="全部"
                    allowClear
                    options={saleTypeOptions.filter((item) => item.value !== undefined)}
                  />
                </Form.Item>
                <div className="health-course-search-form__actions">
                  <Button
                    onClick={async () => {
                      videoSearchForm.resetFields();
                      await loadVideos(1, videoPagination.pageSize);
                    }}
                  >
                    重置
                  </Button>
                  <Button type="primary" onClick={() => loadVideos(1, videoPagination.pageSize)}>
                    搜索
                  </Button>
                </div>
              </Form>
            </div>
            <div className="health-course-toolbar">
              <div />
              <div>
                {canVideoAdd ? (
                  <Button type="primary" onClick={() => history.push(`/live/course/health/${courseId}/videos/create`)}>
                    添加
                  </Button>
                ) : null}
              </div>
            </div>
            <div className="health-course-table-card">
              <Table<VideoItem>
                rowKey="id"
                columns={videoColumns}
                dataSource={videoList}
                loading={videoLoading}
                pagination={false}
              />
              <div className="health-course-pagination">
                <Pagination
                  current={videoPagination.page}
                  pageSize={videoPagination.pageSize}
                  total={videoPagination.total}
                  showSizeChanger
                  showTotal={(total) => `共 ${total} 条`}
                  onChange={(page, pageSize) => loadVideos(page, pageSize)}
                />
              </div>
            </div>
          </div>
        )}
      </div>
    </div>
  );
};
export default HealthCourseDetailPage;
tcm-training-admin/src/pages/live/health/index.tsx
New file
@@ -0,0 +1,313 @@
import { getLiveCategoryPage } from '../category/service';
import { downloadBlob } from '@/pages/trade/points-exchange/shared';
import { history, useAccess } from '@umijs/max';
import { Button, Form, Image, Input, Pagination, Select, Space, Table, Tag, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import React, { useEffect, useState } from 'react';
import {
  deleteHealthCourse,
  exportHealthCourses,
  getHealthCoursePage,
  updateHealthCourseStatus,
} from './service';
import './style.less';
type SearchValues = {
  keyword?: string;
  levelCategoryId?: number;
  difficultyCategoryId?: number;
  status?: number;
};
type CategoryOption = {
  id: number;
  name: string;
};
type CourseItem = {
  id: number;
  title: string;
  levelName?: string;
  difficultyName?: string;
  salePrice?: number;
  salesCount?: number;
  learnUserCount?: number;
  totalDurationSeconds?: number;
  favoriteCount?: number;
  coverImg?: string;
  status: number;
  expert?: {
    id: number;
    name: string;
  };
};
const statusOptions = [
  { label: '启用', value: 1 },
  { label: '禁用', value: 0 },
];
const trimSearchValues = (values: SearchValues) => ({
  keyword: values.keyword?.trim() || undefined,
  levelCategoryId: values.levelCategoryId,
  difficultyCategoryId: values.difficultyCategoryId,
  status: values.status,
});
const formatDuration = (seconds?: number) => {
  const total = Number(seconds || 0);
  const minutes = Math.floor(total / 60);
  const remainSeconds = total % 60;
  return `${minutes}:${String(remainSeconds).padStart(2, '0')}`;
};
const HealthCourseListPage: React.FC = () => {
  const access = useAccess();
  const [searchForm] = Form.useForm<SearchValues>();
  const [list, setList] = useState<CourseItem[]>([]);
  const [levelOptions, setLevelOptions] = useState<CategoryOption[]>([]);
  const [difficultyOptions, setDifficultyOptions] = useState<CategoryOption[]>([]);
  const [loading, setLoading] = useState(false);
  const [pagination, setPagination] = useState({ page: 1, pageSize: 10, total: 0 });
  const canView = access['live:health-course:view'];
  const canDetail = access['live:health-course:detail'];
  const canAdd = access['live:health-course:add'];
  const canEdit = access['live:health-course:edit'];
  const canDelete = access['live:health-course:delete'];
  const canStatus = access['live:health-course:status'];
  const canExport = access['live:health-course:export'];
  const refreshKey = history.location.search;
  const loadOptions = async () => {
    const [levelRes, difficultyRes] = await Promise.all([
      getLiveCategoryPage({ type: 'LEVEL', page: 1, pageSize: 100 }),
      getLiveCategoryPage({ type: 'DIFFICULTY', page: 1, pageSize: 100 }),
    ]);
    setLevelOptions(levelRes.data.list || []);
    setDifficultyOptions(difficultyRes.data.list || []);
  };
  const loadList = async (page = pagination.page, pageSize = pagination.pageSize) => {
    setLoading(true);
    try {
      const res = await getHealthCoursePage({
        page,
        pageSize,
        ...trimSearchValues(searchForm.getFieldsValue()),
      });
      setList(res.data.list || []);
      setPagination({
        page: Number(res.data.page || page),
        pageSize: Number(res.data.pageSize || pageSize),
        total: Number(res.data.total || 0),
      });
    } finally {
      setLoading(false);
    }
  };
  useEffect(() => {
    if (!canView) {
      history.replace('/Welcome');
      return;
    }
    loadOptions();
  }, [canView]);
  useEffect(() => {
    if (!canView) {
      return;
    }
    loadList(1, 10);
  }, [canView, refreshKey]);
  const handleExport = async () => {
    const blob = await exportHealthCourses(trimSearchValues(searchForm.getFieldsValue()));
    downloadBlob(blob, '养身课程.xlsx');
  };
  const handleDelete = async (record: CourseItem) => {
    await deleteHealthCourse(record.id);
    message.success('删除成功');
    await loadList(list.length === 1 && pagination.page > 1 ? pagination.page - 1 : pagination.page, pagination.pageSize);
  };
  const handleToggleStatus = async (record: CourseItem) => {
    const nextStatus = record.status === 1 ? 0 : 1;
    await updateHealthCourseStatus(record.id, { status: nextStatus });
    message.success(nextStatus === 1 ? '启用成功' : '禁用成功');
    await loadList(pagination.page, pagination.pageSize);
  };
  const columns: ColumnsType<CourseItem> = [
    {
      title: '序号',
      width: 72,
      render: (_, __, index) => (pagination.page - 1) * pagination.pageSize + index + 1,
    },
    {
      title: '课程名称',
      dataIndex: 'title',
      width: 260,
      render: (_, record) => (
        <div className="health-course-table__course">
          {record.coverImg ? (
            <Image src={record.coverImg} width={62} height={44} preview={false} />
          ) : (
            <div className="health-course-table__cover-placeholder">{record.title?.slice(0, 2) || '-'}</div>
          )}
          <span>{record.title || '-'}</span>
        </div>
      ),
    },
    {
      title: '养生等级',
      dataIndex: 'levelName',
      width: 110,
      render: (value) => value || '-',
    },
    {
      title: '难度',
      dataIndex: 'difficultyName',
      width: 90,
      render: (value) => value || '-',
    },
    {
      title: '售价',
      dataIndex: 'salePrice',
      width: 110,
      render: (value) => `¥ ${Number(value || 0).toFixed(2)}`,
    },
    {
      title: '销量',
      dataIndex: 'salesCount',
      width: 90,
      render: (value) => value || 0,
    },
    {
      title: '学习人数',
      dataIndex: 'learnUserCount',
      width: 100,
      render: (value) => `${value || 0}人`,
    },
    {
      title: '共计课时',
      dataIndex: 'totalDurationSeconds',
      width: 110,
      render: (value) => formatDuration(value),
    },
    {
      title: '收藏量',
      dataIndex: 'favoriteCount',
      width: 90,
      render: (value) => value || 0,
    },
    {
      title: '授课专家',
      dataIndex: 'expert',
      width: 120,
      render: (value) => value?.name || '-',
    },
    {
      title: '状态',
      dataIndex: 'status',
      width: 90,
      render: (value) => <Tag color={value === 1 ? 'success' : 'error'}>{value === 1 ? '启用' : '禁用'}</Tag>,
    },
    {
      title: '操作',
      width: 220,
      render: (_, record) => (
        <Space size={12} className="health-course-table__actions">
          {canDetail ? (
            <button type="button" onClick={() => history.push(`/live/course/health/${record.id}`)}>
              详情
            </button>
          ) : null}
          {canEdit ? (
            <button type="button" onClick={() => history.push(`/live/course/health/${record.id}/edit`)}>
              编辑
            </button>
          ) : null}
          {canStatus ? (
            <button type="button" onClick={() => handleToggleStatus(record)}>
              {record.status === 1 ? '禁用' : '启用'}
            </button>
          ) : null}
          {canDelete ? (
            <button type="button" onClick={() => handleDelete(record)}>
              删除
            </button>
          ) : null}
        </Space>
      ),
    },
  ];
  return (
    <div className="health-course-page">
      <div className="health-course-page__panel">
        <div className="health-course-page__title">养生课程</div>
        <div className="health-course-search-card">
          <Form form={searchForm} layout="horizontal" className="health-course-search-form">
            <Form.Item label="课程名称" name="keyword">
              <Input placeholder="请输入" allowClear />
            </Form.Item>
            <Form.Item label="养生等级" name="levelCategoryId">
              <Select
                placeholder="全部"
                allowClear
                options={levelOptions.map((item) => ({ label: item.name, value: item.id }))}
              />
            </Form.Item>
            <Form.Item label="难度" name="difficultyCategoryId">
              <Select
                placeholder="全部"
                allowClear
                options={difficultyOptions.map((item) => ({ label: item.name, value: item.id }))}
              />
            </Form.Item>
            <Form.Item label="状态" name="status">
              <Select placeholder="全部" allowClear options={statusOptions} />
            </Form.Item>
            <div className="health-course-search-form__actions">
              <Button
                onClick={async () => {
                  searchForm.resetFields();
                  await loadList(1, pagination.pageSize);
                }}
              >
                重置
              </Button>
              <Button type="primary" onClick={() => loadList(1, pagination.pageSize)}>
                搜索
              </Button>
            </div>
          </Form>
        </div>
        <div className="health-course-toolbar">
          <div>
            {canExport ? <Button onClick={handleExport}>导出</Button> : null}
          </div>
          <div>{canAdd ? <Button type="primary" onClick={() => history.push('/live/course/health/create')}>添加</Button> : null}</div>
        </div>
        <div className="health-course-table-card">
          <Table<CourseItem> rowKey="id" columns={columns} dataSource={list} loading={loading} pagination={false} />
          <div className="health-course-pagination">
            <Pagination
              current={pagination.page}
              pageSize={pagination.pageSize}
              total={pagination.total}
              showSizeChanger
              showTotal={(total) => `共 ${total} 条`}
              onChange={(page, pageSize) => loadList(page, pageSize)}
            />
          </div>
        </div>
      </div>
    </div>
  );
};
export default HealthCourseListPage;
tcm-training-admin/src/pages/live/health/manage.tsx
New file
@@ -0,0 +1,302 @@
import { getLiveCategoryPage } from '../category/service';
import BraftEditorCom from '@/components/BraftEditor';
import { PlusOutlined } from '@ant-design/icons';
import { history, useAccess } from '@umijs/max';
import { Button, Form, Image, Input, InputNumber, Select, Switch, Upload, message } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import {
  createHealthCourse,
  getHealthCourseDetail,
  updateHealthCourse,
  uploadHealthCourseImage,
} from './service';
import { getExpertOptions } from '../broadcast/service';
import './style.less';
type CategoryOption = {
  id: number;
  name: string;
};
type ExpertOption = {
  id: number;
  name: string;
};
const HealthCourseManagePage: React.FC = () => {
  const access = useAccess();
  const [form] = Form.useForm();
  const [loading, setLoading] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const [coverImg, setCoverImg] = useState('');
  const [bannerImgs, setBannerImgs] = useState<string[]>([]);
  const [levelOptions, setLevelOptions] = useState<CategoryOption[]>([]);
  const [difficultyOptions, setDifficultyOptions] = useState<CategoryOption[]>([]);
  const [experts, setExperts] = useState<ExpertOption[]>([]);
  const pathname = history.location.pathname;
  const isCreate = pathname.endsWith('/create');
  const listPagePath = '/live/course/health';
  const courseId = useMemo(() => {
    if (isCreate) {
      return '';
    }
    const segments = pathname.split('/');
    return segments[segments.length - 2] || '';
  }, [isCreate, pathname]);
  const canAccess = isCreate ? access['live:health-course:add'] : access['live:health-course:edit'];
  const syncBannerField = (nextImages: string[]) => {
    setBannerImgs(nextImages);
    form.setFieldsValue({ bannerImgs: nextImages });
  };
  useEffect(() => {
    if (!canAccess) {
      history.replace('/Welcome');
      return;
    }
    const bootstrap = async () => {
      setLoading(true);
      try {
        const [levelRes, difficultyRes, expertRes] = await Promise.all([
          getLiveCategoryPage({ type: 'LEVEL', page: 1, pageSize: 100 }),
          getLiveCategoryPage({ type: 'DIFFICULTY', page: 1, pageSize: 100 }),
          getExpertOptions(),
        ]);
        setLevelOptions(levelRes.data.list || []);
        setDifficultyOptions(difficultyRes.data.list || []);
        setExperts(expertRes.data || []);
        if (isCreate) {
          form.setFieldsValue({
            status: true,
            isHomeRecommend: false,
            salePrice: 0,
            baseSalesCount: 0,
            summary: '',
            targetUsers: '',
            bannerImgs: [],
            content: '',
          });
          return;
        }
        const detailRes = await getHealthCourseDetail(courseId);
        const course = detailRes.data;
        setCoverImg(course.coverImg || '');
        setBannerImgs(course.bannerImgs || []);
        form.setFieldsValue({
          title: course.title,
          levelCategoryId: course.levelCategoryId,
          difficultyCategoryId: course.difficultyCategoryId,
          expertId: course.expert?.id,
          salePrice: course.salePrice,
          summary: course.summary,
          targetUsers: course.targetUsers,
          coverImg: course.coverImg,
          bannerImgs: course.bannerImgs || [],
          baseSalesCount: course.baseSalesCount,
          status: course.status === 1,
          isHomeRecommend: course.isHomeRecommend === 1,
          content: course.content,
        });
      } finally {
        setLoading(false);
      }
    };
    bootstrap();
  }, [canAccess, courseId, form, isCreate]);
  const handleSubmit = async () => {
    const values = await form.validateFields();
    const payload = {
      ...values,
      title: values.title?.trim(),
      summary: values.summary?.trim(),
      targetUsers: values.targetUsers?.trim(),
      coverImg,
      bannerImgs,
      content: values.content,
      status: values.status ? 1 : 0,
      isHomeRecommend: values.isHomeRecommend ? 1 : 0,
    };
    setSubmitting(true);
    try {
      await (isCreate ? createHealthCourse(payload) : updateHealthCourse(courseId, payload));
      message.success(isCreate ? '新增成功' : '保存成功');
      history.replace(`${listPagePath}?refresh=${Date.now()}`);
    } finally {
      setSubmitting(false);
    }
  };
  return (
    <div className="health-course-page">
      <div className="health-course-page__panel">
        <div className="health-course-page__title">{isCreate ? '添加养生课程' : '编辑养生课程'}</div>
        <Form form={form} layout="vertical" className="health-course-edit-form">
          <div className="health-course-edit-form__section-title">课程信息</div>
          <div className="health-course-edit-form__grid">
            <Form.Item label="课程名称" name="title" rules={[{ required: true, message: '请输入课程名称' }]}>
              <Input placeholder="请输入" maxLength={128} disabled={loading} />
            </Form.Item>
            <Form.Item label="养生等级" name="levelCategoryId" rules={[{ required: true, message: '请选择养生等级' }]}>
              <Select
                placeholder="请选择"
                options={levelOptions.map((item) => ({ label: item.name, value: item.id }))}
                disabled={loading}
              />
            </Form.Item>
            <Form.Item label="难度" name="difficultyCategoryId" rules={[{ required: true, message: '请选择难度' }]}>
              <Select
                placeholder="请选择"
                options={difficultyOptions.map((item) => ({ label: item.name, value: item.id }))}
                disabled={loading}
              />
            </Form.Item>
            <Form.Item label="课程售价" name="salePrice" rules={[{ required: true, message: '请输入课程售价' }]}>
              <InputNumber
                min={0}
                precision={2}
                placeholder="请输入"
                className="health-course-edit-form__number"
              />
            </Form.Item>
            <Form.Item label="授课专家" name="expertId" rules={[{ required: true, message: '请选择授课专家' }]}>
              <Select
                placeholder="请选择"
                showSearch
                optionFilterProp="label"
                options={experts.map((item) => ({ label: item.name, value: item.id }))}
                disabled={loading}
              />
            </Form.Item>
            <div className="health-course-edit-form__switch-group">
              <div className="health-course-edit-form__switch-item">
                <span className="health-course-edit-form__switch-label">是否启用</span>
                <Form.Item
                  name="status"
                  valuePropName="checked"
                  rules={[{ required: true, message: '请选择是否启用' }]}
                >
                  <Switch checkedChildren="启用" unCheckedChildren="禁用" />
                </Form.Item>
              </div>
              <div className="health-course-edit-form__switch-item">
                <span className="health-course-edit-form__switch-label">首页推荐</span>
                <Form.Item
                  name="isHomeRecommend"
                  valuePropName="checked"
                  rules={[{ required: true, message: '请选择是否首页推荐' }]}
                >
                  <Switch checkedChildren="是" unCheckedChildren="否" />
                </Form.Item>
              </div>
            </div>
            <Form.Item
              label="基础购买量"
              name="baseSalesCount"
              rules={[{ required: true, message: '请输入基础购买量' }]}
            >
              <InputNumber min={0} precision={0} placeholder="请输入" className="health-course-edit-form__number" />
            </Form.Item>
            <Form.Item label="课程介绍" name="summary">
              <Input.TextArea rows={4} maxLength={500} placeholder="请输入" disabled={loading} />
            </Form.Item>
            <Form.Item label="适用人群" name="targetUsers">
              <Input.TextArea rows={4} maxLength={255} placeholder="请输入" disabled={loading} />
            </Form.Item>
            <Form.Item label="课程封面图" className="health-course-edit-form__upload" required>
              <Form.Item name="coverImg" rules={[{ required: true, message: '请上传课程封面图' }]} hidden>
                <Input />
              </Form.Item>
              <Upload
                accept="image/*"
                showUploadList={false}
                customRequest={async (options) => {
                  try {
                    const res = await uploadHealthCourseImage(options.file as File);
                    const url = res.data?.url;
                    if (!url) {
                      throw new Error('upload failed');
                    }
                    setCoverImg(url);
                    form.setFieldsValue({ coverImg: url });
                    options.onSuccess?.(res);
                  } catch (error) {
                    message.error('课程封面图上传失败');
                    options.onError?.(error as Error);
                  }
                }}
              >
                <div className="health-course-edit-form__cover-box">
                  {coverImg ? (
                    <Image src={coverImg} width={120} height={80} preview={false} />
                  ) : (
                    <PlusOutlined />
                  )}
                </div>
              </Upload>
            </Form.Item>
            <Form.Item label="课程宣传图" className="health-course-edit-form__upload" required>
              <Form.Item name="bannerImgs" rules={[{ required: true, message: '请上传课程宣传图' }]} hidden>
                <Input />
              </Form.Item>
              <div className="health-course-banner-list">
                {bannerImgs.map((item) => (
                  <div key={item} className="health-course-banner-list__item">
                    <Image src={item} width={120} height={80} preview={false} />
                    <button type="button" onClick={() => syncBannerField(bannerImgs.filter((url) => url !== item))}>
                      删除
                    </button>
                  </div>
                ))}
                <Upload
                  accept="image/*"
                  showUploadList={false}
                  customRequest={async (options) => {
                    try {
                      const res = await uploadHealthCourseImage(options.file as File);
                      const url = res.data?.url;
                      if (!url) {
                        throw new Error('upload failed');
                      }
                      syncBannerField([...bannerImgs, url]);
                      options.onSuccess?.(res);
                    } catch (error) {
                      message.error('课程宣传图上传失败');
                      options.onError?.(error as Error);
                    }
                  }}
                >
                  <div className="health-course-banner-list__upload">
                    <PlusOutlined />
                    <span>上传图片</span>
                  </div>
                </Upload>
              </div>
            </Form.Item>
          </div>
          <Form.Item label="课程介绍" name="content" rules={[{ required: true, message: '请输入课程介绍' }]}>
            <div className="health-course-edit-form__editor">
              <BraftEditorCom
                data={form.getFieldValue('content') || ''}
                resultCallback={(html: string) => form.setFieldsValue({ content: html })}
              />
            </div>
          </Form.Item>
          <div className="health-course-edit-form__footer">
            <Button onClick={() => history.push(isCreate ? listPagePath : `/live/course/health/${courseId}`)}>
              取消
            </Button>
            <Button type="primary" loading={submitting} onClick={handleSubmit}>
              保存
            </Button>
          </div>
        </Form>
      </div>
    </div>
  );
};
export default HealthCourseManagePage;
tcm-training-admin/src/pages/live/health/service.ts
New file
@@ -0,0 +1,121 @@
import { decryptData } from '@/utils/utils';
import { request } from '@umijs/max';
export async function getHealthCoursePage(params?: Record<string, any>) {
  return request('/health-courses', {
    method: 'GET',
    params,
  });
}
export async function exportHealthCourses(params: Record<string, any>) {
  const authorization = decryptData(localStorage.getItem('token')) || '';
  const query = new URLSearchParams();
  Object.keys(params).forEach((key) => {
    const value = params[key];
    if (value !== undefined && value !== null && value !== '') {
      query.append(key, String(value));
    }
  });
  const response = await fetch(`${BASE_URL}/health-courses/export?${query.toString()}`, {
    method: 'GET',
    headers: {
      Authorization: authorization,
    },
  });
  if (!response.ok) {
    throw new Error('导出失败');
  }
  return response.blob();
}
export async function getHealthCourseDetail(id: number | string) {
  return request(`/health-courses/${id}`, {
    method: 'GET',
  });
}
export async function createHealthCourse(data: Record<string, any>) {
  return request('/health-courses', {
    method: 'POST',
    data,
  });
}
export async function updateHealthCourse(id: number | string, data: Record<string, any>) {
  return request(`/health-courses/${id}`, {
    method: 'PUT',
    data,
  });
}
export async function updateHealthCourseStatus(id: number | string, data: { status: number }) {
  return request(`/health-courses/${id}/status`, {
    method: 'PATCH',
    data,
  });
}
export async function deleteHealthCourse(id: number | string) {
  return request(`/health-courses/${id}`, {
    method: 'DELETE',
  });
}
export async function getHealthCourseVideos(
  courseId: number | string,
  params?: Record<string, any>,
) {
  return request(`/health-courses/${courseId}/videos`, {
    method: 'GET',
    params,
  });
}
export async function getHealthCourseVideoDetail(
  courseId: number | string,
  videoId: number | string,
) {
  return request(`/health-courses/${courseId}/videos/${videoId}`, {
    method: 'GET',
  });
}
export async function createHealthCourseVideo(
  courseId: number | string,
  data: Record<string, any>,
) {
  return request(`/health-courses/${courseId}/videos`, {
    method: 'POST',
    data,
  });
}
export async function updateHealthCourseVideo(
  courseId: number | string,
  videoId: number | string,
  data: Record<string, any>,
) {
  return request(`/health-courses/${courseId}/videos/${videoId}`, {
    method: 'PUT',
    data,
  });
}
export async function deleteHealthCourseVideo(
  courseId: number | string,
  videoId: number | string,
) {
  return request(`/health-courses/${courseId}/videos/${videoId}`, {
    method: 'DELETE',
  });
}
export async function uploadHealthCourseImage(file: File) {
  const formData = new FormData();
  formData.append('file', file);
  return request('/upload/image', {
    method: 'POST',
    data: formData,
  });
}
tcm-training-admin/src/pages/live/health/style.less
New file
@@ -0,0 +1,337 @@
.health-course-page {
  padding: 18px 20px 24px;
  min-height: calc(100vh - 48px);
  background: #f2f5f9;
}
.health-course-page__panel {
  padding: 18px 18px 24px;
  border-radius: 10px;
  background: #fff;
  box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
}
.health-course-page__title {
  margin-bottom: 16px;
  color: #303133;
  font-size: 20px;
  font-weight: 500;
}
.health-course-search-card {
  padding: 16px 18px 10px;
  border: 1px solid #e7edf4;
  border-radius: 6px;
}
.health-course-search-form {
  display: grid;
  grid-template-columns: repeat(4, minmax(220px, 1fr)) auto;
  gap: 10px 18px;
  align-items: start;
}
.health-course-search-form .ant-form-item {
  margin-bottom: 0;
}
.health-course-search-form .ant-form-item-row {
  display: grid;
  grid-template-columns: 88px minmax(0, 1fr);
  align-items: center;
}
.health-course-search-form .ant-form-item-label {
  padding: 0;
}
.health-course-search-form__actions {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  min-width: 160px;
}
.health-course-toolbar {
  display: flex;
  justify-content: space-between;
  margin: 14px 0 10px;
}
.health-course-table-card {
  overflow: hidden;
  border: 1px solid #e7edf4;
  border-radius: 6px;
}
.health-course-table-card .ant-table-thead > tr > th {
  background: #f8fafc;
  color: #303133;
  font-weight: 500;
}
.health-course-table__course {
  display: flex;
  align-items: center;
  gap: 10px;
}
.health-course-table__cover-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 62px;
  height: 44px;
  border-radius: 6px;
  background: #edf2f7;
  color: #64748b;
  font-size: 14px;
  font-weight: 600;
}
.health-course-table__actions {
  button {
    padding: 0;
    border: none;
    background: none;
    color: #1677ff;
    cursor: pointer;
  }
}
.health-course-pagination {
  display: flex;
  justify-content: flex-end;
  padding: 16px 18px;
}
.health-course-edit-form__section-title {
  margin-bottom: 16px;
  padding-left: 8px;
  border-left: 3px solid #1677ff;
  color: #303133;
  font-size: 15px;
  font-weight: 500;
}
.health-course-edit-form__grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0 36px;
}
.health-course-edit-form .ant-form-item,
.health-course-video-form .ant-form-item {
  margin-bottom: 18px;
}
.health-course-edit-form__number {
  width: 100%;
}
.health-course-edit-form__switch-group {
  display: flex;
  align-items: center;
  gap: 24px;
  min-height: 72px;
}
.health-course-edit-form__switch-item {
  display: flex;
  align-items: center;
  gap: 10px;
}
.health-course-edit-form__switch-label {
  color: #606266;
  white-space: nowrap;
}
.health-course-edit-form__switch-item .ant-form-item {
  margin-bottom: 0;
}
.health-course-edit-form__upload {
  grid-column: span 1;
}
.health-course-edit-form__cover-box {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 120px;
  height: 80px;
  overflow: hidden;
  border: 1px dashed #cbd5e1;
  border-radius: 8px;
  background: #f8fafc;
  color: #94a3b8;
  font-size: 26px;
  cursor: pointer;
}
.health-course-banner-list {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}
.health-course-banner-list__item,
.health-course-banner-list__upload {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.health-course-banner-list__item button {
  padding: 0;
  border: none;
  background: none;
  color: #1677ff;
  text-align: left;
  cursor: pointer;
}
.health-course-banner-list__upload {
  align-items: center;
  justify-content: center;
  width: 120px;
  height: 80px;
  border: 1px dashed #cbd5e1;
  border-radius: 8px;
  background: #f8fafc;
  color: #94a3b8;
  cursor: pointer;
}
.health-course-edit-form__editor {
  border: 1px solid #dcdfe6;
}
.health-course-edit-form__footer {
  display: flex;
  justify-content: flex-start;
  gap: 12px;
  margin-top: 12px;
}
.health-course-detail-header {
  display: flex;
  justify-content: space-between;
  gap: 20px;
  padding-bottom: 16px;
}
.health-course-detail-header__main {
  display: flex;
  gap: 16px;
}
.health-course-detail-header__meta h2 {
  margin: 0 0 8px;
  font-size: 22px;
}
.health-course-detail-header__tags,
.health-course-detail-header__stats,
.health-course-detail-header__actions {
  display: flex;
  gap: 12px;
  align-items: center;
  flex-wrap: wrap;
}
.health-course-detail-header__tags span {
  padding: 2px 8px;
  border-radius: 10px;
  background: #fef3c7;
  color: #b45309;
  font-size: 12px;
}
.health-course-detail-header__stats span:first-child {
  color: #ef4444;
}
.health-course-detail-tabs {
  display: inline-flex;
  overflow: hidden;
  margin-bottom: 18px;
  border: 1px solid #dbeafe;
  border-radius: 6px;
}
.health-course-detail-tabs button {
  min-width: 110px;
  padding: 8px 18px;
  border: none;
  background: #fff;
  color: #1677ff;
  cursor: pointer;
}
.health-course-detail-tabs button.is-active {
  background: #1677ff;
  color: #fff;
}
.health-course-detail-card {
  border-top: 1px solid #eef2f7;
  padding-top: 18px;
}
.health-course-detail-card__grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 14px 28px;
  margin-bottom: 16px;
  color: #475569;
}
.health-course-detail-card__images {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  margin-bottom: 16px;
}
.health-course-detail-card__content {
  color: #475569;
  line-height: 1.7;
}
.health-course-search-form--video {
  grid-template-columns: minmax(260px, 1fr) minmax(220px, 1fr) auto;
}
@media (max-width: 1200px) {
  .health-course-search-form,
  .health-course-edit-form__grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
@media (max-width: 768px) {
  .health-course-page {
    padding: 12px;
  }
  .health-course-search-form,
  .health-course-search-form--video,
  .health-course-edit-form__grid,
  .health-course-detail-card__grid {
    grid-template-columns: 1fr;
  }
  .health-course-search-form__actions,
  .health-course-toolbar,
  .health-course-detail-header,
  .health-course-detail-header__main {
    flex-direction: column;
    align-items: flex-start;
  }
  .health-course-pagination {
    justify-content: center;
  }
}
tcm-training-admin/src/pages/live/health/video-manage.tsx
New file
@@ -0,0 +1,130 @@
import { history, useAccess } from '@umijs/max';
import { Button, Form, Input, InputNumber, Radio, message } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import {
  createHealthCourseVideo,
  getHealthCourseVideoDetail,
  updateHealthCourseVideo,
} from './service';
import './style.less';
const HealthCourseVideoManagePage: React.FC = () => {
  const access = useAccess();
  const [form] = Form.useForm();
  const [submitting, setSubmitting] = useState(false);
  const pathname = history.location.pathname;
  const isCreate = pathname.endsWith('/create');
  const segments = useMemo(() => pathname.split('/'), [pathname]);
  const courseId = isCreate ? segments[segments.length - 3] || '' : segments[segments.length - 4] || '';
  const videoId = isCreate ? '' : segments[segments.length - 2] || '';
  const canAccess = isCreate ? access['live:health-course:video:add'] : access['live:health-course:video:edit'];
  useEffect(() => {
    if (!canAccess) {
      history.replace('/Welcome');
      return;
    }
    const bootstrap = async () => {
      if (isCreate) {
        form.setFieldsValue({
          saleType: 0,
          salePrice: 0,
          playCount: 0,
          durationSeconds: 0,
          status: 1,
        });
        return;
      }
      const res = await getHealthCourseVideoDetail(courseId, videoId);
      form.setFieldsValue({
        chapterName: res.data.chapterName,
        chapterRemark: res.data.chapterRemark,
        videoUrl: res.data.videoUrl,
        durationSeconds: res.data.durationSeconds,
        saleType: res.data.saleType,
        salePrice: res.data.salePrice,
        playCount: res.data.playCount,
        status: res.data.status,
      });
    };
    bootstrap();
  }, [canAccess, courseId, form, isCreate, videoId]);
  const handleSubmit = async () => {
    const values = await form.validateFields();
    const payload = {
      ...values,
      chapterName: values.chapterName?.trim(),
      chapterRemark: values.chapterRemark?.trim(),
      videoUrl: values.videoUrl?.trim(),
      salePrice: values.saleType === 0 ? 0 : values.salePrice,
    };
    setSubmitting(true);
    try {
      if (isCreate) {
        await createHealthCourseVideo(courseId, payload);
        message.success('新增成功');
      } else {
        await updateHealthCourseVideo(courseId, videoId, payload);
        message.success('保存成功');
      }
      history.push(`/live/course/health/${courseId}?tab=videos`);
    } finally {
      setSubmitting(false);
    }
  };
  return (
    <div className="health-course-page">
      <div className="health-course-page__panel">
        <div className="health-course-page__title">{isCreate ? '添加视频课程' : '编辑视频课程'}</div>
        <Form form={form} layout="vertical" className="health-course-video-form">
          <div className="health-course-edit-form__grid">
            <Form.Item label="小节名称" name="chapterName" rules={[{ required: true, message: '请输入小节名称' }]}>
              <Input placeholder="请输入" maxLength={128} />
            </Form.Item>
            <Form.Item label="售价类型" name="saleType" rules={[{ required: true, message: '请选择售价类型' }]}>
              <Radio.Group>
                <Radio value={0}>免费</Radio>
                <Radio value={1}>收费</Radio>
              </Radio.Group>
            </Form.Item>
            <Form.Item label="小节备注" name="chapterRemark">
              <Input.TextArea rows={4} placeholder="请输入" maxLength={500} />
            </Form.Item>
            <Form.Item label="视频时长(秒)" name="durationSeconds" rules={[{ required: true, message: '请输入视频时长' }]}>
              <InputNumber min={0} precision={0} className="health-course-edit-form__number" />
            </Form.Item>
            <Form.Item label="视频地址" name="videoUrl" rules={[{ required: true, message: '请输入视频地址' }]}>
              <Input placeholder="请输入" />
            </Form.Item>
            <Form.Item
              label="售价"
              name="salePrice"
              rules={[{ required: true, message: '请输入售价' }]}
            >
              <InputNumber min={0} precision={2} className="health-course-edit-form__number" />
            </Form.Item>
            <Form.Item label="播放次数" name="playCount" rules={[{ required: true, message: '请输入播放次数' }]}>
              <InputNumber min={0} precision={0} className="health-course-edit-form__number" />
            </Form.Item>
            <Form.Item label="状态" name="status" rules={[{ required: true, message: '请选择状态' }]}>
              <Radio.Group>
                <Radio value={1}>启用</Radio>
                <Radio value={0}>禁用</Radio>
              </Radio.Group>
            </Form.Item>
          </div>
          <div className="health-course-edit-form__footer">
            <Button onClick={() => history.push(`/live/course/health/${courseId}?tab=videos`)}>取消</Button>
            <Button type="primary" loading={submitting} onClick={handleSubmit}>
              保存
            </Button>
          </div>
        </Form>
      </div>
    </div>
  );
};
export default HealthCourseVideoManagePage;