| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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> |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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', |
| | | }); |
| | | } |
| New file |
| | |
| | | .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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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, |
| | | }); |
| | | } |
| New file |
| | |
| | | .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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |