Gqingci před 1 týdnem
rodič
revize
7acbf637d3

+ 2 - 2
ruoyi-admin/src/main/resources/application.yml

@@ -72,9 +72,9 @@ spring:
   servlet:
     multipart:
       # 单个文件大小
-      max-file-size: 20MB
+      max-file-size: 10GB
       # 设置总上传的文件大小
-      max-request-size: 50MB
+      max-request-size: 10GB
   mvc:
     # 设置静态资源路径 防止所有请求都去查静态资源
     static-path-pattern: /static/**

+ 181 - 1
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/MainTrainingController.java

@@ -1,4 +1,184 @@
 package org.dromara.main.controller;
 
-public class MainTrainingController {
+import cn.hutool.core.util.ObjectUtil;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.excel.utils.ExcelUtil;
+import org.dromara.common.idempotent.annotation.RepeatSubmit;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.main.domain.bo.MainTrainingBo;
+import org.dromara.main.domain.vo.MainTrainingLearnRecordVo;
+import org.dromara.main.domain.vo.MainTrainingParticipantVo;
+import org.dromara.main.domain.vo.MainTrainingVo;
+import org.dromara.main.service.IMainTrainingService;
+import org.dromara.system.domain.vo.SysOssUploadVo;
+import org.dromara.system.domain.vo.SysOssVo;
+import org.dromara.system.service.ISysOssService;
+import org.springframework.http.MediaType;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import jakarta.servlet.http.HttpServletResponse;
+import java.util.List;
+
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/main/training")
+public class MainTrainingController extends BaseController {
+
+    private final IMainTrainingService mainTrainingService;
+    private final ISysOssService ossService;
+
+    /**
+     * 查询培训列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo<MainTrainingVo> list(MainTrainingBo bo, PageQuery pageQuery) {
+        return mainTrainingService.queryPageList(bo, pageQuery);
+    }
+
+    /**
+     * 查询线下培训列表
+     */
+    @GetMapping("/offline/list")
+    public TableDataInfo<MainTrainingVo> offlineList(MainTrainingBo bo, PageQuery pageQuery) {
+        return mainTrainingService.queryOfflinePageList(bo, pageQuery);
+    }
+
+    /**
+     * 获取培训详细信息
+     *
+     * @param id 主键
+     */
+    @GetMapping("/{id}")
+    public R<MainTrainingVo> getInfo(@NotNull(message = "主键不能为空") @PathVariable Long id) {
+        return R.ok(mainTrainingService.queryById(id));
+    }
+
+    /**
+     * 获取线下培训详细信息
+     */
+    @GetMapping("/offline/{id}")
+    public R<MainTrainingVo> getOfflineInfo(@NotNull(message = "主键不能为空") @PathVariable Long id) {
+        MainTrainingVo vo = mainTrainingService.queryOfflineById(id);
+        return vo == null ? R.fail("线下培训不存在") : R.ok(vo);
+    }
+
+    /**
+     * 查询视频培训学习记录分页
+     */
+    @GetMapping("/{id}/learn-records")
+    public TableDataInfo<MainTrainingLearnRecordVo> learnRecords(@NotNull(message = "主键不能为空") @PathVariable Long id, PageQuery pageQuery) {
+        return mainTrainingService.queryLearnRecordPage(id, pageQuery);
+    }
+
+    /**
+     * 查询线下培训参与人分页
+     */
+    @GetMapping("/offline/{id}/participants")
+    public TableDataInfo<MainTrainingParticipantVo> offlineParticipants(@NotNull(message = "主键不能为空") @PathVariable Long id, PageQuery pageQuery) {
+        return mainTrainingService.queryOfflineParticipantPage(id, pageQuery);
+    }
+
+    /**
+     * 导出视频培训学习记录
+     */
+    @Log(title = "培训管理", businessType = BusinessType.EXPORT)
+    @PostMapping("/{id}/learn-records/export")
+    public void exportLearnRecords(@NotNull(message = "主键不能为空") @PathVariable Long id, @RequestBody List<Long> studentIds, HttpServletResponse response) {
+        List<MainTrainingLearnRecordVo> list = mainTrainingService.queryLearnRecordExportList(id, studentIds);
+        ExcelUtil.exportExcel(list, "学习记录", MainTrainingLearnRecordVo.class, response);
+    }
+
+    /**
+     * 导出线下培训参与人
+     */
+    @Log(title = "培训管理", businessType = BusinessType.EXPORT)
+    @PostMapping("/offline/{id}/participants/export")
+    public void exportOfflineParticipants(@NotNull(message = "主键不能为空") @PathVariable Long id, @RequestBody List<Long> studentIds, HttpServletResponse response) {
+        List<MainTrainingParticipantVo> list = mainTrainingService.queryOfflineParticipantExportList(id, studentIds);
+        ExcelUtil.exportExcel(list, "线下培训参与人", MainTrainingParticipantVo.class, response);
+    }
+
+    /**
+     * 新增培训
+     */
+    @Log(title = "培训管理", businessType = BusinessType.INSERT)
+    @RepeatSubmit()
+    @PostMapping()
+    public R<Void> add(@RequestBody MainTrainingBo bo) {
+        return toAjax(mainTrainingService.insertByBo(bo));
+    }
+
+    /**
+     * 培训文件上传
+     */
+    @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public R<SysOssUploadVo> upload(@RequestPart("file") MultipartFile file) {
+        if (ObjectUtil.isNull(file)) {
+            return R.fail("上传文件不能为空");
+        }
+        SysOssVo oss = ossService.upload(file);
+        SysOssUploadVo uploadVo = new SysOssUploadVo();
+        uploadVo.setUrl(oss.getUrl());
+        uploadVo.setFileName(oss.getOriginalName());
+        uploadVo.setOssId(oss.getOssId().toString());
+        return R.ok(uploadVo);
+    }
+
+    /**
+     * 修改培训
+     */
+    @Log(title = "培训管理", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PutMapping()
+    public R<Void> edit(@RequestBody MainTrainingBo bo) {
+        return toAjax(mainTrainingService.updateByBo(bo));
+    }
+
+    /**
+     * 删除培训
+     *
+     * @param ids 主键串
+     */
+    @Log(title = "培训管理", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public R<Void> remove(@NotEmpty(message = "主键不能为空") @PathVariable Long[] ids) {
+        return toAjax(mainTrainingService.deleteWithValidByIds(List.of(ids), true));
+    }
+
+    /**
+     * 删除线下培训
+     */
+    @Log(title = "培训管理", businessType = BusinessType.DELETE)
+    @DeleteMapping("/offline/{id}")
+    public R<Void> removeOffline(@NotNull(message = "主键不能为空") @PathVariable Long id) {
+        return toAjax(mainTrainingService.deleteOfflineById(id));
+    }
+
+    /**
+     * 更新培训状态(上架/下架)
+     */
+    @Log(title = "培训管理", businessType = BusinessType.UPDATE)
+    @PutMapping("/updateStatus")
+    public R<Void> updateStatus(@RequestBody MainTrainingBo bo) {
+        return toAjax(mainTrainingService.updateStatus(bo.getId(), bo.getStatus()));
+    }
+
+    /**
+     * 更新线下培训状态(上架/下架)
+     */
+    @Log(title = "培训管理", businessType = BusinessType.UPDATE)
+    @PutMapping("/offline/updateStatus")
+    public R<Void> updateOfflineStatus(@RequestBody MainTrainingBo bo) {
+        return toAjax(mainTrainingService.updateOfflineStatus(bo.getId(), bo.getStatus()));
+    }
 }

+ 140 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/MainTraining.java

@@ -0,0 +1,140 @@
+package org.dromara.main.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+
+import java.util.Date;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("main_training")
+public class MainTraining extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 培训ID
+     */
+    @TableId(value = "id")
+    private Long id;
+
+    /**
+     * 培训类型: video=视频培训, offline=线下培训, live=直播培训
+     */
+    private String trainingType;
+
+    /**
+     * 培训名称
+     */
+    private String name;
+
+    /**
+     * 培训描述
+     */
+    private String description;
+
+    /**
+     * 封面图ID(关联 sys_oss.oss_id)
+     */
+    private Long thumbnail;
+
+    /**
+     * 岗位类型(全职/兼职/实习)
+     */
+    private String jobType;
+
+    /**
+     * 岗位等级(A1/A2/B1/B2)
+     */
+    private String jobLevel;
+
+    /**
+     * 岗位名称
+     */
+    private String job;
+
+    /**
+     * 排序号
+     */
+    private Integer sortOrder;
+
+    /**
+     * 状态: 0=下架, 1=上架
+     */
+    private Integer status;
+
+    /**
+     * 总时长
+     */
+    private String duration;
+
+    /**
+     * 上架时间
+     */
+    private Date publishTime;
+
+    /**
+     * 培训城市
+     */
+    private String city;
+
+    /**
+     * 培训区域
+     */
+    private String area;
+
+    /**
+     * 详细地址
+     */
+    private String addressDetail;
+
+    /**
+     * 培训开始时间
+     */
+    private Date trainingStartTime;
+
+    /**
+     * 培训结束时间
+     */
+    private Date trainingEndTime;
+
+    /**
+     * 报名开始时间
+     */
+    private Date applyStartTime;
+
+    /**
+     * 报名结束时间
+     */
+    private Date applyEndTime;
+
+    /**
+     * 主办单位
+     */
+    private String organizer;
+
+    /**
+     * 标签(逗号分隔)
+     */
+    private String tags;
+
+    /**
+     * 租户编号
+     */
+    private String tenantId;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**
+     * 删除标志(0代表存在 1代表删除)
+     */
+    @TableLogic
+    private String delFlag;
+}

+ 68 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/MainTrainingVideo.java

@@ -0,0 +1,68 @@
+package org.dromara.main.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("main_training_video")
+public class MainTrainingVideo extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 视频ID
+     */
+    @TableId(value = "id")
+    private Long id;
+
+    /**
+     * 关联培训ID
+     */
+    private Long trainingId;
+
+    /**
+     * 视频名称
+     */
+    private String name;
+
+    /**
+     * 视频文件ID(关联 sys_oss.oss_id)
+     */
+    private Long ossId;
+
+    /**
+     * 视频文件URL
+     */
+    private String fileUrl;
+
+    /**
+     * 文件大小
+     */
+    private String fileSize;
+
+    /**
+     * 文件格式
+     */
+    private String fileType;
+
+    /**
+     * 视频时长
+     */
+    private String duration;
+
+    /**
+     * 排序
+     */
+    private Integer sortOrder;
+
+    /**
+     * 删除标志(0代表存在 1代表删除)
+     */
+    @TableLogic
+    private String delFlag;
+}

+ 148 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/bo/MainTrainingBo.java

@@ -0,0 +1,148 @@
+package org.dromara.main.domain.bo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+import org.dromara.main.domain.MainTraining;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@AutoMapper(target = MainTraining.class, reverseConvertGenerate = false)
+public class MainTrainingBo extends BaseEntity {
+
+    /**
+     * 培训ID
+     */
+    private Long id;
+
+    /**
+     * 培训类型: video=视频培训, offline=线下培训, live=直播培训
+     */
+    private String trainingType;
+
+    /**
+     * 培训名称
+     */
+    private String name;
+
+    /**
+     * 培训描述
+     */
+    private String description;
+
+    /**
+     * 封面图ID
+     */
+    private Long thumbnail;
+
+    /**
+     * 岗位类型
+     */
+    private String jobType;
+
+    /**
+     * 岗位等级
+     */
+    private String jobLevel;
+
+    /**
+     * 岗位名称
+     */
+    private String job;
+
+    /**
+     * 排序号
+     */
+    private Integer sortOrder;
+
+    /**
+     * 状态: 0=下架, 1=上架
+     */
+    private Integer status;
+
+    /**
+     * 总时长
+     */
+    private String duration;
+
+    /**
+     * 培训城市
+     */
+    private String city;
+
+    /**
+     * 培训区域
+     */
+    private String area;
+
+    /**
+     * 详细地址
+     */
+    private String addressDetail;
+
+    /**
+     * 培训开始时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date trainingStartTime;
+
+    /**
+     * 培训结束时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date trainingEndTime;
+
+    /**
+     * 报名开始时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date applyStartTime;
+
+    /**
+     * 报名结束时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date applyEndTime;
+
+    /**
+     * 主办单位
+     */
+    private String organizer;
+
+    /**
+     * 标签(逗号分隔)
+     */
+    private String tags;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**
+     * 视频列表(新增/修改时使用)
+     */
+    private List<TrainingVideoBo> videoList;
+
+    @Data
+    public static class TrainingVideoBo {
+        private Long id;
+        private String name;
+        private Long ossId;
+        private String fileUrl;
+        private String fileSize;
+        private String fileType;
+        private String duration;
+        private Integer sortOrder;
+    }
+}

+ 49 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/MainTrainingLearnRecordVo.java

@@ -0,0 +1,49 @@
+package org.dromara.main.domain.vo;
+
+import cn.idev.excel.annotation.ExcelProperty;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import org.dromara.common.translation.annotation.Translation;
+import org.dromara.common.translation.constant.TransConstant;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+public class MainTrainingLearnRecordVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @ExcelProperty(value = "学员ID")
+    private Long studentId;
+
+    @ExcelProperty(value = "姓名")
+    private String name;
+
+    @ExcelProperty(value = "手机号")
+    private String mobile;
+
+    private Long avatar;
+
+    @Translation(type = TransConstant.OSS_ID_TO_URL, mapper = "avatar")
+    private String avatarUrl;
+
+    @ExcelProperty(value = "已学习时长(分钟)")
+    private Integer learnedTime;
+
+    @ExcelProperty(value = "剩余时长(分钟)")
+    private Integer remainingTime;
+
+    @ExcelProperty(value = "学习进度(%)")
+    private Integer progress;
+
+    @ExcelProperty(value = "完成时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date finishTime;
+
+    @ExcelProperty(value = "上次学习时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date lastLearnTime;
+}

+ 40 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/MainTrainingParticipantVo.java

@@ -0,0 +1,40 @@
+package org.dromara.main.domain.vo;
+
+import cn.idev.excel.annotation.ExcelProperty;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import org.dromara.common.translation.annotation.Translation;
+import org.dromara.common.translation.constant.TransConstant;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+public class MainTrainingParticipantVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @ExcelProperty(value = "学员ID")
+    private Long studentId;
+
+    @ExcelProperty(value = "姓名")
+    private String name;
+
+    @ExcelProperty(value = "手机号")
+    private String mobile;
+
+    private Long avatar;
+
+    @Translation(type = TransConstant.OSS_ID_TO_URL, mapper = "avatar")
+    private String avatarUrl;
+
+    @ExcelProperty(value = "报名时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date enrollTime;
+
+    @ExcelProperty(value = "签到时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date checkInTime;
+}

+ 61 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/MainTrainingVideoVo.java

@@ -0,0 +1,61 @@
+package org.dromara.main.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.main.domain.MainTrainingVideo;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+@Data
+@AutoMapper(target = MainTrainingVideo.class)
+public class MainTrainingVideoVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 视频ID
+     */
+    private Long id;
+
+    /**
+     * 关联培训ID
+     */
+    private Long trainingId;
+
+    /**
+     * 视频名称
+     */
+    private String name;
+
+    /**
+     * 视频文件ID
+     */
+    private Long ossId;
+
+    /**
+     * 视频文件URL
+     */
+    private String fileUrl;
+
+    /**
+     * 文件大小
+     */
+    private String fileSize;
+
+    /**
+     * 文件格式
+     */
+    private String fileType;
+
+    /**
+     * 视频时长
+     */
+    private String duration;
+
+    /**
+     * 排序
+     */
+    private Integer sortOrder;
+}

+ 163 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/vo/MainTrainingVo.java

@@ -0,0 +1,163 @@
+package org.dromara.main.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.common.translation.annotation.Translation;
+import org.dromara.common.translation.constant.TransConstant;
+import org.dromara.main.domain.MainTraining;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+import java.util.List;
+
+@Data
+@AutoMapper(target = MainTraining.class)
+public class MainTrainingVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 培训ID
+     */
+    private Long id;
+
+    /**
+     * 培训类型
+     */
+    private String trainingType;
+
+    /**
+     * 培训名称
+     */
+    private String name;
+
+    /**
+     * 培训描述
+     */
+    private String description;
+
+    /**
+     * 封面图ID
+     */
+    private Long thumbnail;
+
+    /**
+     * 封面图URL(自动翻译)
+     */
+    @Translation(type = TransConstant.OSS_ID_TO_URL, mapper = "thumbnail")
+    private String thumbnailUrl;
+
+    /**
+     * 岗位类型
+     */
+    private String jobType;
+
+    /**
+     * 岗位等级
+     */
+    private String jobLevel;
+
+    /**
+     * 岗位名称
+     */
+    private String job;
+
+    /**
+     * 排序号
+     */
+    private Integer sortOrder;
+
+    /**
+     * 状态: 0=下架, 1=上架
+     */
+    private Integer status;
+
+    /**
+     * 总时长
+     */
+    private String duration;
+
+    /**
+     * 上架时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date publishTime;
+
+    /**
+     * 培训城市
+     */
+    private String city;
+
+    /**
+     * 培训区域
+     */
+    private String area;
+
+    /**
+     * 详细地址
+     */
+    private String addressDetail;
+
+    /**
+     * 培训开始时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date trainingStartTime;
+
+    /**
+     * 培训结束时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date trainingEndTime;
+
+    /**
+     * 报名开始时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date applyStartTime;
+
+    /**
+     * 报名结束时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date applyEndTime;
+
+    /**
+     * 主办单位
+     */
+    private String organizer;
+
+    /**
+     * 标签
+     */
+    private String tags;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /**
+     * 学习记录数(视频培训用)
+     */
+    private Integer learnCount;
+
+    /**
+     * 参与人数(线下培训用)
+     */
+    private Integer participantCount;
+
+    /**
+     * 视频列表
+     */
+    private List<MainTrainingVideoVo> videoList;
+}

+ 120 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/MainTrainingMapper.java

@@ -0,0 +1,120 @@
+package org.dromara.main.mapper;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.main.domain.MainTraining;
+import org.dromara.main.domain.vo.MainTrainingLearnRecordVo;
+import org.dromara.main.domain.vo.MainTrainingParticipantVo;
+import org.dromara.main.domain.vo.MainTrainingVo;
+
+import java.util.List;
+
+@Mapper
+public interface MainTrainingMapper extends BaseMapperPlus<MainTraining, MainTrainingVo> {
+
+    /**
+     * 统计培训的学习记录数
+     */
+    @Select("SELECT COUNT(*) FROM main_training_learn_record WHERE training_id = #{trainingId} AND del_flag = '0'")
+    Long countLearnRecords(@Param("trainingId") Long trainingId);
+
+    /**
+     * 统计培训的报名人数
+     */
+    @Select("SELECT COUNT(*) FROM main_training_enrollment WHERE training_id = #{trainingId} AND del_flag = '0'")
+    Long countEnrollments(@Param("trainingId") Long trainingId);
+
+    /**
+     * 查询视频培训学习记录分页
+     */
+    @Select("""
+        SELECT r.student_id AS studentId,
+               s.name AS name,
+               s.mobile AS mobile,
+               s.avatar AS avatar,
+               r.learned_time AS learnedTime,
+               r.remaining_time AS remainingTime,
+               r.progress AS progress,
+               r.finish_time AS finishTime,
+               r.last_learn_time AS lastLearnTime
+          FROM main_training_learn_record r
+          LEFT JOIN main_student s ON s.id = r.student_id AND s.del_flag = '0'
+         WHERE r.training_id = #{trainingId}
+           AND r.del_flag = '0'
+         ORDER BY r.last_learn_time DESC, r.id DESC
+        """)
+    IPage<MainTrainingLearnRecordVo> selectLearnRecordPage(Page<MainTrainingLearnRecordVo> page, @Param("trainingId") Long trainingId);
+
+    /**
+     * 按学员ID导出视频培训学习记录
+     */
+    @Select("""
+        <script>
+        SELECT r.student_id AS studentId,
+               s.name AS name,
+               s.mobile AS mobile,
+               s.avatar AS avatar,
+               r.learned_time AS learnedTime,
+               r.remaining_time AS remainingTime,
+               r.progress AS progress,
+               r.finish_time AS finishTime,
+               r.last_learn_time AS lastLearnTime
+          FROM main_training_learn_record r
+          LEFT JOIN main_student s ON s.id = r.student_id AND s.del_flag = '0'
+         WHERE r.training_id = #{trainingId}
+           AND r.del_flag = '0'
+           AND r.student_id IN
+           <foreach collection='studentIds' item='studentId' open='(' separator=',' close=')'>
+             #{studentId}
+           </foreach>
+         ORDER BY r.last_learn_time DESC, r.id DESC
+        </script>
+        """)
+    List<MainTrainingLearnRecordVo> selectLearnRecordListByStudentIds(@Param("trainingId") Long trainingId, @Param("studentIds") List<Long> studentIds);
+
+    /**
+     * 查询线下培训参与人分页
+     */
+    @Select("""
+        SELECT s.id AS studentId,
+               s.name AS name,
+               s.mobile AS mobile,
+               s.avatar AS avatar,
+               e.enroll_time AS enrollTime,
+               e.check_in_time AS checkInTime
+          FROM main_training_enrollment e
+          LEFT JOIN main_student s ON s.id = e.student_id AND s.del_flag = '0'
+         WHERE e.training_id = #{trainingId}
+           AND e.del_flag = '0'
+         ORDER BY e.enroll_time DESC, e.id DESC
+        """)
+    IPage<MainTrainingParticipantVo> selectOfflineParticipantPage(Page<MainTrainingParticipantVo> page, @Param("trainingId") Long trainingId);
+
+    /**
+     * 按学员ID导出线下培训参与人
+     */
+    @Select("""
+        <script>
+        SELECT s.id AS studentId,
+               s.name AS name,
+               s.mobile AS mobile,
+               s.avatar AS avatar,
+               e.enroll_time AS enrollTime,
+               e.check_in_time AS checkInTime
+          FROM main_training_enrollment e
+          LEFT JOIN main_student s ON s.id = e.student_id AND s.del_flag = '0'
+         WHERE e.training_id = #{trainingId}
+           AND e.del_flag = '0'
+           AND e.student_id IN
+           <foreach collection='studentIds' item='studentId' open='(' separator=',' close=')'>
+             #{studentId}
+           </foreach>
+         ORDER BY e.enroll_time DESC, e.id DESC
+        </script>
+        """)
+    List<MainTrainingParticipantVo> selectOfflineParticipantListByStudentIds(@Param("trainingId") Long trainingId, @Param("studentIds") List<Long> studentIds);
+}

+ 8 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/mapper/MainTrainingVideoMapper.java

@@ -0,0 +1,8 @@
+package org.dromara.main.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.main.domain.MainTrainingVideo;
+import org.dromara.main.domain.vo.MainTrainingVideoVo;
+
+public interface MainTrainingVideoMapper extends BaseMapperPlus<MainTrainingVideo, MainTrainingVideoVo> {
+}

+ 89 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/IMainTrainingService.java

@@ -0,0 +1,89 @@
+package org.dromara.main.service;
+
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.main.domain.bo.MainTrainingBo;
+import org.dromara.main.domain.vo.MainTrainingLearnRecordVo;
+import org.dromara.main.domain.vo.MainTrainingParticipantVo;
+import org.dromara.main.domain.vo.MainTrainingVo;
+
+import java.util.Collection;
+import java.util.List;
+
+public interface IMainTrainingService {
+
+    /**
+     * 查询培训详情
+     */
+    MainTrainingVo queryById(Long id);
+
+    /**
+     * 分页查询培训列表
+     */
+    TableDataInfo<MainTrainingVo> queryPageList(MainTrainingBo bo, PageQuery pageQuery);
+
+    /**
+     * 分页查询线下培训列表
+     */
+    TableDataInfo<MainTrainingVo> queryOfflinePageList(MainTrainingBo bo, PageQuery pageQuery);
+
+    /**
+     * 查询培训列表
+     */
+    List<MainTrainingVo> queryList(MainTrainingBo bo);
+
+    /**
+     * 查询线下培训详情
+     */
+    MainTrainingVo queryOfflineById(Long id);
+
+    /**
+     * 查询视频培训学习记录分页
+     */
+    TableDataInfo<MainTrainingLearnRecordVo> queryLearnRecordPage(Long trainingId, PageQuery pageQuery);
+
+    /**
+     * 查询线下培训参与人分页
+     */
+    TableDataInfo<MainTrainingParticipantVo> queryOfflineParticipantPage(Long trainingId, PageQuery pageQuery);
+
+    /**
+     * 按勾选学员导出视频培训学习记录
+     */
+    List<MainTrainingLearnRecordVo> queryLearnRecordExportList(Long trainingId, List<Long> studentIds);
+
+    /**
+     * 按勾选学员导出线下培训参与人
+     */
+    List<MainTrainingParticipantVo> queryOfflineParticipantExportList(Long trainingId, List<Long> studentIds);
+
+    /**
+     * 新增培训
+     */
+    Boolean insertByBo(MainTrainingBo bo);
+
+    /**
+     * 修改培训
+     */
+    Boolean updateByBo(MainTrainingBo bo);
+
+    /**
+     * 校验并批量删除培训
+     */
+    Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
+
+    /**
+     * 删除线下培训
+     */
+    Boolean deleteOfflineById(Long id);
+
+    /**
+     * 更新培训状态(上架/下架)
+     */
+    Boolean updateStatus(Long id, Integer status);
+
+    /**
+     * 更新线下培训状态
+     */
+    Boolean updateOfflineStatus(Long id, Integer status);
+}

+ 243 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/MainTrainingServiceImpl.java

@@ -0,0 +1,243 @@
+package org.dromara.main.service.impl;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.main.domain.MainTraining;
+import org.dromara.main.domain.MainTrainingVideo;
+import org.dromara.main.domain.bo.MainTrainingBo;
+import org.dromara.main.domain.vo.MainTrainingLearnRecordVo;
+import org.dromara.main.domain.vo.MainTrainingParticipantVo;
+import org.dromara.main.domain.vo.MainTrainingVo;
+import org.dromara.main.domain.vo.MainTrainingVideoVo;
+import org.dromara.main.mapper.MainTrainingMapper;
+import org.dromara.main.mapper.MainTrainingVideoMapper;
+import org.dromara.main.service.IMainTrainingService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+
+@RequiredArgsConstructor
+@Service
+public class MainTrainingServiceImpl implements IMainTrainingService {
+
+    private final MainTrainingMapper baseMapper;
+    private final MainTrainingVideoMapper videoMapper;
+
+    @Override
+    public MainTrainingVo queryById(Long id) {
+        MainTrainingVo vo = baseMapper.selectVoById(id);
+        if (vo != null) {
+            // 查询关联视频列表
+            List<MainTrainingVideoVo> videoList = videoMapper.selectVoList(
+                new LambdaQueryWrapper<MainTrainingVideo>()
+                    .eq(MainTrainingVideo::getTrainingId, id)
+                    .orderByAsc(MainTrainingVideo::getSortOrder)
+            );
+            vo.setVideoList(videoList);
+            fillExtraInfo(vo);
+        }
+        return vo;
+    }
+
+    @Override
+    public TableDataInfo<MainTrainingVo> queryPageList(MainTrainingBo bo, PageQuery pageQuery) {
+        LambdaQueryWrapper<MainTraining> lqw = buildQueryWrapper(bo);
+        Page<MainTrainingVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
+
+        // 填充额外信息
+        for (MainTrainingVo vo : result.getRecords()) {
+            fillExtraInfo(vo);
+        }
+
+        return TableDataInfo.build(result);
+    }
+
+    @Override
+    public TableDataInfo<MainTrainingVo> queryOfflinePageList(MainTrainingBo bo, PageQuery pageQuery) {
+        bo.setTrainingType("offline");
+        return queryPageList(bo, pageQuery);
+    }
+
+    @Override
+    public List<MainTrainingVo> queryList(MainTrainingBo bo) {
+        LambdaQueryWrapper<MainTraining> lqw = buildQueryWrapper(bo);
+        return baseMapper.selectVoList(lqw);
+    }
+
+    @Override
+    public MainTrainingVo queryOfflineById(Long id) {
+        MainTrainingVo vo = queryById(id);
+        if (vo == null || !"offline".equals(vo.getTrainingType())) {
+            return null;
+        }
+        return vo;
+    }
+
+    @Override
+    public TableDataInfo<MainTrainingLearnRecordVo> queryLearnRecordPage(Long trainingId, PageQuery pageQuery) {
+        Page<MainTrainingLearnRecordVo> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
+        return TableDataInfo.build(baseMapper.selectLearnRecordPage(page, trainingId));
+    }
+
+    @Override
+    public TableDataInfo<MainTrainingParticipantVo> queryOfflineParticipantPage(Long trainingId, PageQuery pageQuery) {
+        Page<MainTrainingParticipantVo> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
+        return TableDataInfo.build(baseMapper.selectOfflineParticipantPage(page, trainingId));
+    }
+
+    @Override
+    public List<MainTrainingLearnRecordVo> queryLearnRecordExportList(Long trainingId, List<Long> studentIds) {
+        List<Long> validIds = studentIds.stream().filter(Objects::nonNull).toList();
+        if (validIds.isEmpty()) {
+            return List.of();
+        }
+        return baseMapper.selectLearnRecordListByStudentIds(trainingId, validIds);
+    }
+
+    @Override
+    public List<MainTrainingParticipantVo> queryOfflineParticipantExportList(Long trainingId, List<Long> studentIds) {
+        List<Long> validIds = studentIds.stream().filter(Objects::nonNull).toList();
+        if (validIds.isEmpty()) {
+            return List.of();
+        }
+        return baseMapper.selectOfflineParticipantListByStudentIds(trainingId, validIds);
+    }
+
+    private LambdaQueryWrapper<MainTraining> buildQueryWrapper(MainTrainingBo bo) {
+        LambdaQueryWrapper<MainTraining> lqw = Wrappers.lambdaQuery();
+        lqw.eq(StringUtils.isNotBlank(bo.getTrainingType()), MainTraining::getTrainingType, bo.getTrainingType());
+        lqw.like(StringUtils.isNotBlank(bo.getName()), MainTraining::getName, bo.getName());
+        lqw.eq(ObjectUtil.isNotNull(bo.getStatus()), MainTraining::getStatus, bo.getStatus());
+        lqw.eq(StringUtils.isNotBlank(bo.getJobType()), MainTraining::getJobType, bo.getJobType());
+        lqw.eq(StringUtils.isNotBlank(bo.getJobLevel()), MainTraining::getJobLevel, bo.getJobLevel());
+        lqw.orderByAsc(MainTraining::getSortOrder);
+        lqw.orderByDesc(MainTraining::getCreateTime);
+        return lqw;
+    }
+
+    /**
+     * 填充额外统计信息
+     */
+    private void fillExtraInfo(MainTrainingVo vo) {
+        if ("video".equals(vo.getTrainingType())) {
+            Long count = baseMapper.countLearnRecords(vo.getId());
+            vo.setLearnCount(count != null ? count.intValue() : 0);
+        } else if ("offline".equals(vo.getTrainingType())) {
+            Long count = baseMapper.countEnrollments(vo.getId());
+            vo.setParticipantCount(count != null ? count.intValue() : 0);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean insertByBo(MainTrainingBo bo) {
+        MainTraining add = BeanUtil.toBean(bo, MainTraining.class);
+        // 如果上架,设置上架时间
+        if (add.getStatus() != null && add.getStatus() == 1) {
+            add.setPublishTime(new Date());
+        }
+        boolean flag = baseMapper.insert(add) > 0;
+        if (flag) {
+            bo.setId(add.getId());
+            // 保存视频子表
+            saveVideoList(add.getId(), bo.getVideoList());
+        }
+        return flag;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean updateByBo(MainTrainingBo bo) {
+        MainTraining update = BeanUtil.toBean(bo, MainTraining.class);
+        boolean flag = baseMapper.updateById(update) > 0;
+        if (flag && bo.getVideoList() != null) {
+            // 先删除旧视频,再插入新视频
+            videoMapper.delete(
+                new LambdaQueryWrapper<MainTrainingVideo>()
+                    .eq(MainTrainingVideo::getTrainingId, bo.getId())
+            );
+            saveVideoList(bo.getId(), bo.getVideoList());
+        }
+        return flag;
+    }
+
+    /**
+     * 保存视频子表
+     */
+    private void saveVideoList(Long trainingId, List<MainTrainingBo.TrainingVideoBo> videoList) {
+        if (videoList == null || videoList.isEmpty()) {
+            return;
+        }
+        for (int i = 0; i < videoList.size(); i++) {
+            MainTrainingBo.TrainingVideoBo videoBo = videoList.get(i);
+            MainTrainingVideo video = new MainTrainingVideo();
+            video.setTrainingId(trainingId);
+            video.setName(videoBo.getName());
+            video.setOssId(videoBo.getOssId());
+            video.setFileUrl(videoBo.getFileUrl());
+            video.setFileSize(videoBo.getFileSize());
+            video.setFileType(videoBo.getFileType());
+            video.setDuration(videoBo.getDuration());
+            video.setSortOrder(videoBo.getSortOrder() != null ? videoBo.getSortOrder() : i + 1);
+            videoMapper.insert(video);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
+        if (isValid) {
+            // 业务校验
+        }
+        // 同时删除视频子表
+        for (Long id : ids) {
+            videoMapper.delete(
+                new LambdaQueryWrapper<MainTrainingVideo>()
+                    .eq(MainTrainingVideo::getTrainingId, id)
+            );
+        }
+        return baseMapper.deleteBatchIds(ids) > 0;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean deleteOfflineById(Long id) {
+        MainTraining training = baseMapper.selectById(id);
+        if (training == null || !"offline".equals(training.getTrainingType())) {
+            return false;
+        }
+        return deleteWithValidByIds(List.of(id), true);
+    }
+
+    @Override
+    public Boolean updateStatus(Long id, Integer status) {
+        MainTraining training = new MainTraining();
+        training.setId(id);
+        training.setStatus(status);
+        // 上架时设置上架时间
+        if (status != null && status == 1) {
+            training.setPublishTime(new Date());
+        }
+        return baseMapper.updateById(training) > 0;
+    }
+
+    @Override
+    public Boolean updateOfflineStatus(Long id, Integer status) {
+        MainTraining training = baseMapper.selectById(id);
+        if (training == null || !"offline".equals(training.getTrainingType())) {
+            return false;
+        }
+        return updateStatus(id, status);
+    }
+}