Jelajahi Sumber

PhaseE1: 课程版块维护(新train_course_section表+section CRUD接口+管理端版块UI)

liaoxg 1 Minggu lalu
induk
melakukan
092ffb3

+ 111 - 2
train-backend/src/main/java/com/train/controller/admin/AdminCourseController.java

@@ -4,10 +4,13 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.train.common.Result;
 import com.train.entity.TrainClass;
 import com.train.entity.TrainCourse;
+import com.train.entity.TrainCourseSection;
 import com.train.mapper.TrainClassMapper;
 import com.train.mapper.TrainCourseMapper;
+import com.train.mapper.TrainCourseSectionMapper;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.util.StringUtils;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -18,11 +21,11 @@ import java.util.List;
 import java.util.Map;
 
 /**
- * 管理端-课程管理(L0/L1/L2/L3/L4 课程主数据 + 价格)。
+ * 管理端-课程管理(L0/L1/L2/L3/L4 课程主数据 + 价格 + 课程版块)。
  * <p>报名=报课程:课程级 price/member_price(分)作为报名支付价格来源;
  * 班次仅关联课程(course_id),作为助教审核时分配的上课期次。
  */
-@Tag(name = "管理端-课程管理", description = "课程列表、新建、编辑(含价格/时间/状态)")
+@Tag(name = "管理端-课程管理", description = "课程列表、新建、编辑(含价格/时间/状态)+ 课程版块维护")
 @RestController
 @RequestMapping("/api/admin/course")
 public class AdminCourseController {
@@ -31,6 +34,8 @@ public class AdminCourseController {
     private TrainCourseMapper trainCourseMapper;
     @Resource
     private TrainClassMapper trainClassMapper;
+    @Resource
+    private TrainCourseSectionMapper trainCourseSectionMapper;
 
     @Operation(summary = "课程列表(含各班次数量/可分配班次)")
     @PostMapping("/list")
@@ -117,4 +122,108 @@ public class AdminCourseController {
                         .eq(TrainClass::getCourseId, courseId)
                         .orderByAsc(TrainClass::getId)));
     }
+
+    // ==================== 课程版块维护(D1) ====================
+
+    @Operation(summary = "课程版块列表")
+    @PostMapping("/section/list")
+    public Result<List<TrainCourseSection>> sectionList(@RequestBody Map<String, Object> body) {
+        if (body == null || body.get("courseId") == null) {
+            return Result.error("缺少课程ID");
+        }
+        Long courseId = Long.valueOf(body.get("courseId").toString());
+        return Result.success(trainCourseSectionMapper.selectList(
+                new LambdaQueryWrapper<TrainCourseSection>()
+                        .eq(TrainCourseSection::getCourseId, courseId)
+                        .orderByAsc(TrainCourseSection::getSort)
+                        .orderByAsc(TrainCourseSection::getId)));
+    }
+
+    @Operation(summary = "新建课程版块")
+    @PostMapping("/section/create")
+    public Result<TrainCourseSection> sectionCreate(@RequestBody TrainCourseSection section) {
+        if (section.getCourseId() == null) {
+            return Result.error("缺少课程ID");
+        }
+        TrainCourse exist = trainCourseMapper.selectById(section.getCourseId());
+        if (exist == null) {
+            return Result.error("课程不存在");
+        }
+        if (!StringUtils.hasText(section.getTitle())) {
+            return Result.error("版块标题不能为空");
+        }
+        section.setTitle(section.getTitle().trim());
+        if (section.getSort() == null) {
+            section.setSort(0);
+        }
+        if (section.getEnabled() == null) {
+            section.setEnabled(1);
+        }
+        trainCourseSectionMapper.insert(section);
+        return Result.success(section);
+    }
+
+    @Operation(summary = "编辑课程版块(标题/说明/排序/启用)")
+    @PostMapping("/section/update")
+    public Result<Boolean> sectionUpdate(@RequestBody TrainCourseSection section) {
+        if (section.getId() == null) {
+            return Result.error("缺少版块ID");
+        }
+        TrainCourseSection exist = trainCourseSectionMapper.selectById(section.getId());
+        if (exist == null) {
+            return Result.error("版块不存在");
+        }
+        // 仅更新允许编辑的字段,其余保持原值
+        if (StringUtils.hasText(section.getTitle())) {
+            exist.setTitle(section.getTitle().trim());
+        }
+        if (section.getDescription() != null) {
+            exist.setDescription(section.getDescription());
+        }
+        if (section.getSort() != null) {
+            exist.setSort(section.getSort());
+        }
+        if (section.getEnabled() != null) {
+            exist.setEnabled(section.getEnabled());
+        }
+        trainCourseSectionMapper.updateById(exist);
+        return Result.success(true);
+    }
+
+    @Operation(summary = "删除课程版块")
+    @PostMapping("/section/delete")
+    public Result<Boolean> sectionDelete(@RequestBody Map<String, Object> body) {
+        if (body == null || body.get("id") == null) {
+            return Result.error("缺少版块ID");
+        }
+        Long id = Long.valueOf(body.get("id").toString());
+        trainCourseSectionMapper.deleteById(id);
+        return Result.success(true);
+    }
+
+    @SuppressWarnings("unchecked")
+    @Operation(summary = "版块批量排序(items: [{id, sort}])")
+    @PostMapping("/section/sort")
+    public Result<Boolean> sectionSort(@RequestBody Map<String, Object> body) {
+        if (body == null || body.get("items") == null) {
+            return Result.error("缺少排序列表");
+        }
+        List<Map<String, Object>> items = (List<Map<String, Object>>) body.get("items");
+        if (items.isEmpty()) {
+            return Result.error("排序列表不能为空");
+        }
+        for (Map<String, Object> item : items) {
+            Object idObj = item.get("id");
+            Object sortObj = item.get("sort");
+            if (idObj == null || sortObj == null) {
+                continue;
+            }
+            TrainCourseSection exist = trainCourseSectionMapper.selectById(Long.valueOf(idObj.toString()));
+            if (exist != null) {
+                exist.setSort(Integer.valueOf(sortObj.toString()));
+                trainCourseSectionMapper.updateById(exist);
+            }
+        }
+        return Result.success(true);
+    }
 }

+ 40 - 0
train-backend/src/main/java/com/train/entity/TrainCourseSection.java

@@ -0,0 +1,40 @@
+package com.train.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 课程版块(管理端维护)。
+ * 一个课程可包含多个版块(如:课前准备/课中学习/课后跟进),
+ * 版块用于组织资料与学员学习路径。
+ */
+@Data
+@TableName("train_course_section")
+public class TrainCourseSection implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 所属课程ID(train_course.id) */
+    private Long courseId;
+
+    /** 版块标题 */
+    private String title;
+
+    /** 版块说明 */
+    private String description;
+
+    /** 排序(升序) */
+    private Integer sort;
+
+    /** 1=启用,0=禁用 */
+    private Integer enabled;
+
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 7 - 0
train-backend/src/main/java/com/train/mapper/TrainCourseSectionMapper.java

@@ -0,0 +1,7 @@
+package com.train.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.train.entity.TrainCourseSection;
+
+public interface TrainCourseSectionMapper extends BaseMapper<TrainCourseSection> {
+}

+ 14 - 0
train-backend/src/main/resources/schema.sql

@@ -20,6 +20,20 @@ CREATE TABLE IF NOT EXISTS train_course (
     INDEX idx_status (status)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='训练营课程(L0/L1/L2/L3/L4)';
 
+-- 课程版块表(管理端维护:版块增删改/排序/启用)
+CREATE TABLE IF NOT EXISTS train_course_section (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    course_id BIGINT NOT NULL COMMENT '所属课程ID(train_course.id)',
+    title VARCHAR(100) NOT NULL COMMENT '版块标题(如:课前准备/课中学习/课后跟进)',
+    description VARCHAR(500) COMMENT '版块说明',
+    sort INT DEFAULT 0 COMMENT '排序(升序)',
+    enabled TINYINT DEFAULT 1 COMMENT '1=启用,0=禁用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_course_id (course_id),
+    INDEX idx_sort (sort)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程版块(管理端维护)';
+
 -- 班次表
 CREATE TABLE IF NOT EXISTS train_class (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 17 - 0
train-web/src/api/course.js

@@ -16,4 +16,21 @@ export function updateCourse(data) {
 
 export function courseClasses(courseId) {
   return request.post('/api/admin/course/classes', { courseId })
+}
+
+// 课程版块维护(D1)
+export function sectionList(courseId) {
+  return request.post('/api/admin/course/section/list', { courseId })
+}
+
+export function createSection(data) {
+  return request.post('/api/admin/course/section/create', data)
+}
+
+export function updateSection(data) {
+  return request.post('/api/admin/course/section/update', data)
+}
+
+export function deleteSection(id) {
+  return request.post('/api/admin/course/section/delete', { id })
 }

+ 157 - 2
train-web/src/views/Courses.vue

@@ -33,9 +33,10 @@
         </template>
       </el-table-column>
       <el-table-column prop="createdAt" label="创建时间" width="170" :formatter="formatTime" />
-      <el-table-column label="操作" width="90" fixed="right">
+      <el-table-column label="操作" width="150" fixed="right">
         <template slot-scope="{ row }">
           <el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
+          <el-button size="mini" @click="openSections(row)">版块</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -81,11 +82,59 @@
         <el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
       </div>
     </el-dialog>
+
+    <!-- 课程版块管理弹窗 -->
+    <el-dialog :title="'课程版块 - ' + (sectionCourse && sectionCourse.name ? sectionCourse.name : '')" :visible.sync="showSectionDialog" width="640px" @close="resetSectionForm">
+      <div class="section-toolbar">
+        <el-button type="primary" icon="el-icon-plus" size="small" @click="openSectionCreate">新增版块</el-button>
+        <span class="tips">版块用于组织课程学习路径(如:课前准备/课中学习/课后跟进)</span>
+      </div>
+      <el-table :data="sectionRows" v-loading="sectionLoading" border stripe style="width:100%">
+        <el-table-column prop="sort" label="排序" width="70" />
+        <el-table-column prop="title" label="版块标题" min-width="140" />
+        <el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
+        <el-table-column label="启用" width="70">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.enabled === 1 ? 'success' : 'info'" size="small">{{ row.enabled === 1 ? '启用' : '禁用' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="140">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="primary" @click="openSectionEdit(row)">编辑</el-button>
+            <el-button size="mini" type="danger" @click="removeSection(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div v-if="!sectionLoading && sectionRows.length === 0" class="empty-tip">暂未配置版块</div>
+
+      <!-- 版块新建/编辑 -->
+      <el-dialog width="480px" :title="sectionForm.id ? '编辑版块' : '新增版块'" :visible.sync="showSectionForm" append-to-body>
+        <el-form :model="sectionForm" :rules="sectionRules" ref="sectionForm" label-width="80px">
+          <el-form-item label="版块标题" prop="title">
+            <el-input v-model="sectionForm.title" placeholder="如:课前准备" />
+          </el-form-item>
+          <el-form-item label="说明">
+            <el-input v-model="sectionForm.description" type="textarea" :rows="2" placeholder="版块说明(选填)" />
+          </el-form-item>
+          <el-form-item label="排序">
+            <el-input-number v-model="sectionForm.sort" :min="0" :max="9999" />
+            <span class="tips">数字越小越靠前</span>
+          </el-form-item>
+          <el-form-item label="启用">
+            <el-switch v-model="sectionForm.enabled" :active-value="1" :inactive-value="0" />
+          </el-form-item>
+        </el-form>
+        <div slot="footer">
+          <el-button @click="showSectionForm = false">取消</el-button>
+          <el-button type="primary" @click="handleSectionSubmit" :loading="sectionSubmitting">确定</el-button>
+        </div>
+      </el-dialog>
+    </el-dialog>
   </div>
 </template>
 
 <script>
-import { courseList, createCourse, updateCourse } from '@/api/course'
+import { courseList, createCourse, updateCourse, sectionList, createSection, updateSection, deleteSection } from '@/api/course'
 
 function fenToYuan(fen) {
   return ((fen || 0) / 100).toFixed(2)
@@ -113,6 +162,23 @@ export default {
       rules: {
         level: [{ required: true, message: '请选择课程等级', trigger: 'change' }],
         name: [{ required: true, message: '请输入课程名称', trigger: 'blur' }]
+      },
+      // 课程版块维护
+      showSectionDialog: false,
+      sectionCourse: null,
+      sectionRows: [],
+      sectionLoading: false,
+      showSectionForm: false,
+      sectionSubmitting: false,
+      sectionForm: {
+        id: null,
+        title: '',
+        description: '',
+        sort: 0,
+        enabled: 1
+      },
+      sectionRules: {
+        title: [{ required: true, message: '请输入版块标题', trigger: 'blur' }]
       }
     }
   },
@@ -200,6 +266,82 @@ export default {
           self.submitting = false
         })
       })
+    },
+    // ==================== 课程版块维护 ====================
+    openSections: function (row) {
+      var self = this
+      self.sectionCourse = row
+      self.showSectionDialog = true
+      self.fetchSections()
+    },
+    fetchSections: function () {
+      var self = this
+      if (!self.sectionCourse) return
+      self.sectionLoading = true
+      sectionList(self.sectionCourse.id).then(function (res) {
+        self.sectionRows = res.data || []
+      }).catch(function () {
+        self.$message.error('获取版块列表失败')
+      }).finally(function () {
+        self.sectionLoading = false
+      })
+    },
+    openSectionCreate: function () {
+      this.resetSectionForm()
+      this.showSectionForm = true
+    },
+    openSectionEdit: function (row) {
+      this.sectionForm = {
+        id: row.id,
+        title: row.title,
+        description: row.description || '',
+        sort: row.sort || 0,
+        enabled: row.enabled === 1 ? 1 : 0
+      }
+      this.showSectionForm = true
+    },
+    resetSectionForm: function () {
+      this.sectionForm = { id: null, title: '', description: '', sort: 0, enabled: 1 }
+    },
+    handleSectionSubmit: function () {
+      var self = this
+      if (self.sectionSubmitting) return
+      self.$refs.sectionForm.validate(function (valid) {
+        if (!valid) return
+        self.sectionSubmitting = true
+        var payload = {
+          title: self.sectionForm.title,
+          description: self.sectionForm.description,
+          sort: self.sectionForm.sort,
+          enabled: self.sectionForm.enabled
+        }
+        var p
+        if (self.sectionForm.id) {
+          payload.id = self.sectionForm.id
+          p = updateSection(payload)
+        } else {
+          payload.courseId = self.sectionCourse.id
+          p = createSection(payload)
+        }
+        p.then(function () {
+          self.$message.success('保存成功')
+          self.showSectionForm = false
+          self.fetchSections()
+        }).catch(function (err) {
+          self.$message.error((err && err.message) || '保存失败')
+        }).finally(function () {
+          self.sectionSubmitting = false
+        })
+      })
+    },
+    removeSection: function (row) {
+      var self = this
+      self.$confirm('确认删除版块「' + row.title + '」?', '提示', { type: 'warning' }).then(function () {
+        return deleteSection(row.id)
+      }).then(function () {
+        self.$message.success('删除成功')
+        self.fetchSections()
+      }).catch(function () {})
     }
   }
 }
@@ -224,4 +366,17 @@ export default {
   font-size: 12px;
   color: #94A3B8;
 }
+
+.section-toolbar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+.empty-tip {
+  padding: 20px 0;
+  text-align: center;
+  font-size: 13px;
+  color: #94A3B8;
+}
 </style>