Przeglądaj źródła

feat(课程管理): 新增课程导入功能并优化课程列表交互

zhanghaijun 11 miesięcy temu
rodzic
commit
0c81e434a6

+ 37 - 37
src/api/course.api.ts

@@ -2,97 +2,97 @@ import request from '@/api'
 
 /** 课程模型 */
 export interface Course {
-  courseID: number /** 课程ID*/
+  courseID: string /** 课程ID*/
   courseName: string /** 课程名称*/
   courseDesc: string /** 课程描述*/
-  totalDuration: number /** 课程总时长*/
-  typeID: number /** 分类ID*/
+  totalDuration: string /** 课程总时长*/
+  typeID: string /** 分类ID*/
   courseDataStream: string /** 课程数据流*/
-  externalTypeID: number /** 外部分类ID*/
+  externalTypeID: string /** 外部分类ID*/
   courseGUID: string /** 课程GUID*/
   guideDesc: string /** 指导描述*/
   guideFilePath: string /** 指导文件地址*/
-  lineNo: number /** 线路号*/
-  runningMap: number /** 运行图*/
+  lineNo: string /** 线路号*/
+  runningMap: string /** 运行图*/
   virtualRole: string /** 虚拟角色,使用逗号隔开*/
-  virtualStartTime: number /** 虚拟起始时间*/
+  virtualStartTime: string /** 虚拟起始时间*/
   creator: string /** 创建人*/
   createTime: string /** 创建时间*/
   modifyTime: string /** 修改时间*/
 }
 
 export class CourseServer {
-  public static list = (data: any) => request.post<Course[]>('/course/list', data)
-  public static detail = (courseId: number) => request.post<Course>('/course/detail/get', { courseId })
+  public static list = (params: Record<string, unknown>) => request.post<Course[]>('/course/list', params)
+  public static detail = (courseId: string) => request.post<Course>('/course/detail/get', { courseId })
   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: number) => request.post('/course/delete', { courseId })
+  public static delete = (courseId: string) => request.post('/course/delete', { courseId })
   // 课程复制
-  public static copy = (courseId: number) => request.post('/course/copy', { courseId })
+  public static copy = (courseId: string) => request.post('/course/copy', { courseId })
   // 课程导入
   public static import = (file: Blob | File) => request.upload('/course/import', { file })
   // 课程导出
-  public static export = (courseIds: number[]) => request.post('/course/export', { courseIds })
+  public static export = (courseIds: string[]) => request.post('/course/export', { courseIds })
 }
 
 /** 用户角色 */
 export interface ProcessRole {
-  roleID: number /** 角色ID */
+  roleID: string /** 角色ID */
   roleName: string /** 角色名称 */
-  roleType: number /** 角色类型 */
+  roleType: string /** 角色类型 */
 }
 /** 操作类模板 */
 export interface ProcessOperate {
-  templateID: number /** 模板ID */
+  templateID: string /** 模板ID */
   templateName: string /** 操作模板名称 */
-  templateType: number /** 操作模板类型 */
-  category: number /** 分类 */
+  templateType: string /** 操作模板类型 */
+  category: string /** 分类 */
   categoryName: string /** 分类名称 */
   param1Desc: string /** 参数1说明 */
-  param1Type: number /** 参数1类型 */
+  param1Type: string /** 参数1类型 */
   param2Desc: string /** 参数2说明 */
-  param2Type: number /** 参数2类型 */
+  param2Type: string /** 参数2类型 */
   param3Desc: string /** 参数3说明 */
-  param3Type: number /** 参数3类型 */
+  param3Type: string /** 参数3类型 */
   param4Desc: string /** 参数4说明 */
-  param4Type: number /** 参数4类型 */
+  param4Type: string /** 参数4类型 */
   param5Desc: string /** 参数5说明 */
-  param5Type: number /** 参数5类型 */
-  subCategory: number /** 子分类 */
+  param5Type: string /** 参数5类型 */
+  subCategory: string /** 子分类 */
   subCategoryName: string /** 子分类名称 */
 }
 /** 故障类模板 */
 export interface ProcessFault {
-  templateID: number /** 模板ID */
+  templateID: string /** 模板ID */
   templateName: string /** 模板名称 */
-  templateType: number /** 故障模板类型 */
-  category: number /** 分类 */
+  templateType: string /** 故障模板类型 */
+  category: string /** 分类 */
   categoryName: string /** 分类名称 */
   param1Desc: string /** 参数1说明 */
-  param1Type: number /** 参数1类型 */
+  param1Type: string /** 参数1类型 */
   param2Desc: string /** 参数2说明 */
-  param2Type: number /** 参数2类型 */
+  param2Type: string /** 参数2类型 */
   param3Desc: string /** 参数3说明 */
-  param3Type: number /** 参数3类型 */
+  param3Type: string /** 参数3类型 */
   param4Desc: string /** 参数4说明 */
-  param4Type: number /** 参数4类型 */
+  param4Type: string /** 参数4类型 */
   param5Desc: string /** 参数5说明 */
-  param5Type: number /** 参数5类型 */
-  subCategory: number /** 子分类 */
+  param5Type: string /** 参数5类型 */
+  subCategory: string /** 子分类 */
   subCategoryName: string /** 子分类名称 */
 }
 /** 语音类模板 */
 export interface ProcessVoice {
-  templateID: number /** 模板ID */
+  templateID: string /** 模板ID */
   templateName: string /** 模板名称 */
-  templateType: number /** 语音模板类型 */
-  category: number /** 分类 */
+  templateType: string /** 语音模板类型 */
+  category: string /** 分类 */
   categoryName: string /** 分类名称 */
-  subCategory: number /** 子分类 */
+  subCategory: string /** 子分类 */
   subCategoryName: string /** 子分类名称 */
   voiceContent: string /** 语音内容 */
   evalTemplate: string /** 评估模板 */
-  destRoleID: number /** 目标角色ID */
+  destRoleID: string /** 目标角色ID */
 }
 export class ProcessServer {
   // 角色

+ 2 - 3
src/components/business/EasyProcess/core/drawer_wrap.vue

@@ -1,13 +1,12 @@
 <template>
   <Dialog>
     <div class="drawer_wrap">
-      <component :is="DrawerMap[node.nodeType] || DrawerDefault" :node="node" />
+      <component :is="node.nodeType in DrawerMap ? DrawerMap[node.nodeType as keyof typeof DrawerMap] : DrawerDefault" :node="node" />
     </div>
   </Dialog>
 </template>
 
 <script setup lang="ts" name="DrawerWrap">
-  import { Component } from 'vue'
   import { useDialog } from '@/components/dialog'
   import { EasyProcess } from '../common/process.type'
 
@@ -17,7 +16,7 @@
   import DrawerCondition from '../node/condition_drawer.vue'
   import DrawerDefault from './drawer_default.vue'
 
-  const DrawerMap = shallowRef<Record<string, Component>>({
+  const DrawerMap = shallowRef({
     Operation: DrawerOperation,
     Fault: DrawerFault,
     Voice: DrawerVoice,

+ 2 - 8
src/typings/api.d.ts

@@ -88,10 +88,7 @@ declare namespace Api {
     }
 
     /** 用户搜索参数 */
-    type UserSearchParams = Partial<
-      Pick<UserListItem, 'id' | 'userName' | 'userGender' | 'userPhone' | 'userEmail' | 'status'> &
-        Api.Common.CommonSearchParams
-    >
+    type UserSearchParams = Partial<Pick<UserListItem, 'id' | 'userName' | 'userGender' | 'userPhone' | 'userEmail' | 'status'> & Api.Common.CommonSearchParams>
 
     /** 角色列表 */
     type RoleList = Api.Common.PaginatedResponse<RoleListItem>
@@ -107,9 +104,6 @@ declare namespace Api {
     }
 
     /** 角色搜索参数 */
-    type RoleSearchParams = Partial<
-      Pick<RoleListItem, 'roleId' | 'roleName' | 'roleCode' | 'description' | 'enabled'> &
-        Api.Common.CommonSearchParams
-    >
+    type RoleSearchParams = Partial<Pick<RoleListItem, 'roleId' | 'roleName' | 'roleCode' | 'description' | 'enabled'> & Api.Common.CommonSearchParams>
   }
 }

+ 5 - 2
src/utils/http/server.axios.ts

@@ -46,6 +46,9 @@ export class RequestHttp {
         const { code, msg } = res
         if (code === ApiStatus.success) return response
         if (code === ApiStatus.unauthorized) handleUnauthorizedError(msg)
+        if (code === ApiStatus.internalServerError) {
+          ElMessage.error('接口报错:' + (msg || $t('httpMsg.requestFailed')))
+        }
         throw new Error(msg || $t('httpMsg.requestFailed'))
       },
       (error: AxiosError) => {
@@ -92,7 +95,7 @@ export class RequestHttp {
     return this.service.post(url, params, { ..._object, responseType: 'blob' })
   }
 
-  public upload(url: string, data: Record<string, any> & { file: Blob | File }, _object: ExtendedAxiosRequestConfig = {}): Promise<ApiResponse> {
+  public upload<T>(url: string, data: Record<string, any> & { file: Blob | File }, _object: ExtendedAxiosRequestConfig = {}): Promise<ApiResponse<T>> {
     const formData = new FormData()
     Object.entries(data).forEach(([key, value]) => {
       if (Array.isArray(value)) {
@@ -107,6 +110,6 @@ export class RequestHttp {
       ..._object,
       headers: { 'Content-Type': 'multipart/form-data', ..._object?.headers }
     }
-    return this.post(url, formData, finalConfig)
+    return this.post<T>(url, formData, finalConfig)
   }
 }

+ 96 - 0
src/views/train/course/course-import.vue

@@ -0,0 +1,96 @@
+<template>
+  <Dialog>
+    <div class="m-4">
+      <!-- 文件上传区域 -->
+      <el-upload
+        ref="uploadRef"
+        class="avatar-uploader"
+        action=""
+        :auto-upload="false"
+        :show-file-list="false"
+        :on-change="handleChange"
+        :on-exceed="handleExceed"
+        :limit="1"
+        accept=".xlsx,.xls,.csv,.json,.xml"
+      >
+        <el-button size="small" type="primary" :disabled="!!file">
+          {{ file ? '已选择文件' : '点击上传' }}
+        </el-button>
+        <template #tip>
+          <div class="el-upload__tip mt-1 text-gray-500"> 支持文件格式:Excel、CSV、JSON、XML,大小不超过 5MB </div>
+        </template>
+      </el-upload>
+
+      <!-- 显示已选文件名 -->
+      <div v-if="file" class="mt-2 flex items-center">
+        <el-tag size="small" type="success" class="mr-2">
+          {{ file.name }}
+        </el-tag>
+        <el-button size="small" type="danger" @click="clearFile">清除</el-button>
+      </div>
+    </div>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+  import { ref, nextTick } from 'vue'
+  import { ElMessage } from 'element-plus'
+  import { useDialog } from '@/components/dialog'
+  import { CourseServer } from '@/api/course.api'
+
+  const [Dialog, dialogApi] = useDialog({
+    title: '课程导入',
+    width: '30%',
+    modal: true,
+    onConfirm: async () => {
+      if (!file.value) {
+        ElMessage.error('请选择要导入的文件!')
+        return
+      }
+      dialogApi.setState({ confirmLoading: true })
+      CourseServer.import(file.value)
+        .then(() => {
+          ElMessage.success('导入成功!')
+          dialogApi.close()
+        })
+        .finally(() => {
+          dialogApi.setState({ confirmLoading: false })
+        })
+    }
+  })
+
+  const uploadRef = ref()
+  const file = ref<File | null>(null)
+
+  const handleExceed = () => ElMessage.warning('只能上传一个文件!')
+  // 文件变化时处理
+  const handleChange = (uploadFile: any) => {
+    const rawFile = uploadFile.raw as File
+    if (rawFile.size > 5 * 1024 * 1024) {
+      ElMessage.error('文件大小不能超过 5MB!')
+      nextTick(() => uploadRef.value?.clearFiles())
+      file.value = null
+      return
+    }
+    file.value = rawFile
+  }
+
+  // 清除已选文件
+  const clearFile = () => {
+    file.value = null
+    uploadRef.value?.clearFiles()
+  }
+  // 暴露 show 方法
+  defineExpose({
+    show: () => {
+      file.value = null
+      dialogApi.open()
+    }
+  })
+</script>
+
+<style lang="scss" scoped>
+  .avatar-uploader ::v-deep(.el-upload) {
+    display: inline-block;
+  }
+</style>

+ 54 - 31
src/views/train/course/course-list.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="list">
     <div class="offset">
-      <div class="item" v-for="item in data" :key="item.courseID" @click="toDetail(item)">
+      <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>
@@ -23,17 +23,19 @@
                 </template>
               </ElImage>
 
-              <span class="type">{{ useDateFormat(item.createTime, 'YYYY-MM-DD') }}</span>
+              <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 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="toEdit(item)">编辑</el-button>
-                <el-button size="small" type="danger" :icon="Delete" @click.stop="deleteCourse(item)">删除</el-button>
-                <el-button size="small" type="info" :icon="CopyDocument" @click.stop="copyCourse(item)">复制</el-button>
-                <!-- <el-button size="small" type="link"> 编辑记录 </el-button> -->
-                <el-button size="small" type="text"> 培训记录 </el-button>
+                <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>
           </template>
@@ -59,34 +61,49 @@
     loading: true
   })
 
+  const selectCourses = ref<string[]>([])
+
   const emit = defineEmits<{
-    delete: [item: Course]
-    copy: [item: Course]
-    detail: [item: Course]
-    edit: [item: Course]
+    handle: [type: string, item: Course]
+    change: [item: string[]]
   }>()
 
   const getCover = (item: Course) => {
-    switch (item.typeID) {
-      case 1:
-        return cover1
-      case 2:
-        return cover2
-      case 3:
-        return cover3
+    if (item.typeID == '1') return cover1
+    if (item.typeID == '2') return cover2
+    if (item.typeID == '3') return cover3
+    return cover2
+  }
+
+  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: '确定',
+          cancelButtonText: '取消',
+          type: 'warning'
+        }).then(() => emit('handle', 'delete', item))
+        break
       default:
-        return cover1
+        emit('handle', type, item)
+        break
     }
+    return true
   }
-  const toDetail = (item: Course) => emit('detail', item)
-  const toEdit = (item: Course) => emit('edit', item)
-  const copyCourse = (item: Course) => emit('copy', item)
-  const deleteCourse = (item: Course) => {
-    ElMessageBox.confirm('确定删除课程吗?', '提示', {
-      confirmButtonText: '确定',
-      cancelButtonText: '取消',
-      type: 'warning'
-    }).then(() => emit('delete', item))
+
+  const isSelected = (item: Course) => {
+    return selectCourses.value.includes(String(item.courseID))
   }
 </script>
 <style lang="scss" scoped>
@@ -139,16 +156,22 @@
               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);
-            background: rgba($color: #000, $alpha: 60%);
-            border-radius: 4px;
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            .type-tag {
+              padding: 2px 4px;
+              border-radius: 4px;
+              background: rgba($color: #000, $alpha: 60%);
+            }
           }
         }
 

+ 42 - 91
src/views/train/course/course-search.vue

@@ -1,102 +1,53 @@
-<!-- 表格搜索栏示例 -->
 <template>
-  <ArtSearchBar ref="searchBarBasicRef" v-model="formDataBasic" :items="baseFormItems" @search="search"> </ArtSearchBar>
+  <ArtSearchBar ref="courseSearchRef" v-model="courseFormData" :items="formItems" labelWidth="100px" @search="courseSearchHandle"> </ArtSearchBar>
 </template>
 
 <script setup lang="ts">
-  // 表单数据双向绑定
-  const searchBarBasicRef = ref()
-  // 基础示例表单数据
-  const formDataBasic = ref({
-    courseID: undefined,
-    courseName: undefined,
-    courseType: undefined,
-    createUser: undefined,
-    trainPlan: undefined,
-    level: undefined,
-    date: undefined
+  import { Course } from '@/api/course.api'
+
+  // courseID 非必须,其他字段必须
+  // 培训计划 非必须
+  interface CourseSearch extends Pick<Course, 'courseID' | 'courseGUID' | 'courseName' | 'courseDesc' | 'typeID' | 'externalTypeID' | 'lineNo' | 'runningMap' | 'creator'> {
+    // 培训计划
+    trainPlan: string
+  }
+
+  const courseSearchRef = ref()
+  const emit = defineEmits<{
+    (e: 'search', params: Record<string, unknown>): void
+  }>()
+
+  const courseFormData = reactive<CourseSearch>({
+    courseID: '',
+    courseGUID: '',
+    courseName: '',
+    courseDesc: '',
+    typeID: '',
+    externalTypeID: '',
+    lineNo: '',
+    runningMap: '',
+    creator: '',
+    trainPlan: ''
   })
-  // 基础表单项配置
-  const baseFormItems = [
-    { label: '课程编码', key: 'courseID', type: 'input', placeholder: '请输入课程编码', clearable: true },
+
+  const formItems = computed(() => [
+    { label: '课程ID', key: 'courseID', type: 'input', placeholder: '请输入课程ID', clearable: true },
+    { label: '课程GUID', key: 'courseGUID', type: 'input', placeholder: '请输入课程GUID', clearable: true },
     { label: '课程名称', key: 'courseName', type: 'input', placeholder: '请输入课程名称', clearable: true },
-    {
-      label: '课程类型',
-      key: 'courseType',
-      type: 'select',
-      props: {
-        placeholder: '请选择课程类型',
-        options: [
-          { label: '基础操作', value: '1' },
-          { label: '故障处理', value: '2' },
-          { label: '非正常处理', value: '3' }
-        ]
-      }
-    },
-    {
-      label: '培训对象',
-      key: 'trainObject',
-      type: 'select',
-      props: {
-        placeholder: '请选择培训对象',
-        options: [
-          { label: '行车调度员', value: '1' },
-          { label: '电力调度员', value: '2' },
-          { label: '供电检修员', value: '3' }
-        ]
-      }
-    },
-    {
-      label: '培训路线',
-      key: 'trainRoute',
-      type: 'select',
-      props: {
-        placeholder: '请选择培训路线',
-        options: [
-          { label: '成渝高铁线', value: '1' },
-          { label: '成西高铁线', value: '2' },
-          { label: '青藏铁路', value: '3' }
-        ]
-      }
-    },
+    { label: '课程描述', key: 'courseDesc', type: 'input', placeholder: '请输入课程描述', clearable: true },
     { label: '培训计划', key: 'trainPlan', type: 'input', placeholder: '请输入培训计划', clearable: true },
-    {
-      label: '培训日期',
-      key: 'date',
-      type: 'datetime',
-      props: {
-        style: { width: '100%' },
-        placeholder: '请选择培训日期',
-        type: 'date',
-        valueFormat: 'YYYY-MM-DD',
-        shortcuts: [
-          { text: '今日', value: new Date() },
-          { text: '昨日', value: () => new Date(Date.now() - 86400000) },
-          { text: '一周前', value: () => new Date(Date.now() - 604800000) }
-        ]
-      }
-    },
-    { label: '创建人', key: 'createUser', type: 'input', placeholder: '请输入创建人', clearable: true }
-  ]
+    { label: '分类ID', key: 'typeID', type: 'input', placeholder: '请输入分类ID', clearable: true },
+    { label: '外部分类ID', key: 'externalTypeID', type: 'input', placeholder: '请输入外部分类ID', clearable: true },
+    { label: '线路号', key: 'lineNo', type: 'input', placeholder: '请输入线路号', clearable: true },
+    { label: '运行图', key: 'runningMap', type: 'input', placeholder: '请输入运行图', clearable: true },
+    { label: '创建人', key: 'creator', type: 'input', placeholder: '请输入创建人', clearable: true }
+  ])
 
-  const search = async () => {
-    await searchBarBasicRef.value.validate()
-    console.log(`表单数据`, formDataBasic.value)
+  const courseSearchHandle = async () => {
+    await courseSearchRef.value.validate()
+    emit('search', courseFormData)
+    console.log('表单数据', courseFormData)
   }
 </script>
 
-<style scoped lang="scss">
-  .search-bar {
-    padding-bottom: 20px;
-
-    .title {
-      margin-bottom: 5px;
-      font-size: 18px;
-      font-weight: 500;
-
-      &.m-15 {
-        margin-top: 15px;
-      }
-    }
-  }
-</style>
+<style scoped lang="scss"></style>

+ 1 - 18
src/views/train/course/detail/course-flow.vue

@@ -4,9 +4,8 @@
   </div>
 </template>
 
-<script setup>
+<script setup lang="ts">
   import ProcessDesigner from '@/components/business/EasyProcess/index.vue'
-  import { ElMessageBox } from 'element-plus'
   import { onMounted, ref } from 'vue'
   import mockData from './flow.json'
 
@@ -23,22 +22,6 @@
   onMounted(() => {
     processData.value = mockData
   })
-
-  /**
-   * 发布流程
-   */
-  const publish = () => {
-    if (!processRef.value) return
-    processRef.value.validate((valid, messages) => {
-      if (valid) {
-        const result = processRef.value?.getResult()
-        console.info('发布流程:', result)
-        ElMessageBox.alert(JSON.stringify(result, null, 2), '结果')
-      } else {
-        ElMessageBox.alert(messages, '结果')
-      }
-    })
-  }
 </script>
 
 <style lang="scss" scoped></style>

+ 30 - 24
src/views/train/course/index.vue

@@ -1,24 +1,20 @@
 <template>
   <div class="page-content">
     <h2 class="title">课程列表</h2>
-    <course-search></course-search>
+    <course-search v-show="showSearchBar" @search="getCourseData"></course-search>
     <el-card class="art-table-card" shadow="never">
-      <div class="header" id="art-table-header">
-        <el-space wrap>
-          <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"> 导入课程 </el-button>
-          <el-button v-ripple type="default" :icon="Download"> 导出课程 </el-button>
-        </el-space>
-      </div>
-      <course-list
-        :data="courseData"
-        :loading="loading"
-        @copy="(item) => courseHandle('copy', item)"
-        @detail="(item) => courseHandle('detail', item)"
-        @delete="(item) => courseHandle('delete', item)"
-        @edit="(item) => courseHandle('edit', item)"
-      ></course-list>
+      <ArtTableHeader v-model:showSearchBar="showSearchBar" :loading="loading" @refresh="() => getCourseData({})" layout="search,refresh">
+        <template #left>
+          <ElSpace wrap>
+            <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>
+          </ElSpace>
+        </template>
+      </ArtTableHeader>
+      <course-list :data="courseData" :loading="loading" @handle="(type, item) => courseHandle(type, item)" @change="(ids) => (selectCourses = ids)"></course-list>
     </el-card>
+    <course-import ref="courseImportRef"></course-import>
   </div>
 </template>
 <script lang="ts" setup>
@@ -26,19 +22,26 @@
   import { CourseServer, type Course } from '@/api/course.api'
   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 { router } from '@/router'
 
+  // 搜索项
+  const showSearchBar = ref<boolean>(false)
+
+  // 导入课程
+  const courseImportRef = ref()
+  const courseImportHandle = () => courseImportRef?.value.show()
+
+  // 选择的课程
+  const selectCourses: string[] = []
+
   const courseData = ref<Course[]>([])
   const loading = ref(false)
 
-  onMounted(async () => {
-    getCourseData()
-  })
-
-  const getCourseData = async () => {
+  const getCourseData = async (params: Record<string, unknown>) => {
     loading.value = true
-    const { data } = await CourseServer.list({})
+    const { data } = await CourseServer.list(params)
     courseData.value = data!
     loading.value = false
   }
@@ -48,13 +51,13 @@
       case 'delete':
         CourseServer.delete(item.courseID).then(() => {
           ElMessage.success('删除成功')
-          getCourseData()
+          getCourseData({})
         })
         break
       case 'copy':
         CourseServer.copy(item.courseID).then(() => {
           ElMessage.success('复制成功')
-          getCourseData()
+          getCourseData({})
         })
         break
       case 'detail':
@@ -68,6 +71,9 @@
         break
     }
   }
+  onMounted(async () => {
+    getCourseData({})
+  })
 </script>
 <style lang="scss" scoped>
   .title {