Jelajahi Sumber

PhaseE2: 资料维护(课程/版块/分类维度过滤+删除接口+上传预览管理端UI)

liaoxg 1 Minggu lalu
induk
melakukan
54880f3f19

+ 49 - 11
train-backend/src/main/java/com/train/controller/admin/AdminMaterialController.java

@@ -18,9 +18,11 @@ import java.util.List;
 import java.util.Map;
 
 /**
- * 课前资料管理(管理端):资料列表、保存(幂等 upsert)。
+ * 课前资料管理(管理端):资料列表、保存(幂等 upsert)、删除。
+ * <p>过滤维度:courseId(课程)/ sectionId(版块)/ classId(班次)/ category(分类),
+ * 均含"通用(对应字段为空)"记录,便于后台编辑兜底。
  */
-@Tag(name = "管理端-课前资料", description = "课前资料包配置")
+@Tag(name = "管理端-课前资料", description = "课前资料包配置(课程/版块/班次/分类维度)")
 @RestController
 @RequestMapping("/api/admin/material")
 public class AdminMaterialController {
@@ -29,20 +31,36 @@ public class AdminMaterialController {
     private TrainCourseMaterialMapper trainCourseMaterialMapper;
 
     /**
-     * 资料列表:classId 为空返回全部;否则只返回指定班次(含通用 classId 为空的资料,便于后台编辑)
+     * 资料列表:可传 courseId/sectionId/classId/category 做维度过滤;全不传返回全部
      */
     @Operation(summary = "课前资料列表")
     @PostMapping("/list")
     public Result<List<TrainCourseMaterial>> list(@RequestBody(required = false) Map<String, Object> body,
                                                   @RequestAttribute("adminId") Long adminId) {
-        String classIdStr = body == null ? null
-                : (body.get("classId") == null ? null : body.get("classId").toString());
         LambdaQueryWrapper<TrainCourseMaterial> wrapper = new LambdaQueryWrapper<>();
-        if (StringUtils.hasText(classIdStr)) {
-            wrapper.and(w -> w
-                    .eq(TrainCourseMaterial::getClassId, Long.valueOf(classIdStr))
-                    .or()
-                    .isNull(TrainCourseMaterial::getClassId));
+        if (body != null) {
+            String courseIdStr = body.get("courseId") == null ? null : body.get("courseId").toString();
+            if (StringUtils.hasText(courseIdStr)) {
+                Long courseId = Long.valueOf(courseIdStr);
+                wrapper.and(w -> w.eq(TrainCourseMaterial::getCourseId, courseId)
+                        .or().isNull(TrainCourseMaterial::getCourseId));
+            }
+            String sectionIdStr = body.get("sectionId") == null ? null : body.get("sectionId").toString();
+            if (StringUtils.hasText(sectionIdStr)) {
+                Long sectionId = Long.valueOf(sectionIdStr);
+                wrapper.and(w -> w.eq(TrainCourseMaterial::getSectionId, sectionId)
+                        .or().isNull(TrainCourseMaterial::getSectionId));
+            }
+            String classIdStr = body.get("classId") == null ? null : body.get("classId").toString();
+            if (StringUtils.hasText(classIdStr)) {
+                Long classId = Long.valueOf(classIdStr);
+                wrapper.and(w -> w.eq(TrainCourseMaterial::getClassId, classId)
+                        .or().isNull(TrainCourseMaterial::getClassId));
+            }
+            String category = body.get("category") == null ? null : body.get("category").toString();
+            if (StringUtils.hasText(category)) {
+                wrapper.eq(TrainCourseMaterial::getCategory, category);
+            }
         }
         List<TrainCourseMaterial> list = trainCourseMaterialMapper.selectList(
                 wrapper.orderByAsc(TrainCourseMaterial::getSort).orderByDesc(TrainCourseMaterial::getId));
@@ -65,9 +83,15 @@ public class AdminMaterialController {
         if (idObj != null && StringUtils.hasText(idObj.toString())) {
             material.setId(Long.valueOf(idObj.toString()));
         }
+        String courseIdStr = body.get("courseId") == null ? null : body.get("courseId").toString();
+        material.setCourseId(StringUtils.hasText(courseIdStr) ? Long.valueOf(courseIdStr) : null);
+        String sectionIdStr = body.get("sectionId") == null ? null : body.get("sectionId").toString();
+        material.setSectionId(StringUtils.hasText(sectionIdStr) ? Long.valueOf(sectionIdStr) : null);
         String classIdStr = body.get("classId") == null ? null : body.get("classId").toString();
         material.setClassId(StringUtils.hasText(classIdStr) ? Long.valueOf(classIdStr) : null);
         material.setTitle(title);
+        String category = body.get("category") == null ? null : body.get("category").toString();
+        material.setCategory(StringUtils.hasText(category) ? category : null);
         material.setFileUrl(body.get("fileUrl") == null ? null : body.get("fileUrl").toString());
         Object sortObj = body.get("sort");
         material.setSort(sortObj == null ? 0 : Integer.valueOf(sortObj.toString()));
@@ -75,9 +99,23 @@ public class AdminMaterialController {
         if (material.getId() == null) {
             trainCourseMaterialMapper.insert(material);
         } else {
-            // 覆盖更新:未传的字段置空,确保可清空 classId/fileUrl/sort
+            // 覆盖更新:未传的字段置空,确保可清空 courseId/sectionId/classId/category/fileUrl/sort
             trainCourseMaterialMapper.updateById(material);
         }
         return Result.success(true);
     }
+
+    /**
+     * 删除资料。
+     */
+    @Operation(summary = "删除课前资料")
+    @PostMapping("/delete")
+    public Result<Boolean> delete(@RequestBody Map<String, Object> body) {
+        if (body == null || body.get("id") == null) {
+            return Result.error("缺少资料ID");
+        }
+        Long id = Long.valueOf(body.get("id").toString());
+        trainCourseMaterialMapper.deleteById(id);
+        return Result.success(true);
+    }
 }

+ 10 - 0
train-backend/src/main/java/com/train/entity/TrainCourseMaterial.java

@@ -15,8 +15,18 @@ public class TrainCourseMaterial implements Serializable {
     @TableId(type = IdType.AUTO)
     private Long id;
 
+    /** 所属课程ID(train_course.id,空=按班次关联的存量资料) */
+    private Long courseId;
+
+    /** 所属课程版块ID(train_course_section.id,空=通用) */
+    private Long sectionId;
+
     private Long classId; // 为空=通用资料
     private String title;
+
+    /** 分类:pre(课前)/course(课中)/after(课后) */
+    private String category;
+
     private String fileUrl;
     private Integer sort;
     private Date createdAt;

+ 11 - 1
train-backend/src/main/resources/schema.sql

@@ -354,14 +354,24 @@ CREATE TABLE IF NOT EXISTS train_invite (
 -- 课前资料包表(二期)
 CREATE TABLE IF NOT EXISTS train_course_material (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    course_id BIGINT COMMENT '所属课程ID(train_course.id,为空=按班次关联的存量资料)',
+    section_id BIGINT COMMENT '所属课程版块ID(train_course_section.id,空=通用)',
     class_id BIGINT COMMENT '班次ID(为空=通用资料)',
-    title VARCHAR(100) NOT NULL,
+    title VARCHAR(100) NOT NULL COMMENT '资料标题',
+    category VARCHAR(20) COMMENT '分类:pre(课前)/course(课中)/after(课后)',
     file_url VARCHAR(255) COMMENT 'PDF/附件URL',
     sort INT DEFAULT 0,
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_course_id (course_id),
+    INDEX idx_section_id (section_id),
     INDEX idx_class_id (class_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课前资料包';
 
+-- 课前资料包表补列(存量库幂等迁移:重复执行报错被吞,属预期)
+ALTER TABLE train_course_material ADD COLUMN course_id BIGINT COMMENT '所属课程ID(train_course.id)';
+ALTER TABLE train_course_material ADD COLUMN section_id BIGINT COMMENT '所属课程版块ID(train_course_section.id)';
+ALTER TABLE train_course_material ADD COLUMN category VARCHAR(20) COMMENT '分类:pre(课前)/course(课中)/after(课后)';
+
 -- 班级投屏配置表(每班一条,按下述模块开关控制大屏显示)
 CREATE TABLE IF NOT EXISTS train_screen_config (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 7 - 2
train-web/src/api/material.js

@@ -1,11 +1,16 @@
 import request from '@/utils/request'
 
-// 课前资料列表(classId 为空 = 全部)
+// 资料列表(可传 courseId/sectionId/classId/category 过滤;全不传 = 全部)
 export function materialList(data) {
   return request.post('/api/admin/material/list', data || {})
 }
 
-// 保存课前资料(带 id 更新,不带 id 新增)
+// 保存资料(带 id 更新,不带 id 新增)
 export function materialSave(data) {
   return request.post('/api/admin/material/save', data)
+}
+
+// 删除资料
+export function materialDelete(id) {
+  return request.post('/api/admin/material/delete', { id })
 }

+ 190 - 19
train-web/src/views/Materials.vue

@@ -1,10 +1,13 @@
 <template>
   <div class="page-container">
     <div class="page-header">
-      <h2 class="page-title">课资料</h2>
+      <h2 class="page-title">课资料</h2>
       <div class="filter-bar">
-        <el-select v-model="filterClassId" placeholder="全部班次" clearable style="width:200px" @change="fetchList">
-          <el-option v-for="c in classList" :key="c.id" :label="c.name" :value="c.id" />
+        <el-select v-model="filterCourseId" placeholder="全部课程" clearable style="width:180px" @change="onCourseFilterChange">
+          <el-option v-for="c in courseList" :key="c.id" :label="c.level + ' ' + c.name" :value="c.id" />
+        </el-select>
+        <el-select v-model="filterSectionId" placeholder="全部版块" clearable style="width:160px" @change="fetchList">
+          <el-option v-for="s in sectionList" :key="s.id" :label="s.title" :value="s.id" />
         </el-select>
         <el-button type="primary" icon="el-icon-plus" @click="openEdit()">新增资料</el-button>
         <el-button icon="el-icon-refresh" @click="fetchList">刷新</el-button>
@@ -12,39 +15,83 @@
     </div>
 
     <el-table :data="list" v-loading="loading" border stripe style="width:100%">
-      <el-table-column prop="id" label="ID" width="80" />
-      <el-table-column prop="title" label="资料标题" min-width="200" />
-      <el-table-column label="适用班次" width="140">
+      <el-table-column prop="id" label="ID" width="70" />
+      <el-table-column prop="title" label="资料标题" min-width="180">
+        <template slot-scope="scope">
+          <a v-if="scope.row.fileUrl" :href="scope.row.fileUrl" target="_blank" class="link">{{ scope.row.title }}</a>
+          <span v-else>{{ scope.row.title }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="分类" width="90">
+        <template slot-scope="scope">
+          <el-tag :type="categoryType(scope.row.category)" size="small">{{ categoryLabel(scope.row.category) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="适用课程" width="140">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.courseId" size="small">课程 #{{ scope.row.courseId }}</el-tag>
+          <el-tag v-else-if="scope.row.classId" size="small" type="warning">班次 #{{ scope.row.classId }}</el-tag>
+          <el-tag v-else size="small" type="info">通用</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="所属版块" width="110">
         <template slot-scope="scope">
-          <el-tag v-if="scope.row.classId" size="small" type="warning">班次 #{{ scope.row.classId }}</el-tag>
+          <el-tag v-if="scope.row.sectionId" size="small">版块 #{{ scope.row.sectionId }}</el-tag>
           <el-tag v-else size="small" type="info">通用</el-tag>
         </template>
       </el-table-column>
-      <el-table-column prop="sort" label="排序" width="80" />
-      <el-table-column prop="fileUrl" label="文件地址" min-width="220" show-overflow-tooltip />
-      <el-table-column prop="createdAt" label="创建时间" width="170" />
+      <el-table-column prop="sort" label="排序" width="70" />
+      <el-table-column label="文件" width="80">
+        <template slot-scope="scope">
+          <el-button v-if="scope.row.fileUrl" type="text" size="small" @click="preview(scope.row)">预览</el-button>
+          <span v-else class="muted">未上传</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="createdAt" label="创建时间" width="160" />
       <el-table-column label="操作" width="120" fixed="right">
         <template slot-scope="scope">
           <el-button type="text" size="small" @click="openEdit(scope.row)">编辑</el-button>
+          <el-button type="text" size="small" class="danger-text" @click="handleDelete(scope.row)">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
 
-    <el-empty v-if="!loading && list.length === 0" description="暂无课前资料" />
+    <el-empty v-if="!loading && list.length === 0" description="暂无课资料" />
 
     <el-dialog :title="form.id ? '编辑资料' : '新增资料'" :visible.sync="dialogVisible" width="560px">
       <el-form :model="form" label-width="90px">
         <el-form-item label="资料标题" required>
           <el-input v-model="form.title" placeholder="如:课前预习手册" />
         </el-form-item>
+        <el-form-item label="所属课程">
+          <el-select v-model="form.courseId" placeholder="留空 = 通用资料" clearable style="width:100%" @change="onFormCourseChange">
+            <el-option v-for="c in courseList" :key="c.id" :label="c.level + ' ' + c.name" :value="c.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="所属版块">
+          <el-select v-model="form.sectionId" placeholder="留空 = 通用版块" clearable style="width:100%">
+            <el-option v-for="s in sectionList" :key="s.id" :label="s.title" :value="s.id" />
+          </el-select>
+        </el-form-item>
         <el-form-item label="适用班次">
           <el-select v-model="form.classId" placeholder="留空 = 通用资料" clearable style="width:100%">
             <el-option v-for="c in classList" :key="c.id" :label="c.name" :value="c.id" />
           </el-select>
           <div class="form-tip">留空则所有班次学员可见(通用资料)</div>
         </el-form-item>
-        <el-form-item label="文件地址">
-          <el-input v-model="form.fileUrl" placeholder="https://…(图片/PDF 链接,可后补)" />
+        <el-form-item label="分类">
+          <el-select v-model="form.category" placeholder="选择分类" clearable style="width:100%">
+            <el-option label="课前" value="pre" />
+            <el-option label="课中" value="course" />
+            <el-option label="课后" value="after" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="文件">
+          <div class="upload-row">
+            <el-input v-model="form.fileUrl" placeholder="点击上传或粘贴 https://… 链接" />
+            <el-button type="primary" icon="el-icon-upload" :loading="uploading" @click="triggerUpload">上传</el-button>
+            <input ref="fileInput" type="file" accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip" style="display:none" @change="handleFileChange" />
+          </div>
         </el-form-item>
         <el-form-item label="排序">
           <el-input-number v-model="form.sort" :min="0" :max="9999" />
@@ -60,33 +107,83 @@
 </template>
 
 <script>
-import { materialList, materialSave } from '@/api/material'
+import { materialList, materialSave, materialDelete } from '@/api/material'
 import { classList as fetchClasses } from '@/api/class'
+import { courseList as fetchCourses } from '@/api/course'
+import { sectionList as fetchSections } from '@/api/course'
+import { uploadFile } from '@/api/upload'
 
 export default {
   name: 'Materials',
   data: function () {
     return {
       list: [],
+      courseList: [],
+      sectionList: [],
       classList: [],
-      filterClassId: null,
+      filterCourseId: null,
+      filterSectionId: null,
       loading: false,
       saving: false,
+      uploading: false,
       dialogVisible: false,
       form: {
         id: null,
         title: '',
+        courseId: null,
+        sectionId: null,
         classId: null,
+        category: null,
         fileUrl: '',
         sort: 0
       }
     }
   },
   mounted: function () {
+    this.loadCourses()
     this.loadClasses()
     this.fetchList()
   },
   methods: {
+    categoryType: function (c) {
+      var map = { pre: '', course: 'success', after: 'warning' }
+      return map[c] || 'info'
+    },
+    categoryLabel: function (c) {
+      var map = { pre: '课前', course: '课中', after: '课后' }
+      return map[c] || '通用'
+    },
+    loadCourses: function () {
+      var self = this
+      fetchCourses().then(function (res) {
+        self.courseList = res.data || []
+      }).catch(function () {
+        self.$message.error('获取课程列表失败')
+      })
+    },
+    loadSections: function (courseId) {
+      var self = this
+      if (!courseId) {
+        self.sectionList = []
+        return
+      }
+      fetchSections(courseId).then(function (res) {
+        self.sectionList = res.data || []
+      }).catch(function () {
+        self.sectionList = []
+      })
+    },
+    onCourseFilterChange: function (courseId) {
+      var self = this
+      self.filterSectionId = null
+      self.loadSections(courseId)
+      self.fetchList()
+    },
+    onFormCourseChange: function (courseId) {
+      var self = this
+      self.form.sectionId = null
+      self.loadSections(courseId)
+    },
     loadClasses: function () {
       var self = this
       fetchClasses().then(function (res) {
@@ -98,23 +195,35 @@ export default {
     fetchList: function () {
       var self = this
       self.loading = true
-      materialList({ classId: self.filterClassId || undefined }).then(function (res) {
+      materialList({
+        courseId: self.filterCourseId || undefined,
+        sectionId: self.filterSectionId || undefined
+      }).then(function (res) {
         self.list = res.data || []
       }).catch(function () {
-        self.$message.error('获取课前资料失败')
+        self.$message.error('获取资料列表失败')
       }).finally(function () {
         self.loading = false
       })
     },
     openEdit: function (row) {
-      this.form = {
+      var self = this
+      self.form = {
         id: row ? row.id : null,
         title: row ? row.title : '',
+        courseId: row ? row.courseId : null,
+        sectionId: row ? row.sectionId : null,
         classId: row ? row.classId : null,
+        category: row ? row.category : null,
         fileUrl: row ? row.fileUrl : '',
         sort: row ? row.sort : 0
       }
-      this.dialogVisible = true
+      if (self.form.courseId) {
+        self.loadSections(self.form.courseId)
+      } else {
+        self.sectionList = []
+      }
+      self.dialogVisible = true
     },
     handleSave: function () {
       var self = this
@@ -126,7 +235,10 @@ export default {
       materialSave({
         id: self.form.id,
         title: self.form.title.trim(),
+        courseId: self.form.courseId || null,
+        sectionId: self.form.sectionId || null,
         classId: self.form.classId || null,
+        category: self.form.category || null,
         fileUrl: self.form.fileUrl || null,
         sort: self.form.sort || 0
       }).then(function () {
@@ -138,6 +250,42 @@ export default {
       }).finally(function () {
         self.saving = false
       })
+    },
+    handleDelete: function (row) {
+      var self = this
+      self.$confirm('确认删除资料「' + row.title + '」?', '提示', { type: 'warning' }).then(function () {
+        return materialDelete(row.id)
+      }).then(function () {
+        self.$message.success('删除成功')
+        self.fetchList()
+      }).catch(function () {})
+    },
+    triggerUpload: function () {
+      this.$refs.fileInput.click()
+    },
+    handleFileChange: function (e) {
+      var self = this
+      var file = e.target.files && e.target.files[0]
+      if (!file) return
+      self.uploading = true
+      uploadFile(file).then(function (res) {
+        if (res && res.data) {
+          self.form.fileUrl = res.data.url
+          self.$message.success('上传成功')
+        } else {
+          self.$message.error('上传失败')
+        }
+      }).catch(function () {
+        self.$message.error('上传失败')
+      }).finally(function () {
+        self.uploading = false
+        e.target.value = ''
+      })
+    },
+    preview: function (row) {
+      if (row && row.fileUrl) {
+        window.open(row.fileUrl, '_blank')
+      }
     }
   }
 }
@@ -169,4 +317,27 @@ export default {
   margin-top: 4px;
   line-height: 1.4;
 }
+
+.link {
+  color: #409EFF;
+  text-decoration: none;
+}
+
+.link:hover {
+  text-decoration: underline;
+}
+
+.danger-text {
+  color: #F56C6C;
+}
+
+.muted {
+  font-size: 12px;
+  color: #909399;
+}
+
+.upload-row {
+  display: flex;
+  gap: 8px;
+}
 </style>