Просмотр исходного кода

feat(课程管理): 添加批量操作功能及优化课程列表界面

实现课程的批量选择、删除、复制和导出功能
重构课程列表界面,优化选择交互和样式
添加全选功能和选择状态显示
zhanghaijun месяцев назад: 11
Родитель
Сommit
2a4ac678d0
3 измененных файлов с 205 добавлено и 223 удалено
  1. 2 0
      src/api/course.api.ts
  2. 139 218
      src/views/train/course/course-list.vue
  3. 64 5
      src/views/train/course/index.vue

+ 2 - 0
src/api/course.api.ts

@@ -27,8 +27,10 @@ export class CourseServer {
   public static add = (data: Omit<Course, 'courseID' | 'courseGUID' | 'creator' | 'createTime' | 'modifyTime'>) => request.post('/course/add', data)
   public static update = (data: Omit<Course, 'creator' | 'createTime' | 'modifyTime'>) => request.post('/course/update', data)
   public static delete = (courseId: string) => request.post('/course/delete', { courseId })
+  public static deleteBatch = (courseIds: string[]) => request.post('/course/delete/batch', { courseIds })
   // 课程复制
   public static copy = (courseId: string) => request.post('/course/copy', { courseId })
+  public static copyBatch = (courseIds: string[]) => request.post('/course/copy/batch', { courseIds })
   // 课程导入
   public static import = (file: Blob | File) => request.upload('/course/import', { file })
   // 课程导出

+ 139 - 218
src/views/train/course/course-list.vue

@@ -1,50 +1,59 @@
 <template>
-  <div class="list">
-    <div class="offset">
-      <div class="item" v-for="item in data" :key="item.courseID" @click="handleChange('change', item)">
-        <!-- 骨架屏 -->
-        <ElSkeleton animated :loading="loading" style="width: 100%; height: 100%">
-          <template #template>
-            <div class="top">
-              <ElSkeletonItem variant="image" style="width: 100%; height: 100%; border-radius: 10px" />
-              <div style="padding: 16px 0">
-                <ElSkeletonItem variant="p" style="width: 80%" />
-                <ElSkeletonItem variant="p" style="width: 40%; margin-top: 10px" />
-              </div>
-            </div>
-          </template>
-          <template #default>
-            <div class="top">
-              <ElImage class="cover" :src="getCover(item)" lazy fit="cover">
-                <template #error>
-                  <div class="image-slot">
-                    <ElIcon><icon-picture /></ElIcon>
-                  </div>
-                </template>
-              </ElImage>
-
-              <div class="type">
-                <div class="type-tag">{{ isSelected(item) ? '已选择' : '未选择' }}</div>
-                <!-- <el-checkbox class="" v-model="isSelected(item)" @change="handleChange('change', item)" /> -->
-                <div class="type-tag">{{ useDateFormat(item.createTime, 'YYYY-MM-DD') }}</div>
-              </div>
+  <div>
+    <!-- 卡片列表 骨架屏-->
+    <div v-if="loading" class="mt-4 grid grid-cols-1 gap-8 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
+      <!-- 骨架屏 -->
+      <ElSkeleton animated :loading="loading" style="width: 100%; height: 100%" v-for="item in Array(8)" :key="item">
+        <template #template>
+          <div class="course-card">
+            <ElSkeletonItem class="cover" variant="image" style="width: 100%; height: 100%; border-radius: 10px" />
+            <div style="padding: 16px 0" class="space-y-2">
+              <ElSkeletonItem variant="p" style="width: 40%" />
+              <ElSkeletonItem variant="p" style="width: 80%" />
             </div>
-            <div class="bottom space-y-2">
-              <h2>{{ item.courseName }}</h2>
-              <div class="desc">{{ item.courseDesc || '暂无描述' }}</div>
-              <div>
-                <el-button size="small" type="primary" :icon="Edit" @click.stop="handleChange('edit', item)">编辑</el-button>
-                <el-button size="small" type="danger" :icon="Delete" @click.stop="handleChange('delete', item)">删除</el-button>
-                <el-button size="small" type="info" :icon="CopyDocument" @click.stop="handleChange('copy', item)">复制</el-button>
+          </div>
+        </template>
+      </ElSkeleton>
+    </div>
+    <!-- 卡片列表 骨架屏-->
+    <div v-else class="mt-4 grid grid-cols-1 gap-8 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
+      <div
+        class="course-card cursor-pointer rounded-md border p-[16px] transition-all duration-200"
+        :class="{
+          'border-primary bg-blue-50': checkedCouerse.includes(String(item.courseID)),
+          'border-gray-200': !checkedCouerse.includes(String(item.courseID))
+        }"
+        v-for="item in data"
+        :key="item.courseID"
+        @click="toggleCheckItem(String(item.courseID))"
+      >
+        <div class="relative">
+          <ElImage class="cover" :src="getCover(item)" lazy fit="cover" :preview-src-list="[]" :hide-on-click-modal="true" draggable="false" @click.prevent @dragstart.prevent>
+            <template #error>
+              <div class="image-slot">
+                <ElIcon><icon-picture /></ElIcon>
               </div>
-            </div>
-          </template>
-        </ElSkeleton>
+            </template>
+          </ElImage>
+          <div class="type">
+            <el-checkbox :model-value="checkedCouerse.includes(String(item.courseID))" class="hidden" />
+            <div class="type-tag">{{ useDateFormat(item.createTime, 'YYYY-MM-DD') }}</div>
+          </div>
+        </div>
+        <div class="card-bottom space-y-2">
+          <h2>{{ item.courseName }}</h2>
+          <div class="desc">{{ item.courseDesc || '暂无描述' }}</div>
+          <div>
+            <el-button size="small" type="primary" :icon="Edit" @click.stop="handleChange('edit', item)">编辑</el-button>
+            <el-button size="small" type="danger" :icon="Delete" @click.stop="handleChange('delete', item)">删除</el-button>
+            <el-button size="small" type="info" :icon="CopyDocument" @click.stop="handleChange('copy', item)">复制</el-button>
+          </div>
+        </div>
       </div>
     </div>
   </div>
   <div style="margin-top: 16vh" v-if="data.length === 0 && !props.loading">
-    <ElEmpty :description="`未找到课程数据`" />
+    <ElEmpty :description="`暂无课程数据`" />
   </div>
 </template>
 <script lang="ts" setup>
@@ -56,16 +65,12 @@
   import { type Course } from '@/api/course.api'
   import { ElMessageBox } from 'element-plus'
 
-  const props = withDefaults(defineProps<{ data: Course[]; loading: boolean }>(), {
-    data: () => [],
-    loading: true
-  })
-
-  const selectCourses = ref<string[]>([])
+  // 参数
+  const props = withDefaults(defineProps<{ data: Course[]; loading: boolean; checkedCouerse: string[] }>(), {})
 
   const emit = defineEmits<{
-    handle: [type: string, item: Course]
-    change: [item: string[]]
+    (e: 'update:checkedList', value: string[]): void
+    (e: 'handle', type: string, item: Course): void
   }>()
 
   const getCover = (item: Course) => {
@@ -75,19 +80,20 @@
     return cover2
   }
 
+  // 选择课程
+  const toggleCheckItem = (item: string) => {
+    const newChecked = [...props.checkedCouerse]
+    const index = newChecked.indexOf(item)
+    if (index > -1) {
+      newChecked.splice(index, 1)
+    } else {
+      newChecked.push(item)
+    }
+    emit('update:checkedList', newChecked)
+  }
+
   const handleChange = (type: string, item: Course) => {
-    const courseId = String(item.courseID) // 确保 ID 是字符串
-    const exist = selectCourses.value.includes(courseId)
     switch (type) {
-      case 'change':
-        if (exist) {
-          selectCourses.value = selectCourses.value.filter((id) => id !== courseId)
-        } else {
-          selectCourses.value.push(courseId)
-        }
-        console.log(selectCourses.value) // 调试输出
-        emit('change', selectCourses.value)
-        break
       case 'delete':
         ElMessageBox.confirm('确定删除课程吗?', '提示', {
           confirmButtonText: '确定',
@@ -101,182 +107,97 @@
     }
     return true
   }
-
-  const isSelected = (item: Course) => {
-    return selectCourses.value.includes(String(item.courseID))
-  }
 </script>
 <style lang="scss" scoped>
-  .list {
-    margin-top: 20px;
-
-    .custom-segmented .el-segmented {
-      height: 40px;
-      padding: 6px;
-
-      --el-border-radius-base: 8px;
+  .course-card {
+    position: relative;
+    .cover {
+      display: flex;
+      aspect-ratio: 16/7;
+      align-items: center;
+      justify-content: center;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      background: var(--art-gray-200);
+      border-radius: calc(var(--custom-radius) / 2 + 2px) calc(var(--custom-radius) / 2 + 2px) 0 0;
+
+      .image-slot {
+        font-size: 26px;
+        color: var(--art-gray-400);
+      }
     }
-
-    .offset {
+    .type {
+      position: absolute;
+      top: 2px;
+      right: 5px;
+      left: 5px;
+      padding: 5px 4px;
+      font-size: 12px;
+      color: rgba(#fff, 0.8);
       display: flex;
-      flex-wrap: wrap;
-      width: calc(100% + 20px);
-
-      .item {
-        box-sizing: border-box;
-        width: calc(20% - 20px);
-        margin: 0 20px 20px 0;
-        padding: 20px;
-        cursor: pointer;
-        border: 1px solid var(--art-border-color);
-        border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
-
-        &:hover {
-          .el-button {
-            opacity: 1 !important;
-          }
-        }
-
-        .top {
-          position: relative;
-          aspect-ratio: 16/7;
-
-          .cover {
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            width: 100%;
-            height: 100%;
-            object-fit: cover;
-            background: var(--art-gray-200);
-            border-radius: calc(var(--custom-radius) / 2 + 2px) calc(var(--custom-radius) / 2 + 2px) 0 0;
-
-            .image-slot {
-              font-size: 26px;
-              color: var(--art-gray-400);
-            }
-          }
-          .type {
-            position: absolute;
-            top: 5px;
-            right: 5px;
-            left: 5px;
-            padding: 5px 4px;
-            font-size: 12px;
-            color: rgba(#fff, 0.8);
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            .type-tag {
-              padding: 2px 4px;
-              border-radius: 4px;
-              background: rgba($color: #000, $alpha: 60%);
-            }
-          }
-        }
-
-        .bottom {
-          padding: 5px 10px;
-
-          h2 {
-            font-size: 16px;
-            font-weight: 500;
-            color: #333;
-
-            @include ellipsis();
-          }
-          .desc {
-            font-size: 12px;
-            font-weight: 500;
-            color: var(--art-text-gray-600);
-
-            @include ellipsis();
-          }
-
-          .info {
-            display: flex;
-            justify-content: space-between;
-            width: 100%;
-            height: 25px;
-            margin-top: 6px;
-            line-height: 25px;
-
-            .text {
-              display: flex;
-              align-items: center;
-              color: var(--art-text-gray-600);
-
-              i {
-                margin-right: 5px;
-                font-size: 14px;
-              }
-
-              span {
-                font-size: 13px;
-                color: var(--art-gray-600);
-              }
-
-              .line {
-                width: 1px;
-                height: 12px;
-                margin: 0 15px;
-                background-color: var(--art-border-dashed-color);
-              }
-            }
-
-            .el-button {
-              opacity: 1;
-              transition: all 0.3s;
-            }
-          }
-        }
+      align-items: center;
+      justify-content: space-between;
+      .type-tag {
+        padding: 2px 4px;
+        border-radius: 4px;
+        background: rgba($color: #000, $alpha: 60%);
       }
     }
   }
+  .card-bottom {
+    padding: 5px 10px;
 
-  @media only screen and (max-width: $device-notebook) {
-    .list {
-      .offset {
-        .item {
-          width: calc(25% - 20px);
-        }
-      }
+    h2 {
+      font-size: 16px;
+      font-weight: 500;
+      color: #333;
+
+      @include ellipsis();
     }
-  }
+    .desc {
+      font-size: 12px;
+      font-weight: 500;
+      color: var(--art-text-gray-600);
 
-  @media only screen and (max-width: $device-ipad-pro) {
-    .list {
-      .offset {
-        .item {
-          width: calc(33.333% - 20px);
+      @include ellipsis();
+    }
 
-          .bottom {
-            h2 {
-              font-size: 16px;
-            }
-          }
+    .info {
+      display: flex;
+      justify-content: space-between;
+      width: 100%;
+      height: 25px;
+      margin-top: 6px;
+      line-height: 25px;
+
+      .text {
+        display: flex;
+        align-items: center;
+        color: var(--art-text-gray-600);
+
+        i {
+          margin-right: 5px;
+          font-size: 14px;
         }
-      }
-    }
-  }
 
-  @media only screen and (max-width: $device-ipad) {
-    .list {
-      .offset {
-        .item {
-          width: calc(50% - 20px);
+        span {
+          font-size: 13px;
+          color: var(--art-gray-600);
         }
-      }
-    }
-  }
 
-  @media only screen and (max-width: $device-phone) {
-    .list {
-      .offset {
-        .item {
-          width: calc(100% - 20px);
+        .line {
+          width: 1px;
+          height: 12px;
+          margin: 0 15px;
+          background-color: var(--art-border-dashed-color);
         }
       }
+
+      .el-button {
+        opacity: 1;
+        transition: all 0.3s;
+      }
     }
   }
 </style>

+ 64 - 5
src/views/train/course/index.vue

@@ -6,13 +6,18 @@
       <ArtTableHeader v-model:showSearchBar="showSearchBar" :loading="loading" @refresh="() => getCourseData({})" layout="search,refresh">
         <template #left>
           <ElSpace wrap>
+            <el-checkbox v-model="checkAll" :indeterminate="isIndeterminate" @change="handleCheckAllChange">
+              <div style="width: 110px">全选({{ selectCourses.length }} / {{ courseData.length }})</div>
+            </el-checkbox>
             <el-button v-ripple type="primary" :icon="Plus" @click="() => router.push({ path: '/course/detail/0' })"> 新增课程 </el-button>
             <el-button v-ripple type="default" :icon="Upload" @click="courseImportHandle"> 导入课程 </el-button>
-            <el-button v-ripple type="default" :icon="Download"> 导出课程 </el-button>
+            <el-button v-ripple type="default" :icon="Download" :disabled="selectCourses.length === 0" @click="exportCourseHandle"> 导出课程 </el-button>
+            <el-button v-ripple type="danger" :icon="Delete" :disabled="selectCourses.length === 0" @click="batchDeleteHandle"> 删除课程 </el-button>
+            <el-button v-ripple type="default" :icon="CopyDocument" :disabled="selectCourses.length === 0" @click="copyCourseHandle"> 复制课程 </el-button>
           </ElSpace>
         </template>
       </ArtTableHeader>
-      <course-list :data="courseData" :loading="loading" @handle="(type, item) => courseHandle(type, item)" @change="(ids) => (selectCourses = ids)"></course-list>
+      <course-list :data="courseData" :loading="loading" @handle="(type, item) => courseHandle(type, item)" :checkedCouerse="selectCourses" @update:checkedList="handleCheckedChange"></course-list>
     </el-card>
     <course-import ref="courseImportRef"></course-import>
   </div>
@@ -23,8 +28,9 @@
   import courseSearch from './course-search.vue'
   import courseList from './course-list.vue'
   import courseImport from './course-import.vue'
-  import { Plus, Upload, Download } from '@element-plus/icons-vue'
+  import { Plus, Upload, Download, Delete, CopyDocument } from '@element-plus/icons-vue'
   import { router } from '@/router'
+  import { CheckboxValueType } from 'element-plus'
 
   // 搜索项
   const showSearchBar = ref<boolean>(false)
@@ -33,8 +39,27 @@
   const courseImportRef = ref()
   const courseImportHandle = () => courseImportRef?.value.show()
 
-  // 选择的课程
-  const selectCourses: string[] = []
+  // 选择的课程Id
+  const selectCourses = ref<string[]>([])
+  // 是否全选
+  const checkAll = computed({
+    get: () => selectCourses.value.length === courseData.value.length && courseData.value.length > 0,
+    set: (val: boolean) => (selectCourses.value = val ? [...courseData.value.map((item) => item.courseID)] : [])
+  })
+  // 是否半选
+  const isIndeterminate = computed(() => {
+    const checkedCount = selectCourses.value.length
+    return checkedCount > 0 && checkedCount < courseData.value.length
+  })
+  // 处理子组件更新
+  const handleCheckedChange = (newList: string[]) => {
+    selectCourses.value = newList
+  }
+  // 全选复选框变更(其实 computed setter 已处理,此函数可省略,但保留以兼容 @change)
+  const handleCheckAllChange = (val: CheckboxValueType) => {
+    const checked = val as boolean
+    selectCourses.value = checked ? [...courseData.value.map((item) => String(item.courseID))] : []
+  }
 
   const courseData = ref<Course[]>([])
   const loading = ref(false)
@@ -71,6 +96,40 @@
         break
     }
   }
+  const copyCourseHandle = async () => {
+    if (selectCourses.value.length === 0) {
+      ElMessage.warning('请选择要复制的课程')
+      return
+    }
+    CourseServer.copyBatch(selectCourses.value).then(() => {
+      ElMessage.success('复制成功')
+    })
+  }
+  const exportCourseHandle = async () => {
+    if (selectCourses.value.length === 0) {
+      ElMessage.warning('请选择要导出的课程')
+      return
+    }
+    CourseServer.export(selectCourses.value).then(() => {
+      ElMessage.success('导出成功')
+    })
+  }
+  const batchDeleteHandle = async () => {
+    if (selectCourses.value.length === 0) {
+      ElMessage.warning('请选择要删除的课程')
+      return
+    }
+    ElMessageBox.confirm(`确定删除选中的 ${selectCourses.value.length} 条课程吗?`, '删除确认', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    }).then(() => {
+      CourseServer.deleteBatch(selectCourses.value).then(() => {
+        ElMessage.success('删除成功')
+        getCourseData({})
+      })
+    })
+  }
   onMounted(async () => {
     getCourseData({})
   })