dusenyao 3 лет назад
Родитель
Сommit
a650172372
40 измененных файлов с 2146 добавлено и 1011 удалено
  1. 1 8
      .eslintrc.js
  2. 9 9
      package-lock.json
  3. 3 3
      package.json
  4. 7 0
      src/App.vue
  5. 65 0
      src/api/app.js
  6. 30 4
      src/api/course.js
  7. 22 15
      src/components/common/RichText.vue
  8. 6 6
      src/components/select/SelectCourse.vue
  9. 3 0
      src/icons/svg/check-circle-filled.svg
  10. 6 0
      src/icons/svg/class-grey.svg
  11. 1 0
      src/icons/svg/class.min.svg
  12. 3 0
      src/icons/svg/error-circle-filled.svg
  13. 9 0
      src/icons/svg/loading-blue.svg
  14. 3 0
      src/icons/svg/time-filled.svg
  15. 11 0
      src/utils/common.js
  16. 4 0
      src/utils/request.js
  17. 21 27
      src/views/teacher/create_course/step_table/create_task/components/LeftSidebar.vue
  18. 12 5
      src/views/teacher/create_course/step_table/create_task/components/RightSidebar.vue
  19. 21 4
      src/views/teacher/create_course/step_table/create_task/components/SelectTaskClassify.vue
  20. 84 63
      src/views/teacher/create_course/step_table/create_task/components/TaskEditor.vue
  21. 33 22
      src/views/teacher/create_course/step_table/create_task/components/TaskExplain/index.vue
  22. 83 0
      src/views/teacher/create_course/step_table/create_task/components/data/TaskData.js
  23. 33 18
      src/views/teacher/create_course/step_table/create_task/components/data/TaskType.js
  24. 0 28
      src/views/teacher/create_course/step_table/create_task/components/drag.js
  25. 40 0
      src/views/teacher/create_course/step_table/create_task/components/task_template/CreateTask.vue
  26. 30 6
      src/views/teacher/create_course/step_table/create_task/components/task_template/SubTask.vue
  27. 368 7
      src/views/teacher/create_course/step_table/create_task/components/task_template/SubtaskItem.vue
  28. 363 0
      src/views/teacher/create_course/step_table/create_task/components/task_template/TaskTemplate.vue
  29. 111 0
      src/views/teacher/create_course/step_table/create_task/components/task_template/components/TemplateCourseware.vue
  30. 193 0
      src/views/teacher/create_course/step_table/create_task/components/task_template/components/TemplateFile.vue
  31. 131 0
      src/views/teacher/create_course/step_table/create_task/components/task_template/components/file.js
  32. 0 163
      src/views/teacher/create_course/step_table/create_task/components/task_template/data.js
  33. 17 546
      src/views/teacher/create_course/step_table/create_task/components/task_template/index.vue
  34. 56 0
      src/views/teacher/create_course/step_table/create_task/components/util/drag.js
  35. 15 6
      src/views/teacher/create_course/step_table/create_task/components/util/mouseEvent.js
  36. 142 0
      src/views/teacher/create_course/step_table/create_task/components/util/recording.js
  37. 57 0
      src/views/teacher/create_course/step_table/create_task/components/util/wheel.js
  38. 0 28
      src/views/teacher/create_course/step_table/create_task/components/wheel.js
  39. 104 0
      src/views/teacher/create_course/step_table/create_task/index.js
  40. 49 43
      src/views/teacher/create_course/step_table/create_task/index.vue

+ 1 - 8
.eslintrc.js

@@ -215,13 +215,7 @@ module.exports = {
     'template-curly-spacing': [2, 'never'],
     'wrap-iife': [2, 'any'],
     'yield-star-spacing': [2, 'both'],
-    'object-curly-spacing': [
-      2,
-      'always',
-      {
-        objectsInObjects: false
-      }
-    ],
+    'object-curly-spacing': [2, 'always'],
     'no-alert': process.env.NODE_ENV === 'production' ? 1 : 0,
     'no-array-constructor': 2,
     'no-bitwise': 1,
@@ -245,7 +239,6 @@ module.exports = {
     'no-label-var': 2,
     'no-lone-blocks': 2,
     'no-lonely-if': 2,
-    'no-loop-func': 1,
     'linebreak-style': [0, 'windows'],
     'no-multi-str': 2,
     'no-nested-ternary': 0,

+ 9 - 9
package-lock.json

@@ -5087,9 +5087,9 @@
       }
     },
     "core-js": {
-      "version": "3.24.1",
-      "resolved": "https://repo.huaweicloud.com/repository/npm/core-js/-/core-js-3.24.1.tgz",
-      "integrity": "sha512-0QTBSYSUZ6Gq21utGzkfITDylE8jWC9Ne1D2MrhvlsZBI1x39OdDIVbzSqtgMndIy6BlHxBXpMGqzZmnztg2rg=="
+      "version": "3.25.0",
+      "resolved": "https://repo.huaweicloud.com/repository/npm/core-js/-/core-js-3.25.0.tgz",
+      "integrity": "sha512-CVU1xvJEfJGhyCpBrzzzU1kjCfgsGUxhEvwUV2e/cOedYWHdmluamx+knDnmhqALddMG16fZvIqvs9aijsHHaA=="
     },
     "core-js-compat": {
       "version": "3.24.0",
@@ -13150,9 +13150,9 @@
       "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
     },
     "sass": {
-      "version": "1.54.5",
-      "resolved": "https://repo.huaweicloud.com/repository/npm/sass/-/sass-1.54.5.tgz",
-      "integrity": "sha512-p7DTOzxkUPa/63FU0R3KApkRHwcVZYC0PLnLm5iyZACyp15qSi32x7zVUhRdABAATmkALqgGrjCJAcWvobmhHw==",
+      "version": "1.54.8",
+      "resolved": "https://repo.huaweicloud.com/repository/npm/sass/-/sass-1.54.8.tgz",
+      "integrity": "sha512-ib4JhLRRgbg6QVy6bsv5uJxnJMTS2soVcCp9Y88Extyy13A8vV0G1fAwujOzmNkFQbR3LvedudAMbtuNRPbQww==",
       "dev": true,
       "requires": {
         "chokidar": ">=3.0.0 <4.0.0",
@@ -15694,9 +15694,9 @@
       }
     },
     "vue-demi": {
-      "version": "0.13.10",
-      "resolved": "https://repo.huaweicloud.com/repository/npm/vue-demi/-/vue-demi-0.13.10.tgz",
-      "integrity": "sha512-/R4QhdqGyGqSysOfhkxmYHKwdETZq2z6HAf/fjeGErdJX9cJifX5ijHJS+VjNblGIhjXz/yQTwe/t7Cip+/aJw==",
+      "version": "0.13.11",
+      "resolved": "https://repo.huaweicloud.com/repository/npm/vue-demi/-/vue-demi-0.13.11.tgz",
+      "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==",
       "dev": true
     },
     "vue-esign": {

+ 3 - 3
package.json

@@ -18,7 +18,7 @@
     "awe-dnd": "^0.3.4",
     "axios": "^0.27.2",
     "book-ui": "file:../book-ui-0.3.15.tgz",
-    "core-js": "^3.24.1",
+    "core-js": "^3.25.0",
     "dayjs": "^1.11.5",
     "element-ui": "^2.15.9",
     "gcls-book-question-ui": "file:../gcls-book-question-ui-0.1.0.tgz",
@@ -60,7 +60,7 @@
     "postcss": "^8.4.16",
     "postcss-html": "^1.5.0",
     "prettier": "2.7.1",
-    "sass": "^1.54.5",
+    "sass": "^1.54.8",
     "sass-loader": "^10.3.1",
     "script-ext-html-webpack-plugin": "^2.1.5",
     "stylelint": "14.11.0",
@@ -74,7 +74,7 @@
     "svgo": "^2.8.0",
     "vue-loader": "^15.10.0",
     "vue-template-compiler": "^2.7.10",
-    "vue-demi": "^0.13.10"
+    "vue-demi": "^0.13.11"
   },
   "browserslist": [
     "> 1%",

+ 7 - 0
src/App.vue

@@ -17,6 +17,13 @@ export default {
   components: {
     ProgressBar
   },
+  provide() {
+    return {
+      $t: (key) => {
+        return this.$t(key);
+      }
+    };
+  },
   data() {
     return {
       dir: 'ltr',

+ 65 - 0
src/api/app.js

@@ -2,6 +2,7 @@ import { request, getRequestParams } from '@/utils/request';
 import { getToken } from '@/utils/auth';
 import { app } from '@/store/mutation-types.js';
 import store from '@/store';
+import { ref } from 'vue';
 
 /**
  * 得到语言列表
@@ -63,6 +64,70 @@ export function fileUpload(SecurityLevel, file) {
 }
 
 /**
+ * 可取消的上传文件
+ */
+export function fileUploadAbort(SecurityLevel, formData) {
+  const { token, isHas } = getToken();
+  let progress = ref(0);
+  const controller = new AbortController();
+  return {
+    request: request({
+      method: 'post',
+      url: '/GCLSFileServer/WebFileUpload',
+      timeout: 0,
+      params: {
+        UserCode: isHas ? token.user_code : '',
+        UserType: isHas ? token.user_type : '',
+        SessionID: isHas ? token.session_id : '',
+        SecurityLevel
+      },
+      headers: {
+        'Content-Type': 'multipart/form-data'
+      },
+      data: formData,
+      signal: controller.signal,
+      onUploadProgress: (progressEvent) => {
+        progress.value = (progressEvent.loaded / progressEvent.total) * 100 || 0;
+      }
+    }),
+    progress,
+    controller
+  };
+}
+
+/**
+ * 上传文件 原生类型
+ * @param {String} SecurityLevel 保密级别
+ */
+export function fileUploadPrimordial(SecurityLevel, formData) {
+  const { token, isHas } = getToken();
+
+  store.commit(`app/${app.SHOW_PROGRESS}`, true);
+
+  return request({
+    method: 'post',
+    url: '/GCLSFileServer/WebFileUpload',
+    timeout: 0,
+    params: {
+      UserCode: isHas ? token.user_code : '',
+      UserType: isHas ? token.user_type : '',
+      SessionID: isHas ? token.session_id : '',
+      SecurityLevel
+    },
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    },
+    data: formData,
+    onUploadProgress: (progressEvent) => {
+      store.commit(`app/${app.SET_PERCENTAGE}`, (progressEvent.loaded / progressEvent.total) * 100 || 0);
+    }
+  }).finally(() => {
+    store.commit(`app/${app.SHOW_PROGRESS}`, false);
+    store.commit(`app/${app.SET_PERCENTAGE}`, 0);
+  });
+}
+
+/**
  * 下载文件
  * @param {Object} data {FileID 文件ID}
  */

+ 30 - 4
src/api/course.js

@@ -604,14 +604,40 @@ export function GetCourseBookInfo_Brief(data) {
 }
 
 /**
- * 得到课节信息
- * @param { Object } data
+ * 得到课程的课节列表
+ * @param {Object} data
+ */
+export function GetCSItemListByCourseID(data) {
+  return request({
+    method: 'post',
+    url: process.env.VUE_APP_LearnWebSI,
+    params: getRequestParams('teaching-cs_item_manager-GetCSItemListByCourseID'),
+    data
+  });
+}
+
+/**
+ * 保存任务列表到课节
+ * @param {Object} data
+ */
+export function SaveTaskListToCSItem(data) {
+  return request({
+    method: 'post',
+    url: process.env.VUE_APP_LearnWebSI,
+    params: getRequestParams('teaching-task_manager-SaveTaskListToCSItem'),
+    data
+  });
+}
+
+/**
+ * 得到课节任务列表
+ * @param {Object} data
  */
-export function GetCSItemInfo(data) {
+export function GetCSItemTaskList(data) {
   return request({
     method: 'post',
     url: process.env.VUE_APP_LearnWebSI,
-    params: getRequestParams('teaching-cs_item_manager-GetCSItemInfo'),
+    params: getRequestParams('teaching-task_manager-GetCSItemTaskList'),
     data
   });
 }

+ 22 - 15
src/components/common/RichText.vue

@@ -2,11 +2,12 @@
   <Editor
     v-bind="$attrs"
     ref="richText"
+    :value="value"
     :class="['rich-text', isBorder ? 'is-border' : '']"
     :init="init"
     :style="{ border: '3px' }"
+    @input="updateValue"
     v-on="$listeners"
-    @input="handleInput"
   />
 </template>
 
@@ -41,6 +42,12 @@ import 'tinymce/plugins/lists';
 import 'tinymce/plugins/hr';
 
 const props = defineProps({
+  // 富文本数据
+  // vue2 中自定义组件上的 v-model 对应的值默认是 value,vue 3 是 modelValue
+  value: {
+    type: String,
+    required: true
+  },
   height: {
     type: [Number, String],
     default: 148
@@ -56,6 +63,12 @@ const props = defineProps({
   }
 });
 
+const emit = defineEmits(['update:value']);
+
+function updateValue(data) {
+  emit('update:value', data);
+}
+
 /* eslint-disable max-len */
 const toolbarList = [
   'undo redo | alignleft aligncenter alignright | forecolor | bold italic underline strikethrough code | bullist numlist | link image blockquote hr',
@@ -77,23 +90,17 @@ const init = {
 
 let richText = ref();
 
-function handleInput(e) {
-  // console.log(e);
-}
-
-function handleIframeWheel(e) {
-  if (e.ctrlKey) {
-    e.preventDefault();
-  }
-}
-
 function addWheelEvent() {
-  if (richText.value.$el.nextElementSibling.getElementsByTagName('iframe').length <= 0) {
+  if (!richText.value?.$el?.nextElementSibling?.getElementsByTagName('iframe')?.length) {
     return setTimeout(addWheelEvent, 100);
   }
-  richText.value.$el.nextElementSibling
-    .getElementsByTagName('iframe')[0]
-    .contentDocument.addEventListener('wheel', handleIframeWheel, { passive: false });
+  richText.value.$el.nextElementSibling.getElementsByTagName('iframe')[0].contentDocument.addEventListener(
+    'wheel',
+    (e) => {
+      if (e.ctrlKey) e.preventDefault();
+    },
+    { passive: false }
+  );
 }
 
 onMounted(() => {

+ 6 - 6
src/components/select/SelectCourse.vue

@@ -81,7 +81,6 @@ export default {
   components: { TreeMenus },
   props: {
     dialogVisible: {
-      default: false,
       type: Boolean,
       required: true
     },
@@ -111,16 +110,17 @@ export default {
     curBook() {
       this.GetBookChapterStruct();
     },
-    dialogVisible(val) {
-      if (val && this.book_list.length === 0) {
+    id(newVal) {
+      if (newVal?.length <= 0) return;
+      this.getCourseBookListByCSItemID();
+    },
+    dialogVisible(newVal) {
+      if (newVal && this.book_list.length === 0) {
         this.$message.warning(this.$i18n.t('Key383'));
         this.dialogClose();
       }
     }
   },
-  created() {
-    this.getCourseBookListByCSItemID();
-  },
   methods: {
     dialogClose() {
       this.$emit('dialogClose');

+ 3 - 0
src/icons/svg/check-circle-filled.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM4.5 8.2065L5.2065 7.5L7 9.293L10.7925 5.5L11.5 6.2075L7 10.707L4.5 8.2065Z" fill="#00A870"/>
+</svg>

+ 6 - 0
src/icons/svg/class-grey.svg

@@ -0,0 +1,6 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="20" height="20" rx="3" fill="#D7D7D7"/>
+<path d="M6 8.33398C7.10457 8.33398 8 7.43855 8 6.33398C8 5.22941 7.10457 4.33398 6 4.33398C4.89543 4.33398 4 5.22941 4 6.33398C4 7.43855 4.89543 8.33398 6 8.33398Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
+<path d="M7.66732 12.334H4.33398V15.6673H7.66732V12.334Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
+<path d="M10.5607 6.33398H16.0007V14.0007H10.334" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 1 - 0
src/icons/svg/class.min.svg

@@ -0,0 +1 @@
+<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="20" height="20" rx="3" fill="#2A76E8"/><path d="M6 8.333a2 2 0 100-4 2 2 0 000 4zM7.667 12.333H4.333v3.333h3.334v-3.333z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/><path d="M10.56 6.333H16V14h-5.667" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

+ 3 - 0
src/icons/svg/error-circle-filled.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M15 8C15 4.13401 11.866 1 8 1C4.13401 0.999998 1 4.134 1 8C0.999998 11.866 4.134 15 8 15C11.866 15 15 11.866 15 8ZM8.5 4.0003V9.49999H7.5V4.0003H8.5ZM7.39429 11H8.59429V12.2H7.39429V11Z" fill="#E34D59"/>
+</svg>

+ 9 - 0
src/icons/svg/loading-blue.svg

@@ -0,0 +1,9 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8 12.5C10.4853 12.5 12.5 10.4853 12.5 8C12.5 5.51472 10.4853 3.5 8 3.5C5.51472 3.5 3.5 5.51472 3.5 8C3.5 10.4853 5.51472 12.5 8 12.5ZM8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5Z" fill="url(#paint0_angular_938_7165)"/>
+<defs>
+<radialGradient id="paint0_angular_938_7165" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(8 8) scale(6.5)">
+<stop stop-color="white" stop-opacity="0.01"/>
+<stop offset="1" stop-color="#0052D9"/>
+</radialGradient>
+</defs>
+</svg>

+ 3 - 0
src/icons/svg/time-filled.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM7.5 5H8.5V7.97471L11.3538 10.8285L10.6467 11.5356L7.5 8.38892V5Z" fill="black" fill-opacity="0.4"/>
+</svg>

+ 11 - 0
src/utils/common.js

@@ -13,3 +13,14 @@ export function fullScreenCompatibility(dom) {
 export function setHtmlPageLang(locale) {
   document.querySelector('html')?.setAttribute('lang', locale);
 }
+
+/**
+ * 换算数据大小
+ * @param {Number} size
+ * @returns String
+ */
+export function conversionSize(size) {
+  if (size / 1024 < 1) return `${size}B`;
+  if (size / 1024 / 1024 < 1) return `${(size / 1024).toFixed(0)}KB`;
+  if (size / 1024 / 1024 / 1024 < 1) return `${(size / 1024 / 1024).toFixed(0)}GB`;
+}

+ 4 - 0
src/utils/request.js

@@ -67,6 +67,10 @@ service.interceptors.response.use(
     return res;
   },
   (error) => {
+    if (error.code === 'ERR_CANCELED') {
+      return Message.success('取消上传成功');
+    }
+
     Message({
       message: error.message,
       type: 'error',

+ 21 - 27
src/views/teacher/create_course/step_table/create_task/components/LeftSidebar.vue

@@ -1,14 +1,10 @@
 <template>
   <div class="left-sidebar">
     <template v-for="{ type, name, sidebarListName } in taskTypeArray">
-      <div
-        :key="type"
-        :class="[`${type}-task`, curTaskType === type ? 'active' : '']"
-        @click="handleChangeTaskType(type)"
-      >
+      <div :key="type" :class="[`${type}-task`, curTaskType === type ? 'active' : '']" @click="changeTaskType(type)">
         <span>{{ name }}</span>
         <span v-if="type !== TASK_EXPLAIN">
-          {{ CSItemInfo ? taskNumAndSubtaskNum(type) : 0 }}
+          {{ taskNumAndSubtaskNum(type) }}
         </span>
       </div>
       <div v-if="type !== TASK_EXPLAIN && CSItemInfo" :key="`${type}-task`" class="task-list">
@@ -31,18 +27,23 @@
   </div>
 </template>
 
+<script>
+export default {
+  name: 'LeftSidebar'
+};
+</script>
+
 <script setup>
-import { ref, inject } from 'vue';
-import { GetCSItemInfo } from '@/api/course';
-import { useTaskType } from './taskType.js';
+import { computed, ref } from 'vue';
+import { useTaskType } from './data/TaskType.js';
+import { taskData } from './data/TaskData';
 
-const id = inject('id');
 defineProps({
   curTaskType: {
     type: String,
     required: true
   },
-  handleChangeTaskType: {
+  changeTaskType: {
     type: Function,
     required: true
   },
@@ -62,23 +63,15 @@ let CSItemInfo = ref(null);
 let { taskTypeArray, TASK_EXPLAIN } = useTaskType();
 
 // 任务数量和子任务数量显示名
-function taskNumAndSubtaskNum(type) {
-  if (type === taskTypeArray[1].type) {
-    return `${CSItemInfo.value.pre_task_count}/${CSItemInfo.value.pre_child_task_count}`;
-  }
-  if (type === taskTypeArray[2].type) {
-    return `${CSItemInfo.value.mid_task_count}/${CSItemInfo.value.mid_child_task_count}`;
-  }
-  if (type === taskTypeArray[3].type) {
-    return `${CSItemInfo.value.after_task_count}/${CSItemInfo.value.after_child_task_count}`;
-  }
+function taskNumAndSubtaskNum(taskType) {
+  let childTaskNum = ref(0);
+  const index = taskTypeArray.findIndex(({ type }) => type === taskType);
+  taskData[taskTypeArray[index].sidebarListName].forEach(({ child_task_list }) => {
+    childTaskNum.value += child_task_list.length;
+  });
+  let listNum = taskData[taskTypeArray[index].sidebarListName].length;
+  return listNum > 0 ? `${taskData[taskTypeArray[index].sidebarListName].length}/${childTaskNum.value}` : 0;
 }
-
-GetCSItemInfo({
-  id
-}).then((info) => {
-  CSItemInfo.value = info;
-});
 </script>
 
 <style lang="scss" scoped>
@@ -87,6 +80,7 @@ $basic-background-color: #f7f7f7;
 .left-sidebar {
   width: 154px;
   padding: 16px 0 16px 8px;
+  overflow: auto;
   background-color: $basic-background-color;
 
   .explain-task {

+ 12 - 5
src/views/teacher/create_course/step_table/create_task/components/RightSidebar.vue

@@ -11,20 +11,26 @@
         :src="image"
         :alt="name"
         :draggable="type !== MORE_NAME"
-        @dragstart="dragstart($event, type)"
-        @dragend="dragend"
+        @dragstart="onDragstart($event, type)"
+        @dragend="onDragend"
       />
     </div>
   </div>
 </template>
 
+<script>
+export default {
+  name: 'RightSidebar'
+};
+</script>
+
 <script setup>
-import { useTaskClassify } from './taskType.js';
-import { handleDrag } from './drag.js';
+import { useTaskClassify } from './data/TaskType.js';
+import { useDrag, customTypeList } from './util/drag.js';
 
 const { taskClassify, MORE_NAME } = useTaskClassify();
 
-const { dragstart, dragend } = handleDrag();
+const { onDragstart, onDragend } = useDrag(true, customTypeList[0]);
 </script>
 
 <style lang="scss" scoped>
@@ -34,6 +40,7 @@ $basic-background-color: #f7f7f7;
   width: 200px;
   min-width: 200px;
   padding-top: 16px;
+  overflow: auto;
   background-color: $basic-background-color;
 
   .task-classify-item {

+ 21 - 4
src/views/teacher/create_course/step_table/create_task/components/SelectTaskClassify.vue

@@ -1,7 +1,14 @@
 <template>
-  <el-dialog :visible="visible" width="610px" :before-close="handleClose" :modal="false">
+  <el-dialog
+    :append-to-body="true"
+    top="40vh"
+    :visible="visible"
+    width="610px"
+    :before-close="handleClose"
+    :modal="false"
+  >
     <template v-for="{ type, image, name } in taskClassify">
-      <div v-if="type !== MORE_NAME" :key="type" :class="['task-classify']">
+      <div v-if="getSubtaskIsShow(type)" :key="type" :class="['task-classify']">
         <div class="task-classify-item" @click.once="selectTaskClassify(type)">
           <div class="taskClassify-name">
             {{ name }}
@@ -14,12 +21,16 @@
 </template>
 
 <script setup>
-import { useTaskClassify } from './taskType.js';
+import { useTaskClassify } from './data/TaskType.js';
 
-defineProps({
+const props = defineProps({
   visible: {
     type: Boolean,
     required: true
+  },
+  isAddSubtask: {
+    type: Boolean,
+    default: false
   }
 });
 
@@ -27,6 +38,12 @@ const emit = defineEmits(['select-task-classify', 'update:visible']);
 
 let { taskClassify, MORE_NAME } = useTaskClassify();
 
+function getSubtaskIsShow(type) {
+  if (MORE_NAME === type) return false;
+  if (props.isAddSubtask && (type === taskClassify[0].type || type === taskClassify[4].type)) return false;
+  return true;
+}
+
 function selectTaskClassify(type) {
   emit('select-task-classify', type);
 }

+ 84 - 63
src/views/teacher/create_course/step_table/create_task/components/TaskEditor.vue

@@ -1,70 +1,111 @@
-<!-- 任务编辑 -->
+<!-- 任务编辑与预览 -->
 <template>
   <div class="task-main">
-    <LeftSidebar
-      :handle-change-task-type="handleChangeTaskType"
-      :cur-task-type="curTaskType"
-      :cur-task-type-obj="curTaskTypeObj"
-    />
+    <LeftSidebar :change-task-type="changeTaskType" :cur-task-type="curTaskType" :cur-task-type-obj="curTaskTypeObj" />
 
-    <div ref="center" class="task-main-center" :style="{ cursor: mainCursorDisplayStyle, 'user-select': 'none' }">
-      <span class="zoom-display">
-        <svg-icon icon-class="minus" class-name="zoom-display-minus" />
+    <div ref="center" class="task-main-center" :style="centerStyle">
+      <span v-if="curPageType !== mainPageTypeList[0]" class="zoom-display">
+        <svg-icon icon-class="minus" class-name="zoom-display-minus" @click="changeScale(false)" />
         <span>{{ (scale * 100).toFixed(0) }}%</span>
-        <svg-icon icon-class="add" class-name="zoom-display-add" />
+        <svg-icon icon-class="add" class-name="zoom-display-add" @click="changeScale(true)" />
       </span>
       <div class="task-main-center-container" :style="{ transform: `scale(${scale})` }">
-        <!-- 创建任务 -->
-        <template v-if="curPageType === mainPageTypeList[0]">
-          <div class="create-task" @dragover="dragover" @drop="drop($event, setTaskClassifyType)">
-            <svg-icon icon-class="add" class-name="create-task-add" />
-            <span>创建{{ curTaskTypeObj.name }}</span>
-          </div>
-        </template>
-        <!-- 任务说明 -->
-        <template v-else-if="curPageType === mainPageTypeList[1]">
-          <TaskExplain />
-        </template>
-        <!-- 任务模板 -->
-        <template v-else-if="curPageType === mainPageTypeList[2]">
-          <TaskTemplate :cur-task-classify-type="curTaskClassifyType" />
-        </template>
+        <keep-alive>
+          <component :is="curPageType" />
+        </keep-alive>
       </div>
     </div>
 
-    <RightSidebar v-show="curPageType !== mainPageTypeList[1]" />
+    <RightSidebar v-show="curPageType !== mainPageTypeList[0]" />
+
+    <SelectTaskClassify :visible="visible" @select-task-classify="addTask" />
+    <SelectCourse
+      :id="cs_item_id"
+      :dialog-visible="visibleSelectCourse"
+      @selectCourse="selectCourseList"
+      @dialogClose="visibleSelectCourse = false"
+    />
   </div>
 </template>
 
+<script>
+export default {
+  name: 'TaskEditor'
+};
+</script>
+
 <script setup>
-import { ref } from 'vue';
-import { useTaskType } from './taskType.js';
-import { mouseEvent } from './mouseEvent.js';
-import { handleWheel } from './wheel.js';
-import { handleDrag } from './drag.js';
+import { computed, inject, provide, ref } from 'vue';
+import { useTaskType } from './data/TaskType.js';
+import { useMouseEvent } from './util/mouseEvent.js';
+import { useWheel, scale } from './util/wheel.js';
+import { taskData, addTaskList } from './data/TaskData';
+import { Message } from 'element-ui';
+import { GetTreeNodeInfo_BookChapterStruct } from '@/api/course';
 
-import TaskExplain from './TaskExplain/index.vue';
 import LeftSidebar from './LeftSidebar.vue';
-import TaskTemplate from './task_template/index.vue';
 import RightSidebar from './RightSidebar.vue';
+import SelectTaskClassify from './SelectTaskClassify.vue';
+import SelectCourse from '@/components/select/SelectCourse.vue';
 
 const center = ref(); // vue3 获取 refs 写法,需要同名,且传空
 
-const { curTaskType, curTaskTypeObj, curPageType, mainPageTypeList, handleChangeTaskType, handleChangeCurPageType } =
-  useTaskType();
+const { curTaskType, curTaskTypeObj, curPageType, mainPageTypeList, changeTaskType } = useTaskType();
 
-const { mainCursorDisplayStyle } = mouseEvent(center);
+const { centerStyle } = useMouseEvent(center);
 
-const { scale } = handleWheel(center);
+const { changeScale } = useWheel(center);
 
-const { dragover, drop } = handleDrag();
+// 当前任务模板列表(课前、课中、课后)
+let curTaskTemplateList = computed(() => {
+  if (!curTaskTypeObj.value.sidebarListName) return [];
+  return taskData[curTaskTypeObj.value.sidebarListName];
+});
+provide('curTaskTemplateList', curTaskTemplateList);
 
-const curTaskClassifyType = ref(''); // 当前任务分类类型
+// 显示选择任务类型
+let visible = ref(false);
+function changeVisible(boolean) {
+  visible.value = boolean;
+}
+provide('visible', {
+  visible,
+  changeVisible
+});
 
-function setTaskClassifyType(taskClassifyType) {
+// 添加任务
+function addTask(taskClassifyType) {
   if (!taskClassifyType) return;
-  curTaskClassifyType.value = taskClassifyType;
-  handleChangeCurPageType(mainPageTypeList[2]);
+  visible.value = false;
+  addTaskList(curTaskTypeObj.value.sidebarListName, taskClassifyType);
+}
+provide('addTask', addTask);
+
+// 选择课件
+let cs_item_id = inject('cs_item_id');
+let visibleSelectCourse = ref(false);
+let courseFn = null;
+function changeVisibleSelectCourse(boolean, fn) {
+  visibleSelectCourse.value = boolean;
+  courseFn = fn;
+}
+provide('visibleSelectCourse', {
+  visibleSelectCourse,
+  changeVisibleSelectCourse
+});
+
+const $t = inject('$t');
+
+function selectCourseList(course_id, previewGroupId) {
+  if (course_id.length === 0) {
+    Message.warning($t('Key385'));
+    return;
+  }
+  visibleSelectCourse.value = false;
+
+  GetTreeNodeInfo_BookChapterStruct({ id: course_id }).then(({ name, id }) => {
+    courseFn({ name, courseware_id: id, group_id_selected_info: previewGroupId });
+  });
 }
 </script>
 
@@ -84,30 +125,10 @@ $basic-background-color: #f7f7f7;
     border-left: 1px solid $border-color;
 
     &-container {
+      padding: 24px;
       transform-origin: 0 0;
     }
 
-    .create-task {
-      width: 197px;
-      height: 104px;
-      margin: 182px auto 0;
-      font-size: 16px;
-      font-weight: bold;
-      line-height: 102px;
-      color: v-bind('curTaskTypeObj.color');
-      text-align: center;
-      vertical-align: middle;
-      cursor: pointer;
-      user-select: none;
-      background-color: v-bind('curTaskTypeObj.button_background_color');
-      border: 1px solid v-bind('curTaskTypeObj.button_border_color');
-      border-radius: 16px;
-
-      &-add {
-        margin-right: 8px;
-      }
-    }
-
     .zoom-display {
       position: fixed;
       right: 216px;

+ 33 - 22
src/views/teacher/create_course/step_table/create_task/components/TaskExplain/index.vue

@@ -1,52 +1,70 @@
 <template>
   <div class="task-explain">
     <div class="task-title">任务标题</div>
-    <el-input :placeholder="placeholder" :disabled="disabled" />
+    <el-input v-model="task_intro.title" :placeholder="placeholder" :disabled="disabled" />
     <div class="task-explain-main">
       <div class="sub-title t-center">学科</div>
-      <el-input type="textarea" :placeholder="placeholder" resize="none" :disabled="disabled" />
+      <el-input
+        v-model="task_intro.subject"
+        type="textarea"
+        :placeholder="placeholder"
+        resize="none"
+        :disabled="disabled"
+      />
       <div class="aliquot">
         <div class="sub-title">班级</div>
         <div class="sub-title">执教者</div>
         <div class="sub-title">班型</div>
       </div>
       <div class="aliquot">
-        <el-input :placeholder="placeholder" :disabled="disabled" />
-        <el-input :placeholder="placeholder" :disabled="disabled" />
-        <el-input :placeholder="placeholder" :disabled="disabled" />
+        <el-input v-model="task_intro.class" :placeholder="placeholder" :disabled="disabled" />
+        <el-input v-model="task_intro.teacher" :placeholder="placeholder" :disabled="disabled" />
+        <el-input v-model="task_intro.class_mode" :placeholder="placeholder" :disabled="disabled" />
       </div>
       <ExpandContract title-class="sub-title" title="课题" :height="richTextHeight">
-        <RichText v-model="richText2" :disabled="disabled" :height="richTextHeight" />
+        <RichText v-model="task_intro.topic" :disabled="disabled" :height="richTextHeight" />
       </ExpandContract>
       <div class="aliquot">
         <div class="sub-title">课时</div>
         <div class="sub-title">节次</div>
       </div>
       <div class="aliquot">
-        <el-input :placeholder="placeholder" :disabled="disabled" />
-        <el-input :placeholder="placeholder" :disabled="disabled" />
+        <el-input v-model.number="task_intro.cs_hour" type="number" :placeholder="placeholder" :disabled="disabled" />
+        <el-input
+          v-model.number="task_intro.cs_item_count"
+          type="number"
+          :placeholder="placeholder"
+          :disabled="disabled"
+        />
       </div>
       <ExpandContract title-class="sub-title-two" title="教学目标" :height="richTextHeight">
-        <RichText v-model="richText" :disabled="disabled" :height="richTextHeight" />
+        <RichText v-model="task_intro.teaching_objective" :disabled="disabled" :height="richTextHeight" />
       </ExpandContract>
       <ExpandContract title-class="sub-title-two" title="教学重点" :height="richTextHeight">
-        <RichText v-model="richText3" :disabled="disabled" :height="richTextHeight" />
+        <RichText v-model="task_intro.teaching_focus" :disabled="disabled" :height="richTextHeight" />
       </ExpandContract>
       <ExpandContract title-class="sub-title-two" title="教学难点" :height="richTextHeight">
-        <RichText v-model="richText4" :disabled="disabled" :height="richTextHeight" />
+        <RichText v-model="task_intro.teaching_difficulty" :disabled="disabled" :height="richTextHeight" />
       </ExpandContract>
       <ExpandContract title-class="sub-title-two" title="教学方法" :height="richTextHeight">
-        <RichText v-model="richText5" :disabled="disabled" :height="richTextHeight" />
+        <RichText v-model="task_intro.teaching_method" :disabled="disabled" :height="richTextHeight" />
       </ExpandContract>
       <ExpandContract title-class="sub-title-two" title="课前准备" :height="richTextHeight">
-        <RichText v-model="richText6" :disabled="disabled" :height="richTextHeight" />
+        <RichText v-model="task_intro.pre_class_prepare" :disabled="disabled" :height="richTextHeight" />
       </ExpandContract>
     </div>
   </div>
 </template>
 
+<script>
+export default {
+  name: 'TaskExplain'
+};
+</script>
+
 <script setup>
 import { ref, computed } from 'vue';
+import { taskData } from '../data/TaskData';
 
 import RichText from '@/components/common/RichText.vue';
 import ExpandContract from './ExpandContract.vue';
@@ -55,21 +73,14 @@ const disabled = ref(false);
 const placeholder = computed(() => (disabled.value ? '' : '点击输入'));
 const richTextHeight = 148;
 
-defineProps({});
-
-const richText = ref('');
-const richText2 = ref('');
-const richText3 = ref('');
-const richText4 = ref('');
-const richText5 = ref('');
-const richText6 = ref('');
+let { task_intro } = taskData;
 </script>
 
 <style lang="scss" scoped>
 .task-explain {
   width: 804px;
   padding: 16px;
-  margin: 36px 0 36px max(36px, calc((100vw - 154px - 160px - 804px - 48px) / 2));
+  margin: 12px 0 12px 140px;
   overflow: auto;
   line-height: 22px;
   background: #fff;

+ 83 - 0
src/views/teacher/create_course/step_table/create_task/components/data/TaskData.js

@@ -0,0 +1,83 @@
+import { ref, reactive } from 'vue';
+import { useTaskClassify } from './TaskType';
+import { SaveTaskListToCSItem } from '@/api/course';
+
+const { taskClassify } = useTaskClassify();
+
+export let taskData = reactive({
+  cs_item_id: '', // 课节 id
+  // 任务说明
+  task_intro: {
+    title: '', // 标题
+    subject: '', // 学科
+    class: '', // 班级
+    teacher: '', // 执教者
+    class_mode: '', // 班型
+    topic: '', // 课题
+    cs_hour: 0, // 课时
+    cs_item_count: 0, // 节次
+    teaching_objective: '', // 教学目标
+    teaching_focus: '', // 教学重点
+    teaching_difficulty: '', // 教学难点
+    teaching_method: '', // 教学方法
+    pre_class_prepare: '' // 课前准备
+  },
+  // 课前任务列表
+  pre_task_list: [],
+  // 课中任务列表
+  mid_task_list: [],
+  // 课后任务列表
+  after_task_list: []
+});
+
+// 任务列表中类数组对象
+export let taskObject = {
+  name: '', // 任务名称
+  teaching_type: taskClassify[1].teaching_type, // 任务教学类型
+  begin_time: '', // 开始时间
+  duration_second: 3600, // 持续时长
+  teacher_id: '', // 授课教师 ID
+  content: '', // 任务内容说明
+  courseware_id_list: [], // 课件 id 列表
+  courseware_group_selected_list: [], // 课件分组选择列表
+  file_id_list: [], // 附件的文件 ID 列表
+  _files: [], // 临时保存的文件列表
+  recording_file_id_list: [], // 录制音视频文件 ID 列表
+  custom_student_id_list: [], // 任务自定义学员 ID 列表
+  child_task_list: [] // 子任务列表
+};
+
+// 添加任务
+export function addTaskList(list_name, teaching_type) {
+  taskData[list_name].push(Object.assign({}, taskObject, { teaching_type }));
+}
+
+// 过滤临时属性
+function filterTempProps(arr) {
+  return arr.map((item) => {
+    let obj = {};
+    for (const [key, value] of Object.entries(item)) {
+      if (/^_/.test(key)) continue;
+      if (key === 'child_task_list') {
+        obj[key] = filterTempProps(value);
+        continue;
+      }
+      obj[key] = value;
+    }
+    return obj;
+  });
+}
+
+// 保存任务列表到课节
+export function saveCSItem(id) {
+  const data = {
+    cs_item_id: id,
+    task_intro: taskData.task_intro,
+    pre_task_list: filterTempProps(taskData.pre_task_list),
+    mid_task_list: filterTempProps(taskData.mid_task_list),
+    after_task_list: filterTempProps(taskData.after_task_list)
+  };
+  SaveTaskListToCSItem(data).then((data) => {
+    console.log(data);
+  });
+}

+ 33 - 18
src/views/teacher/create_course/step_table/create_task/components/taskType.js → src/views/teacher/create_course/step_table/create_task/components/data/TaskType.js

@@ -1,4 +1,7 @@
-import { computed, ref } from 'vue';
+import { computed, ref, provide } from 'vue';
+import { useWheel, useScale } from '../util/wheel';
+import TaskExplain from '../TaskExplain/index.vue';
+import TaskTemplate from '../task_template/index.vue';
 
 /**
  * 任务类型相关
@@ -16,16 +19,18 @@ export function useTaskType() {
     {
       type: 'pre',
       name: '课前任务',
+      time_type: 0, // 任务时间类型
       active_color: '#2a76e8', // 激活时左侧按钮颜色
       background_color: '#eaf2ff', // 主背景色
       button_background_color: '#dbe8fe', // 中央按钮背景色
       button_border_color: '#cddffd', // 中央按钮边框色
       color: '#2A76E8', // 中央按钮文字颜色
-      sidebarListName: 'pre_task_list' // 左侧边栏任务和子任务列表对象类名
+      sidebarListName: 'pre_task_list' // 左侧边栏任务和任务、子任务列表对象类名
     },
     {
       type: 'mid',
       name: '课中任务',
+      time_type: 1,
       active_color: '#df5c5c',
       background_color: '#f5ebeb',
       button_background_color: '#f4e0e0',
@@ -36,6 +41,7 @@ export function useTaskType() {
     {
       type: 'after',
       name: '课后任务',
+      time_type: 2,
       active_color: '#59c057',
       background_color: '#eaf3ea',
       button_background_color: '#dfefdf',
@@ -45,30 +51,34 @@ export function useTaskType() {
     }
   ];
 
-  const mainPageTypeList = ['create', 'taskExplain', 'task']; // 主页面类型列表
-  let curPageType = ref(mainPageTypeList[0]); // 当前页面类型
-  let _selectTaskType = ref('pre'); // 选中的任务类型
+  const mainPageTypeList = [TaskExplain, TaskTemplate]; // 主页面类型列表
+  let curPageType = ref(mainPageTypeList[1]); // 当前页面类型
+  let curSelectTaskType = ref(taskTypeArray[1].type); // 当前选中的任务类型
 
   let curTaskType = computed(() => {
-    if (curPageType.value === mainPageTypeList[1]) return TASK_EXPLAIN;
-    return _selectTaskType.value;
+    if (curPageType.value === mainPageTypeList[0]) return TASK_EXPLAIN;
+    return curSelectTaskType.value;
   }); // 当前任务类型
 
   // 当前任务类型对象
   let curTaskTypeObj = computed(() => taskTypeArray.find(({ type }) => type === curTaskType.value));
+  provide('curTaskTypeObj', curTaskTypeObj);
 
   // 切换当前页面类型
-  function handleChangeCurPageType(type) {
+  function changeCurPageType(type) {
     curPageType.value = type;
   }
-
+  // const { handleWheelEvent } = useWheel();
+  const { resetScale } = useScale();
   // 切换任务类型
-  function handleChangeTaskType(type) {
+  function changeTaskType(type) {
     if (type === TASK_EXPLAIN) {
-      handleChangeCurPageType(mainPageTypeList[1]);
+      changeCurPageType(mainPageTypeList[0]);
+      resetScale();
+      // handleWheelEvent(true);
     } else {
-      _selectTaskType.value = type;
-      handleChangeCurPageType(mainPageTypeList[0]);
+      curSelectTaskType.value = type;
+      changeCurPageType(mainPageTypeList[1]);
     }
   }
 
@@ -79,8 +89,8 @@ export function useTaskType() {
     curPageType,
     mainPageTypeList,
     TASK_EXPLAIN,
-    handleChangeTaskType,
-    handleChangeCurPageType
+    changeTaskType,
+    changeCurPageType
   };
 }
 
@@ -94,26 +104,31 @@ export function useTaskClassify() {
     {
       type: 'basic',
       name: '基础',
+      teaching_type: 12, // 任务教学类型
       image: require('@/assets/course/create_task/basic.png')
     },
     {
       type: 'courseware',
       name: '课件',
+      teaching_type: 11,
       image: require('@/assets/course/create_task/courseware.png')
     },
     {
       type: 'file',
       name: '文件',
+      teaching_type: 13,
       image: require('@/assets/course/create_task/file.png')
     },
     {
       type: 'soundRecording',
       name: '录音/录影',
+      teaching_type: 14,
       image: require('@/assets/course/create_task/soundRecording.png')
     },
     {
       type: 'live',
       name: '直播',
+      teaching_type: 10,
       image: require('@/assets/course/create_task/live.png')
     },
     {
@@ -123,10 +138,10 @@ export function useTaskClassify() {
     }
   ];
 
-  function getTaskClassifyName(type) {
+  function getTaskClassifyAttr(type, propName = 'name') {
     if (!type) return '';
-    return taskClassify.find((item) => item.type === type).name;
+    return taskClassify.find((item) => item.type === type)[propName];
   }
 
-  return { taskClassify, MORE_NAME, getTaskClassifyName };
+  return { taskClassify, MORE_NAME, getTaskClassifyAttr };
 }

+ 0 - 28
src/views/teacher/create_course/step_table/create_task/components/drag.js

@@ -1,28 +0,0 @@
-/**
- * 处理拖拽事件
- */
-export function handleDrag() {
-  function dragstart(e, type) {
-    e.dataTransfer.setData('taskClassifyType', type); // 设置拖拽数据
-    e.target.style.opacity = 0.5; // 使其半透明
-  }
-
-  function dragover(e) {
-    e.preventDefault();
-  }
-
-  function dragend(e) {
-    e.target.style.opacity = ''; // 恢复透明度
-  }
-
-  function drop(e, fn) {
-    fn(e.dataTransfer.getData('taskClassifyType'));
-  }
-
-  return {
-    dragstart,
-    dragover,
-    dragend,
-    drop
-  };
-}

+ 40 - 0
src/views/teacher/create_course/step_table/create_task/components/task_template/CreateTask.vue

@@ -0,0 +1,40 @@
+<template>
+  <div class="create-task" @click="changeVisible(true)" @dragover="onDragover" @drop="onDrop($event, addTask)">
+    <svg-icon icon-class="add" class-name="create-task-add" />
+    <span>创建{{ curTaskTypeObj.name }}</span>
+  </div>
+</template>
+
+<script setup>
+import { inject } from 'vue';
+import { useDrag, customTypeList } from '../util/drag';
+
+const curTaskTypeObj = inject('curTaskTypeObj');
+const addTask = inject('addTask');
+const { changeVisible } = inject('visible');
+
+const { onDragover, onDrop } = useDrag(true, customTypeList[0]);
+</script>
+
+<style lang="scss" scoped>
+.create-task {
+  width: 197px;
+  height: 104px;
+  margin: 182px auto 0;
+  font-size: 16px;
+  font-weight: bold;
+  line-height: 102px;
+  color: v-bind('curTaskTypeObj.color');
+  text-align: center;
+  vertical-align: middle;
+  cursor: pointer;
+  user-select: none;
+  background-color: v-bind('curTaskTypeObj.button_background_color');
+  border: 1px solid v-bind('curTaskTypeObj.button_border_color');
+  border-radius: 16px;
+
+  &-add {
+    margin-right: 8px;
+  }
+}
+</style>

+ 30 - 6
src/views/teacher/create_course/step_table/create_task/components/task_template/SubTask.vue

@@ -16,45 +16,68 @@
       <span>任务标题</span>
       <el-input placeholder="点击输入" />
       <span>任务说明</span>
-      <RichText :height="209" :is-border="true" :toolbar-list-index="1" />
+      <RichText v-model="data" :height="209" :is-border="true" :toolbar-list-index="1" />
       <hr />
-      <SubtaskItem v-for="(item, i) in subtaskTypeList" :key="i" :classify="item" />
-      <div class="drag-add-subtask" @drop="drop($event, addSubtask)" @dragover="dragover">
+      <SubtaskItem v-for="(item, i) in subtaskTypeList" :key="i" :subtask-type="item" />
+      <div class="drag-add-subtask" @click="selectTaskType" @drop="onDrop($event, addSubtask)" @dragover="onDragover">
         拖动屏幕右侧“任务模版”到此处或点击此处以添加任务
       </div>
     </div>
+    <SelectTaskClassify :visible.sync="visible" :is-add-subtask="true" @select-task-classify="addSubtask" />
   </div>
 </template>
 
 <script setup>
 import { ref, defineProps, defineEmits } from 'vue';
-import { handleDrag } from '../drag';
+import { useDrag, customTypeList } from '../util/drag';
+import { useTaskClassify } from '../data/TaskType.js';
+import { useSubtask } from './data';
 
 import RichText from '@/components/common/RichText.vue';
 import SubtaskItem from './SubtaskItem.vue';
+import SelectTaskClassify from '../SelectTaskClassify.vue';
+import { Message } from 'element-ui';
 
 const props = defineProps({
   index: {
     type: Number,
     required: true
+  },
+  type: {
+    type: String,
+    required: true
   }
 });
 
 const emit = defineEmits(['deleteSubtask']);
 
+let data = ref('');
+
+const { taskClassify, getTaskClassifyAttr } = useTaskClassify();
+
+const { visible, selectTaskType } = useSubtask();
+
 function deleteSubtask() {
   emit('deleteSubtask', props.index);
 }
 
-const { dragover, drop } = handleDrag();
+const { onDragover, onDrop } = useDrag(true, customTypeList[0]);
 const isUnfold = ref(true); // 是否展开
 function handleStretch() {
   isUnfold.value = !isUnfold.value;
 }
 
-let subtaskTypeList = ref([]); // 子任务中添加的类型列表
+let subtaskTypeList = ref([props.type]); // 子任务中添加的类型列表
 function addSubtask(taskClassifyType) {
+  visible.value = false;
   if (!taskClassifyType) return;
+  if (taskClassify[0].type === taskClassifyType || taskClassify[4].type === taskClassifyType) {
+    Message({
+      type: 'warning',
+      message: `子任务中不能添加${getTaskClassifyAttr(taskClassifyType)}类型`
+    });
+    return;
+  }
   subtaskTypeList.value.push(taskClassifyType);
 }
 </script>
@@ -114,6 +137,7 @@ function addSubtask(taskClassifyType) {
       line-height: 284px;
       color: #999;
       text-align: center;
+      cursor: pointer;
       background-color: #f5f9ff;
       border: 1px dashed #2a76e8;
       border-radius: 4px;

+ 368 - 7
src/views/teacher/create_course/step_table/create_task/components/task_template/SubtaskItem.vue

@@ -1,29 +1,390 @@
 <template>
-  <div class="subtask-item">
-    <div class="subtask-item-content">
-      <template v-if="taskClassify[0].type === classify">sdfsf </template>
+  <div>
+    <div class="subtask-item">
+      <div class="subtask-item-title">
+        <span class="label-name">{{ getTaskClassifyAttr(subtaskType) }}</span>
+        <span class="subtask-delete">删除<svg-icon icon-class="delete-current" class-name="delete-current" /></span>
+      </div>
+      <div class="subtask-item-content">
+        <!-- 课件 -->
+        <template v-if="taskClassify[1].type === subtaskType">
+          <div class="courseware">
+            <el-button size="small" class="courseware-select">选择课件</el-button>
+            <span class="tip">仅支持选择已购买的教材</span>
+          </div>
+          <div class="courseware-list">
+            <div class="courseware-list-title">
+              <div class="file-name">文件名</div>
+              <div class="operation">操作</div>
+            </div>
+            <ul v-if="value1 !== ''">
+              <li class="courseware-list-item">
+                <span class="file-name">发了发动机考虑到非</span>
+                <span class="operation">删除</span>
+              </li>
+            </ul>
+            <div v-else class="courseware-list-none">点击上方“选择课件”</div>
+          </div>
+        </template>
+        <!-- 文件 -->
+        <template v-if="taskClassify[2].type === subtaskType">
+          <div class="file">
+            <el-button size="small" class="file-select">上传文件</el-button>
+            <span class="tip">支持批量上传,上传格式支持mp3; mp4; jpg; png文件</span>
+          </div>
+          <div class="file-list">
+            <div class="file-list-title">
+              <div class="file-name">文件名</div>
+              <div class="file-size">大小</div>
+              <div class="file-status">状态</div>
+              <div class="file-operation">操作</div>
+            </div>
+            <ul v-if="value1 !== ''">
+              <li class="file-list-item">
+                <span class="file-name">1</span>
+                <span class="file-size">2</span>
+                <span class="file-status">3</span>
+                <span class="file-operation">4</span>
+              </li>
+            </ul>
+            <div v-else class="file-list-none">点击上方“上传文件”或将文件拖拽到此区域</div>
+          </div>
+          <div class="file-upload">
+            <el-button size="small" class="cancel">取消</el-button>
+            <el-button size="small" class="start-upload">开始上传</el-button>
+          </div>
+        </template>
+        <!-- 录音/录影 -->
+        <template v-if="taskClassify[3].type === subtaskType">
+          <div v-show="curRecord === audioAndVideo[0].type">
+            <div class="sound-recording"></div>
+            <el-input type="textarea" class="sound-input" resize="none" placeholder="输入" />
+          </div>
+          <div v-show="curRecord === audioAndVideo[1].type" class="video-recording">
+            <video ref="video" width="100%" height="100%" autoplay></video>
+          </div>
+          <div class="recording-operation">
+            <div class="switch">
+              <span
+                v-for="{ type, name } in audioAndVideo"
+                :key="type"
+                :class="[type === curRecord ? 'active' : '']"
+                @click="selectRecord(type)"
+              >
+                {{ name }}
+              </span>
+            </div>
+            <template v-for="{ icon, buttonName, type } in audioAndVideo">
+              <div
+                v-if="curRecord === type"
+                :key="icon"
+                :class="['press-sound', icon]"
+                @mousedown="() => curRecord === audioAndVideo[0].type && startRecording()"
+                @click="() => curRecord === audioAndVideo[1].type && startVideo()"
+                @mouseup="() => curRecord === audioAndVideo[0].type && closeRecording()"
+              >
+                <svg-icon :icon-class="icon" class-name="button-icon" />{{ buttonName }}
+              </div>
+            </template>
+          </div>
+        </template>
+      </div>
     </div>
     <hr />
   </div>
 </template>
 
 <script setup>
-import { useTaskClassify } from '../taskType.js';
+import { ref, watch } from 'vue';
+import { useTaskClassify } from '../data/TaskType.js';
+import { useRecording, useRecordingPageData } from '../util/recording';
+import { fileUploadPrimordial } from '@/api/app.js';
 
 defineProps({
-  classify: {
+  subtaskType: {
     type: String,
     required: true
   }
 });
 
-const { taskClassify } = useTaskClassify();
+const { taskClassify, getTaskClassifyAttr } = useTaskClassify();
+
+let { audioAndVideo, curRecord, selectRecord } = useRecordingPageData();
+
+let value1 = ref('');
+
+let { startRecording, closeRecording, audioBlob } = useRecording('audio');
+
+watch(audioBlob, (newVal) => {
+  let formData = new FormData();
+  formData.append('录音1.mp3', newVal, '录像1.mp3');
+  fileUploadPrimordial('Mid', formData).then((data) => {
+    console.log(data);
+  });
+});
+
+let video = ref();
+let { startVideo, closeVideo, videoBlob } = useRecording('video', video);
+watch(videoBlob, (newVal) => {
+  let formData = new FormData();
+  formData.append('录像1.mp4', newVal, '录像1.mp4');
+  fileUploadPrimordial('Mid', formData).then((data) => {
+    console.log(data);
+  });
+});
 </script>
 
 <style lang="scss" scoped>
+$tip-color: #999;
+
 .subtask-item {
+  background-color: #fff;
+  border: 1px solid $border-color;
+  border-radius: 4px;
+
+  &-title {
+    display: flex;
+    justify-content: space-between;
+    padding: 8px;
+    border-bottom: 1px solid $border-color;
+
+    .label-name {
+      padding: 2px 12px;
+      color: #fff;
+      background-color: #44c1c1;
+      border-radius: 20px;
+    }
+
+    .subtask-delete {
+      color: #de4444;
+      cursor: pointer;
+
+      .delete-current {
+        margin-left: 6px;
+        vertical-align: -2px;
+      }
+    }
+  }
+
   &-content {
-    background-color: #fff;
+    display: flex;
+    flex-direction: column;
+    row-gap: 8px;
+    padding: 8px;
+
+    // 课件
+    %courseware,
+    .courseware {
+      display: flex;
+      column-gap: 16px;
+      align-items: center;
+
+      &-select {
+        width: 90px;
+        color: #191919;
+      }
+
+      .tip {
+        color: $tip-color;
+      }
+    }
+
+    .courseware-list {
+      color: $tip-color;
+      border: 1px solid $border-color;
+
+      &-title {
+        display: flex;
+        border-bottom: 1px solid $border-color;
+
+        .file-name {
+          flex: 1;
+          padding: 10px 24px;
+          border-right: 1px solid $border-color;
+        }
+
+        .operation {
+          padding: 10px 24px;
+          text-align: center;
+        }
+      }
+
+      &-item {
+        display: flex;
+        align-items: center;
+        word-break: break-all;
+
+        > span {
+          padding: 10px 24px;
+        }
+
+        .file-name {
+          flex: 1;
+        }
+      }
+
+      &-none {
+        height: 210px;
+        line-height: 210px;
+        color: $tip-color;
+        text-align: center;
+        vertical-align: middle;
+      }
+    }
+
+    // 文件
+    .file {
+      @extend %courseware;
+    }
+
+    .file-list {
+      border: 1px solid $border-color;
+
+      &-title {
+        display: flex;
+        color: $tip-color;
+        border-bottom: 1px solid $border-color;
+
+        > div {
+          padding: 10px 24px;
+          border-right: 1px solid $border-color;
+        }
+
+        .file-name {
+          flex: 1;
+        }
+
+        .file-status {
+          flex: 1;
+        }
+      }
+
+      &-item {
+        display: flex;
+        align-items: center;
+        word-break: break-all;
+
+        > span {
+          padding: 10px 24px;
+        }
+
+        .file-name {
+          width: 310px;
+        }
+
+        .file-size {
+          width: 78px;
+          text-align: center;
+        }
+
+        .file-status {
+          width: 310px;
+        }
+
+        .file-operation {
+          flex: 1;
+        }
+      }
+
+      &-none {
+        height: 210px;
+        line-height: 210px;
+        color: $tip-color;
+        text-align: center;
+      }
+    }
+
+    .file-upload {
+      display: flex;
+      justify-content: flex-end;
+      margin-top: 16px;
+
+      .cancel {
+        background-color: #e7e7e7;
+      }
+
+      .start-upload {
+        color: #fff;
+        background-color: #d4e3fc;
+      }
+    }
+
+    // 录音/录影
+    .sound-recording {
+      height: 230px;
+      background-color: #f4f4f4;
+      border: 1px solid $border-color;
+    }
+
+    .sound-input {
+      :deep .el-textarea__inner {
+        height: 92px;
+        padding: 8px 12px;
+        font-size: 16px;
+        border: 1px solid $border-color;
+        border-top-width: 0;
+      }
+    }
+
+    .video-recording {
+      height: 342px;
+      padding: 8px;
+      border: 1px solid $border-color;
+    }
+
+    .recording-operation {
+      display: flex;
+      justify-content: space-between;
+      padding: 8px 16px;
+      border: 1px solid $border-color;
+      border-top-width: 0;
+
+      .switch {
+        background-color: #e7e7e7;
+        border: 1px solid $border-color;
+        border-radius: 4px;
+
+        > span {
+          display: inline-block;
+          height: 28px;
+          padding: 2px 8px;
+          font-size: 16px;
+          line-height: 24px;
+          color: $tip-color;
+          cursor: pointer;
+          background-color: #e7e7e7;
+          border-radius: 3px;
+
+          &.active {
+            color: #000;
+            background-color: #fff;
+            box-shadow: 0 2px 4px rgba(0, 0, 0, 25%);
+          }
+        }
+      }
+
+      .press-sound {
+        padding: 4px 8px;
+        line-height: 22px;
+        cursor: pointer;
+        border-radius: 40px;
+
+        &.microphone {
+          color: #fff;
+          background-color: #de4444;
+        }
+
+        &.videocamera {
+          color: #de4444;
+          background-color: #fff;
+          border: 1px solid #de4444;
+        }
+
+        .button-icon {
+          margin-right: 4px;
+        }
+      }
+    }
   }
 }
 </style>

+ 363 - 0
src/views/teacher/create_course/step_table/create_task/components/task_template/TaskTemplate.vue

@@ -0,0 +1,363 @@
+<template>
+  <div class="task-template">
+    <div>
+      <span class="task-template-index">{{ index + 1 }}</span>
+      <span class="task-template-taskClassify-name">{{ getTaskClassifyAttr(teaching_type) }}</span>
+    </div>
+    <div class="task-template-content">
+      <div>任务标题</div>
+      <el-input v-model="curTaskObject.name" placeholder="点击输入" />
+      <div class="task-time">
+        <div class="task-time-item">
+          <span>开始时间</span>
+          <el-date-picker
+            v-model="curTaskObject.begin_time"
+            :style="{ width: '186px' }"
+            size="small"
+            type="datetime"
+            placeholder="选择日期时间"
+          />
+        </div>
+        <div class="task-time-item">
+          <span>持续时长</span>
+          <el-select v-model="curTaskObject.duration_second" size="small" :style="{ width: '90px' }">
+            <el-option v-for="{ name, value } in durationList" :key="value" :label="name" :value="value" />
+          </el-select>
+        </div>
+      </div>
+      <div>执教教师</div>
+      <el-select v-model="selectTeacher" multiple :multiple-limit="1">
+        <el-option
+          v-for="{ teacher_id, teacher_name } in teacherList"
+          :key="teacher_id"
+          :value="teacher_id"
+          :label="teacher_name"
+        />
+      </el-select>
+      <div>参加学生</div>
+      <el-select v-model="curTaskObject.custom_student_id_list" multiple>
+        <el-option
+          v-for="{ student_id, student_name } in studentList"
+          :key="student_id"
+          :value="student_id"
+          :label="student_name"
+        />
+      </el-select>
+      <div>任务说明</div>
+      <RichText v-model="curTaskObject.content" :height="209" :is-border="true" />
+      <hr />
+
+      <!-- 课件 直播 -->
+      <TemplateCourseware v-if="[taskClassify[1].type, taskClassify[4].type].includes(teaching_type)" />
+      <hr v-if="taskClassify[4].type === teaching_type" />
+      <!-- 文件 直播 -->
+      <TemplateFile
+        v-if="[taskClassify[2].type, taskClassify[4].type].includes(teaching_type)"
+        :list-name="listName"
+        :index="index"
+      />
+      <!-- 录音/录影 -->
+      <div v-if="taskClassify[3].type === teaching_type">
+        <div v-show="curRecord === audioAndVideo[0].type">
+          <div class="sound-recording"></div>
+          <el-input type="textarea" class="sound-input" resize="none" placeholder="输入" />
+        </div>
+        <div v-show="curRecord === audioAndVideo[1].type" class="video-recording">
+          <video ref="video" width="100%" height="100%" autoplay></video>
+        </div>
+        <div class="recording-operation">
+          <div class="switch">
+            <span
+              v-for="{ type, name } in audioAndVideo"
+              :key="type"
+              :class="[type === curRecord ? 'active' : '']"
+              @click="selectRecord(type)"
+            >
+              {{ name }}
+            </span>
+          </div>
+          <template v-for="{ icon, buttonName, type } in audioAndVideo">
+            <div
+              v-if="curRecord === type"
+              :key="icon"
+              :class="['press-sound', icon]"
+              @mousedown="() => curRecord === audioAndVideo[0].type && startRecording()"
+              @click="() => curRecord === audioAndVideo[1].type && startVideo()"
+              @mouseup="() => curRecord === audioAndVideo[0].type && closeRecording()"
+            >
+              <svg-icon :icon-class="icon" class-name="button-icon" />{{ buttonName }}
+            </div>
+          </template>
+        </div>
+      </div>
+
+      <hr v-if="taskClassify[0].type !== teaching_type" />
+
+      <SubTask v-for="({ type }, i) in subtaskList" :key="i" :type="type" :index="i" @deleteSubtask="deleteSubtask" />
+
+      <hr v-if="isHasSubtask" />
+
+      <el-button class="add-subtask" @click="selectTaskType">
+        <svg-icon icon-class="plus" class-name="plus" />添加子任务
+      </el-button>
+    </div>
+    <div v-if="isLastTask" class="add-task" @click="changeVisible(true)">
+      <svg-icon icon-class="plus" class-name="task-plus" />
+    </div>
+
+    <SelectTaskClassify :visible.sync="visible" :is-add-subtask="true" @select-task-classify="addSubtask" />
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'TaskTemplate'
+};
+</script>
+
+<script setup>
+import { ref, inject, watch, computed } from 'vue';
+import { useTaskClassify } from '../data/TaskType.js';
+import { useSubtask } from './data';
+import { useRecording, useRecordingPageData } from '../util/recording';
+import { fileUploadPrimordial } from '@/api/app.js';
+import { taskData } from '../data/TaskData';
+
+import TemplateFile from './components/TemplateFile.vue';
+import TemplateCourseware from './components/TemplateCourseware.vue';
+import RichText from '@/components/common/RichText.vue';
+import SelectTaskClassify from '../SelectTaskClassify.vue';
+import SubTask from './SubTask.vue';
+
+const props = defineProps({
+  listName: {
+    type: String,
+    required: true
+  },
+  index: {
+    type: Number,
+    required: true
+  }
+});
+
+const { changeVisible } = inject('visible');
+
+const { taskClassify, getTaskClassifyAttr } = useTaskClassify();
+
+let curTaskObject = computed(() => {
+  return taskData[props.listName][props.index];
+});
+
+let isLastTask = computed(() => {
+  return taskData[props.listName].length === props.index + 1;
+});
+
+let teaching_type = curTaskObject.value.teaching_type;
+
+// 子任务
+let { visible, selectTaskType, subtaskList, isHasSubtask, addSubtask, deleteSubtask } = useSubtask();
+
+// 基础
+let selectTeacher = ref([]);
+curTaskObject.value.teacher_id = computed(() => {
+  if (selectTeacher.value.length > 0) return selectTeacher.value[0];
+  return '';
+});
+const teacherList = inject('teacherList');
+const studentList = inject('studentList');
+const durationList = inject('durationList');
+
+// 录音/录影
+let { audioAndVideo, curRecord, selectRecord } = useRecordingPageData();
+let { startRecording, closeRecording, audioBlob } = useRecording('audio');
+
+watch(audioBlob, (newVal) => {
+  let formData = new FormData();
+  formData.append('录音1.mp3', newVal, '录像1.mp3');
+  fileUploadPrimordial('Mid', formData).then((data) => {
+    console.log(data);
+  });
+});
+
+let video = ref();
+let { startVideo, closeVideo, videoBlob } = useRecording('video', video);
+watch(videoBlob, (newVal) => {
+  let formData = new FormData();
+  formData.append('录像1.mp4', newVal, '录像1.mp4');
+  fileUploadPrimordial('Mid', formData).then((data) => {
+    console.log(data);
+  });
+});
+</script>
+
+<style lang="scss" scoped>
+$tip-color: #999;
+
+.task-template {
+  width: 828px;
+  margin: 0 auto;
+
+  & + .task-template {
+    margin-top: 40px;
+  }
+
+  &-index {
+    display: inline-block;
+    width: 26px;
+    height: 26px;
+    margin-right: 8px;
+    line-height: 26px;
+    color: #fff;
+    text-align: center;
+    background-color: #5498ff;
+    border-radius: 50%;
+  }
+
+  &-taskClassify-name {
+    display: inline-block;
+    padding: 2px 12px;
+    line-height: 22px;
+    color: #fff;
+    background-color: #666;
+    border-radius: 19px;
+  }
+
+  &-content {
+    display: flex;
+    flex-direction: column;
+    row-gap: 8px;
+    padding: 24px;
+    margin-top: 8px;
+    background-color: #fff;
+    border: 1px solid $border-color;
+    border-radius: 8px;
+    box-shadow: 0 2px 4px $border-color;
+
+    // 公用
+    .task-time {
+      display: flex;
+      column-gap: 8px;
+
+      &-item {
+        display: flex;
+        flex-direction: column;
+        row-gap: 8px;
+      }
+    }
+
+    :deep hr {
+      width: 100%;
+      margin: 23px 0;
+      border: 1px dashed $border-color;
+    }
+
+    .add-subtask {
+      width: 106px;
+      padding: 4px;
+      line-height: 22px;
+      color: #fff;
+      background-color: #67cfcf;
+
+      .plus {
+        width: 18px;
+        height: 18px;
+        margin-right: 4px;
+        vertical-align: sub;
+      }
+    }
+
+    // 录音/录影
+    .sound-recording {
+      height: 230px;
+      background-color: #f4f4f4;
+      border: 1px solid $border-color;
+    }
+
+    .sound-input {
+      :deep .el-textarea__inner {
+        height: 92px;
+        padding: 8px 12px;
+        font-size: 16px;
+        border: 1px solid $border-color;
+        border-top-width: 0;
+      }
+    }
+
+    .video-recording {
+      height: 342px;
+      padding: 8px;
+      border: 1px solid $border-color;
+    }
+
+    .recording-operation {
+      display: flex;
+      justify-content: space-between;
+      padding: 8px 16px;
+      border: 1px solid $border-color;
+      border-top-width: 0;
+
+      .switch {
+        background-color: #e7e7e7;
+        border: 1px solid $border-color;
+        border-radius: 4px;
+
+        > span {
+          display: inline-block;
+          height: 28px;
+          padding: 2px 8px;
+          font-size: 16px;
+          line-height: 24px;
+          color: $tip-color;
+          cursor: pointer;
+          background-color: #e7e7e7;
+          border-radius: 3px;
+
+          &.active {
+            color: #000;
+            background-color: #fff;
+            box-shadow: 0 2px 4px rgba(0, 0, 0, 25%);
+          }
+        }
+      }
+
+      .press-sound {
+        padding: 4px 8px;
+        line-height: 22px;
+        cursor: pointer;
+        border-radius: 40px;
+
+        &.microphone {
+          color: #fff;
+          background-color: #de4444;
+        }
+
+        &.videocamera {
+          color: #de4444;
+          background-color: #fff;
+          border: 1px solid #de4444;
+        }
+
+        .button-icon {
+          margin-right: 4px;
+        }
+      }
+    }
+  }
+
+  .add-task {
+    width: 32px;
+    height: 32px;
+    padding: 4px;
+    margin: 24px auto 0;
+    cursor: pointer;
+    background-color: #5498ff;
+    border-radius: 4px;
+
+    .task-plus {
+      width: 24px;
+      height: 24px;
+      text-align: center;
+    }
+  }
+}
+</style>

+ 111 - 0
src/views/teacher/create_course/step_table/create_task/components/task_template/components/TemplateCourseware.vue

@@ -0,0 +1,111 @@
+<template>
+  <div class="courseware">
+    <div class="courseware-select">
+      <el-button size="small" class="select-courseware" @click="changeVisibleSelectCourse(true, getCourseInfo)">
+        选择课件
+      </el-button>
+      <span class="tip">仅支持选择已购买的教材</span>
+    </div>
+    <div class="courseware-list">
+      <div class="courseware-list-title">
+        <div class="file-name">文件名</div>
+        <div class="operation">操作</div>
+      </div>
+      <ul v-if="courseList.length > 0">
+        <li v-for="({ courseware_id, name }, i) in courseList" :key="courseware_id" class="courseware-list-item">
+          <span class="file-name">{{ name }}</span>
+          <span class="operation" @click="deleteCourse(i)">删除</span>
+        </li>
+      </ul>
+      <div v-else class="courseware-list-none">点击上方“选择课件”</div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { inject, ref } from 'vue';
+
+let courseList = ref([]);
+
+const { changeVisibleSelectCourse } = inject('visibleSelectCourse');
+
+function getCourseInfo(data) {
+  courseList.value.push(data);
+}
+
+function deleteCourse(index) {
+  courseList.value.splice(index, 1);
+}
+</script>
+
+<style lang="scss" scoped>
+$tip-color: #999;
+
+.courseware {
+  &-select {
+    display: flex;
+    column-gap: 16px;
+    align-items: center;
+    margin-bottom: 8px;
+
+    .select-courseware {
+      width: 90px;
+      color: #191919;
+    }
+
+    .tip {
+      color: $tip-color;
+    }
+  }
+
+  &-list {
+    color: $tip-color;
+    border: 1px solid $border-color;
+
+    &-title {
+      display: flex;
+      border-bottom: 1px solid $border-color;
+
+      .file-name {
+        flex: 1;
+        padding: 10px 24px;
+        border-right: 1px solid $border-color;
+      }
+
+      .operation {
+        width: 80px;
+        padding: 10px 24px;
+        text-align: center;
+      }
+    }
+
+    &-item {
+      display: flex;
+      align-items: center;
+      word-break: break-all;
+
+      > span {
+        padding: 10px 24px;
+      }
+
+      .file-name {
+        flex: 1;
+      }
+
+      .operation {
+        width: 80px;
+        padding: 10px 24px;
+        text-align: center;
+      }
+    }
+
+    &-none {
+      height: 210px;
+      line-height: 210px;
+      color: $tip-color;
+      text-align: center;
+      vertical-align: middle;
+    }
+  }
+}
+</style>

+ 193 - 0
src/views/teacher/create_course/step_table/create_task/components/task_template/components/TemplateFile.vue

@@ -0,0 +1,193 @@
+<template>
+  <div>
+    <div class="file">
+      <el-button size="small" class="file-select" @click="uploadFile">上传文件</el-button>
+      <input ref="file" type="file" multiple :accept="accept" style="display: none" @change="handleFiles" />
+      <span class="tip">支持批量上传,上传格式支持mp3; mp4; jpg; png文件</span>
+    </div>
+    <div class="file-list">
+      <div class="file-list-title">
+        <div class="file-name">文件名</div>
+        <div class="file-size">大小</div>
+        <div class="file-status">状态</div>
+        <div class="file-operation">操作</div>
+      </div>
+      <ul v-if="files.length > 0">
+        <li
+          v-for="({ file: { name, size, lastModified }, status }, i) in files"
+          :key="`${lastModified}i`"
+          class="file-list-item"
+        >
+          <span class="file-name nowrap-ellipsis">{{ name }}</span>
+          <span class="file-size">{{ conversionSize(size) }}</span>
+          <span class="file-status"><svg-icon :icon-class="status.icon" />{{ status.name }}</span>
+          <span class="file-operation" @click="deleteFile(i)">删除</span>
+        </li>
+      </ul>
+      <div
+        v-else
+        :class="['file-list-none', { 'is-dragover': dragover }]"
+        @dragover="onDragover"
+        @dragleave="onDragleave"
+        @dragenter="onDragenter"
+        @drop="onDrop($event, dropFiles)"
+      >
+        点击上方“上传文件”或将文件拖拽到此区域
+      </div>
+    </div>
+    <div class="file-upload">
+      <el-button size="small" class="cancel" @click="breakUpload">取消</el-button>
+      <el-button size="small" :class="['start-upload', files.length > 0 ? 'active' : '']" @click="upload">
+        开始上传
+      </el-button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref, watch, reactive } from 'vue';
+import { conversionSize } from '@/utils/common';
+import { accept, fileStatusList, useUploadFile } from './file';
+import { useDrag } from '../../util/drag';
+import { taskData } from '../../data/TaskData';
+
+const props = defineProps({
+  listName: {
+    type: String,
+    required: true
+  },
+  index: {
+    type: Number,
+    required: true
+  }
+});
+
+let file = ref();
+let files = reactive(taskData[props.listName][props.index]['_files']);
+
+const { uploadFile, handleFiles, dropFiles, deleteFile, upload, breakUpload } = useUploadFile(file, files);
+
+let file_id_list = computed(() => {
+  return files.filter(({ status: { type } }) => type === fileStatusList[3].type).map(({ status: { id } }) => id);
+});
+
+watch(
+  () => file_id_list.value,
+  () => {
+    taskData[props.listName][props.index].file_id_list = file_id_list.value;
+    console.log(taskData[props.listName][props.index].file_id_list);
+  }
+);
+
+const { dragover, onDrop, onDragover, onDragenter, onDragleave } = useDrag();
+</script>
+
+<style lang="scss" scoped>
+$tip-color: #999;
+
+.file {
+  display: flex;
+  column-gap: 16px;
+  align-items: center;
+  margin-bottom: 8px;
+
+  &-select {
+    width: 90px;
+    color: #191919;
+  }
+
+  .tip {
+    color: $tip-color;
+  }
+}
+
+.file-list {
+  border: 1px solid $border-color;
+
+  &-title {
+    display: flex;
+    color: $tip-color;
+    border-bottom: 1px solid $border-color;
+
+    > div {
+      padding: 10px 24px;
+      border-right: 1px solid $border-color;
+    }
+
+    .file-name {
+      flex: 1;
+    }
+
+    .file-size {
+      width: 100px;
+      text-align: center;
+    }
+
+    .file-status {
+      flex: 1;
+    }
+  }
+
+  &-item {
+    display: flex;
+    align-items: center;
+    word-break: break-all;
+
+    > span {
+      padding: 10px 24px;
+    }
+
+    .file-name {
+      flex: 1;
+    }
+
+    .file-size {
+      width: 100px;
+      text-align: center;
+    }
+
+    .file-status {
+      display: flex;
+      flex: 1;
+      column-gap: 8px;
+      align-items: center;
+    }
+
+    .file-operation {
+      width: 78px;
+      cursor: pointer;
+    }
+  }
+
+  &-none {
+    height: 210px;
+    line-height: 210px;
+    color: $tip-color;
+    text-align: center;
+
+    &.is-dragover {
+      background-color: rgba(32, 159, 255, 6%);
+      border: 2px dashed #409eff;
+    }
+  }
+}
+
+.file-upload {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16px;
+
+  .cancel {
+    background-color: #e7e7e7;
+  }
+
+  .start-upload {
+    color: #fff;
+    background-color: #d4e3fc;
+
+    &.active {
+      background-color: #5498ff;
+    }
+  }
+}
+</style>

+ 131 - 0
src/views/teacher/create_course/step_table/create_task/components/task_template/components/file.js

@@ -0,0 +1,131 @@
+import { ref, watchEffect } from 'vue';
+import { Message } from 'element-ui';
+import { getFileType } from '@/utils/filter';
+import { fileUploadAbort } from '@/api/app';
+
+let allowFormat = ['mp3', 'mp4', 'png', 'jpg']; // 允许的文件格式
+export let accept = `.${allowFormat.join(',.')}`;
+
+// 文件状态列表
+export let fileStatusList = [
+  {
+    name: '文件格式有误',
+    type: 'error',
+    icon: 'error-circle-filled'
+  },
+  {
+    name: '待上传',
+    type: 'wait',
+    icon: 'time-filled'
+  },
+  {
+    name: '上传中',
+    type: 'uploading',
+    icon: 'loading-blue'
+  },
+  {
+    name: '上传成功',
+    type: 'success',
+    icon: 'check-circle-filled',
+    id: ''
+  }
+];
+
+// 是否为允许的文件格式
+export function allowFileFormat(suffix) {
+  return allowFormat.includes(suffix);
+}
+
+export function useUploadFile(file, files) {
+  // 打开选择文件
+  function uploadFile() {
+    file.value.value = null;
+    file.value.click();
+  }
+
+  function pushFiles(fileList) {
+    for (let i = 0; i < fileList.length; i++) {
+      files.push({
+        file: fileList[i],
+        status: allowFileFormat(getFileType(fileList[i].name)) ? fileStatusList[1] : fileStatusList[0]
+      });
+    }
+  }
+
+  function dropFiles(data) {
+    pushFiles(data.files);
+  }
+
+  function handleFiles({ target }) {
+    pushFiles(target.files);
+  }
+
+  // 删除文件
+  function deleteFile(i) {
+    files.splice(i, 1);
+  }
+
+  // 上传文件
+  let controllerFn = ref(null); // 中断上传函数
+  let isBreak = ref(false); // 是否中断
+  let fileNum = 0; // 当前上传的文件索引
+  let uploading = ref(false); // 是否上传中
+  async function* handleUpload() {
+    for (fileNum; fileNum < files.length; fileNum++) {
+      if (isBreak.value) return;
+      let { status, file } = files[fileNum];
+      if (status.type !== fileStatusList[1].type) continue;
+      let formData = new FormData();
+      formData.append(file.name, file, file.name);
+      const { request, progress, controller } = fileUploadAbort('Mid', formData);
+      const stop = watchEffect(() => {
+        files[fileNum].status = Object.assign({}, fileStatusList[2], { name: `上传${progress.value}%...` });
+      });
+      controllerFn.value = controller;
+      yield request;
+      stop();
+    }
+  }
+
+  async function upload() {
+    if (uploading.value) {
+      return Message.warning('上传文件中');
+    }
+    if (files.length <= 0) {
+      return Message.warning('请先选择文件');
+    }
+    if (!files.some(({ status: { type } }) => type === fileStatusList[1].type)) {
+      return Message.warning('没有需要上传的文件');
+    }
+    fileNum = 0;
+    isBreak.value = false;
+    uploading.value = true;
+    for await (let data of handleUpload()) {
+      if ('file_info_list' in data) {
+        files[fileNum].status = { ...fileStatusList[3], id: data.file_info_list?.[0].file_id };
+      }
+    }
+    fileNum = 0;
+    uploading.value = false;
+  }
+
+  // 中断上传
+  function breakUpload() {
+    if (!controllerFn.value) {
+      return Message.warning('没有上传中的文件');
+    }
+    isBreak.value = true;
+    controllerFn.value.abort();
+    uploading.value = false;
+    files[fileNum].status = fileStatusList[1];
+  }
+
+  return {
+    uploadFile,
+    handleFiles,
+    dropFiles,
+    deleteFile,
+    upload,
+    breakUpload
+  };
+}

+ 0 - 163
src/views/teacher/create_course/step_table/create_task/components/task_template/data.js

@@ -1,168 +1,5 @@
 // 任务模板数据处理
 import { ref, computed } from 'vue';
-import { GetTeacherListByCourseID, GetCourseStudentList } from '@/api/select.js';
-import { Message } from 'element-ui';
-
-/**
- * 录音/录影页面数据
- */
-export function useAudioVideo() {
-  const audioAndVideo = ref([
-    {
-      type: 'audio',
-      name: '录音',
-      icon: 'microphone',
-      buttonName: '按住录音'
-    },
-    {
-      type: 'video',
-      name: '录影',
-      icon: 'videocamera',
-      buttonName: '开始录制视频'
-    }
-  ]);
-  let curRecord = ref(audioAndVideo.value[0].type);
-
-  function selectRecord(type) {
-    curRecord.value = type;
-  }
-
-  return {
-    audioAndVideo,
-    curRecord,
-    selectRecord
-  };
-}
-
-// 录音/录影功能处理
-export function useRecording(type) {
-  // 获取设备权限
-  if (!navigator.mediaDevices.getUserMedia) alert('浏览器不支持 getUserMedia');
-  const devicePreset = {
-    audio: {
-      constraints: { audio: true },
-      options: {
-        // audioBitsPerSecond: 128000
-      }
-    },
-    video: {
-      constraints: { audio: true, video: true },
-      options: {
-        // audioBitsPerSecond: 128000,
-        // videoBitsPerSecond: 2500000
-      }
-    }
-  };
-
-  let mediaRecorder = ref(null);
-
-  // 检查授权情况
-  function checkAuthorization() {
-    if (mediaRecorder.value) return false;
-    return Message({
-      type: 'warning',
-      message: '请授权!'
-    });
-  }
-
-  // 录音
-  let chunks = ref([]);
-  let blob = ref(null);
-
-  navigator.mediaDevices.getUserMedia(devicePreset[type].constraints).then(
-    (stream) => {
-      mediaRecorder.value = new MediaRecorder(stream, devicePreset[type].options);
-      mediaRecorder.value.addEventListener('stop', () => {
-        console.log(chunks.value);
-        blob.value = new Blob(chunks.value, {
-          type: 'audio/mp3;codecs=opus'
-        });
-        console.log(blob.value);
-        chunks.value = [];
-        // let audioURL = window.URL.createObjectURL(blob);
-        // audioSrc.src = audioURL;
-      });
-      mediaRecorder.value.addEventListener('dataavailable', (e) => {
-        chunks.value.push(e.data);
-      });
-    },
-    () => {
-      Message({
-        type: 'warning',
-        message: '授权失败!'
-      });
-    }
-  );
-
-  // 开始录音
-  function startRecording() {
-    if (checkAuthorization()) return;
-    if (mediaRecorder.value.state === 'inactive') {
-      mediaRecorder.value.start();
-      console.log('开始录音');
-    } else {
-      Message({
-        type: 'warning',
-        message: '录音已开启'
-      });
-    }
-  }
-
-  // 结束录音
-  function closeRecording() {
-    if (checkAuthorization()) return;
-    if (mediaRecorder.value.state === 'recording') {
-      mediaRecorder.value.stop();
-    } else {
-      Message({
-        type: 'warning',
-        message: '未开启录音'
-      });
-    }
-  }
-
-  function startVideo() {
-    console.log(2);
-  }
-
-  return { startRecording, closeRecording, blob, startVideo };
-}
-
-/**
- * 处理表单数据
- */
-export function useFormData(id) {
-  let value1 = ref('');
-  let duration = ref(1); // 持续时长
-  let durationList = []; // 持续时长列表
-  for (let i = 1; i <= 12; i++) {
-    durationList.push({ name: `${i}小时`, value: i });
-  }
-
-  // 选择教师
-  let selectTeacher = ref([]);
-  let teacherList = ref([]);
-  GetTeacherListByCourseID({ course_id: id }).then(({ teacher_list }) => {
-    teacherList.value = teacher_list;
-  });
-
-  // 选择学生
-  let selectStudent = ref([]);
-  let studentList = ref([]);
-  GetCourseStudentList({ course_id: id, audit_status_list: [1], pay_status: 1 }).then(({ student_list }) => {
-    studentList.value = student_list;
-  });
-
-  return {
-    value1,
-    duration,
-    durationList,
-    selectStudent,
-    selectTeacher,
-    teacherList,
-    studentList
-  };
-}
 
 /**
  * 处理子任务

+ 17 - 546
src/views/teacher/create_course/step_table/create_task/components/task_template/index.vue

@@ -1,554 +1,25 @@
 <template>
-  <div class="task-template">
-    <div>
-      <span class="task-template-index">1</span>
-      <span class="task-template-taskClassify-name">{{ getTaskClassifyName(curTaskClassifyType) }}</span>
-    </div>
-    <div class="task-template-content">
-      <div>任务标题</div>
-      <el-input placeholder="点击输入" />
-      <div class="task-time">
-        <div class="task-time-item">
-          <span>开始时间</span>
-          <el-date-picker
-            v-model="value1"
-            :style="{ width: '186px' }"
-            size="small"
-            type="datetime"
-            placeholder="选择日期时间"
-          />
-        </div>
-        <div class="task-time-item">
-          <span>持续时长</span>
-          <el-select v-model="duration" size="small" :style="{ width: '90px' }">
-            <el-option v-for="{ name, value } in durationList" :key="value" :label="name" :value="value" />
-          </el-select>
-        </div>
-      </div>
-      <div>执教教师</div>
-      <el-select v-model="selectTeacher" multiple>
-        <el-option
-          v-for="{ teacher_id, teacher_name } in teacherList"
-          :key="teacher_id"
-          :value="teacher_id"
-          :label="teacher_name"
-        />
-      </el-select>
-      <div>参加学生</div>
-      <el-select v-model="selectStudent" multiple>
-        <el-option
-          v-for="{ student_id, student_name } in studentList"
-          :key="student_id"
-          :value="student_id"
-          :label="student_name"
-        />
-      </el-select>
-      <div>任务说明</div>
-      <RichText :height="209" :is-border="true" />
-      <hr />
-
-      <!-- 课件 -->
-      <template v-if="taskClassify[1].type === curTaskClassifyType">
-        <div class="courseware">
-          <el-button size="small" class="courseware-select">选择课件</el-button>
-          <span class="tip">仅支持选择已购买的教材</span>
-        </div>
-        <div class="courseware-list">
-          <div class="courseware-list-title">
-            <div class="file-name">文件名</div>
-            <div class="operation">操作</div>
-          </div>
-          <ul v-if="value1 !== ''">
-            <li class="courseware-list-item">
-              <span class="file-name">发了发动机考虑到非了富家大室李逵负荆as抵抗力 </span>
-              <span class="operation">删除</span>
-            </li>
-          </ul>
-          <div v-else class="courseware-list-none">点击上方“选择课件”</div>
-        </div>
-      </template>
-      <!-- 文件 -->
-      <template v-if="taskClassify[2].type === curTaskClassifyType">
-        <div class="file">
-          <el-button size="small" class="file-select">上传文件</el-button>
-          <span class="tip">支持批量上传,上传格式支持mp3; mp4; jpg; png文件</span>
-        </div>
-        <div class="file-list">
-          <div class="file-list-title">
-            <div class="file-name">文件名</div>
-            <div class="file-size">大小</div>
-            <div class="file-status">状态</div>
-            <div class="file-operation">操作</div>
-          </div>
-          <ul v-if="value1 !== ''">
-            <li class="file-list-item">
-              <span class="file-name">1</span>
-              <span class="file-size">2</span>
-              <span class="file-status">3</span>
-              <span class="file-operation">4</span>
-            </li>
-          </ul>
-          <div v-else class="file-list-none">点击上方“上传文件”或将文件拖拽到此区域</div>
-        </div>
-        <div class="file-upload">
-          <el-button size="small" class="cancel">取消</el-button>
-          <el-button size="small" class="start-upload">开始上传</el-button>
-        </div>
-      </template>
-      <!-- 录音/录影 -->
-      <div v-if="taskClassify[3].type === curTaskClassifyType">
-        <template v-if="curRecord === audioAndVideo[0].type">
-          <div class="sound-recording"></div>
-          <el-input type="textarea" class="sound-input" resize="none" placeholder="输入" />
-        </template>
-        <template v-else>
-          <div class="video-recording"></div>
-        </template>
-        <div class="recording-operation">
-          <div class="switch">
-            <span
-              v-for="{ type, name } in audioAndVideo"
-              :key="type"
-              :class="[type === curRecord ? 'active' : '']"
-              @click="selectRecord(type)"
-            >
-              {{ name }}
-            </span>
-          </div>
-          <template v-for="{ icon, buttonName, type } in audioAndVideo">
-            <div
-              v-if="curRecord === type"
-              :key="icon"
-              :class="['press-sound', icon]"
-              @mousedown="startRecording"
-              @mouseup="stopAudio"
-            >
-              <svg-icon :icon-class="icon" class-name="button-icon" />{{ buttonName }}
-            </div>
-          </template>
-        </div>
-      </div>
-      <!-- 直播 -->
-      <template v-if="taskClassify[4].type === curTaskClassifyType">
-        <div class="courseware">
-          <el-button size="small" class="courseware-select">选择课件</el-button>
-          <span class="tip">仅支持选择已购买的教材</span>
-        </div>
-        <div class="courseware-list">
-          <div class="courseware-list-title">
-            <div class="file-name">文件名</div>
-            <div class="operation">操作</div>
-          </div>
-          <ul v-if="value1 !== ''">
-            <li class="courseware-list-item">
-              <span class="file-name">发了发动机考虑到非了富家大室李逵负荆as抵抗力 </span>
-              <span class="operation">删除</span>
-            </li>
-          </ul>
-          <div v-else class="courseware-list-none">点击上方“选择课件”</div>
-        </div>
-        <hr />
-        <div class="file">
-          <el-button size="small" class="file-select">上传文件</el-button>
-          <span class="tip">支持批量上传,上传格式支持mp3; mp4; jpg; png文件</span>
-        </div>
-        <div class="file-list">
-          <div class="file-list-title">
-            <div class="file-name">文件名</div>
-            <div class="file-size">大小</div>
-            <div class="file-status">状态</div>
-            <div class="file-operation">操作</div>
-          </div>
-          <ul v-if="value1 !== ''">
-            <li class="file-list-item">
-              <span class="file-name">1</span>
-              <span class="file-size">2</span>
-              <span class="file-status">3</span>
-              <span class="file-operation">4</span>
-            </li>
-          </ul>
-          <div v-else class="file-list-none">点击上方“上传文件”或将文件拖拽到此区域</div>
-        </div>
-        <div class="file-upload">
-          <el-button size="small" class="cancel">取消</el-button>
-          <el-button size="small" class="start-upload">开始上传</el-button>
-        </div>
-      </template>
-
-      <hr v-if="curTaskClassifyType !== taskClassify[0].type" />
-
-      <SubTask v-for="(item, i) in subtaskList" :key="i" :index="i" @deleteSubtask="deleteSubtask" />
-
-      <hr v-if="isHasSubtask" />
-
-      <el-button class="add-subtask" @click="selectTaskType">
-        <svg-icon icon-class="plus" class-name="plus" />添加子任务
-      </el-button>
-    </div>
-    <div class="add-task"><svg-icon icon-class="plus" class-name="task-plus" /></div>
-
-    <SelectTaskClassify :visible.sync="visible" @select-task-classify="addSubtask" />
-    <!-- TODO 改为课节id -->
-    <SelectCourse :id="id" :dialog-visible="dialogVisible" />
+  <div>
+    <template v-if="curTaskTemplateList.length > 0">
+      <TaskTemplate
+        v-for="(item, i) in curTaskTemplateList"
+        :key="i"
+        :list-name="curTaskTypeObj.sidebarListName"
+        :index="i"
+      />
+    </template>
+    <template v-else>
+      <CreateTask />
+    </template>
   </div>
 </template>
 
-<script>
-export default {
-  name: 'TaskTemplate'
-};
-</script>
-
 <script setup>
-import { ref, inject, watch } from 'vue';
-import { useTaskClassify } from '../taskType.js';
-import { useAudioVideo, useFormData, useSubtask, useRecording } from './data';
-
-import RichText from '@/components/common/RichText.vue';
-import SelectTaskClassify from '../SelectTaskClassify.vue';
-import SelectCourse from '@/components/select/SelectCourse.vue';
-import SubTask from './SubTask.vue';
+import { inject } from 'vue';
 
-defineProps({
-  curTaskClassifyType: {
-    type: String,
-    required: true
-  }
-});
+import TaskTemplate from './TaskTemplate.vue';
+import CreateTask from './CreateTask.vue';
 
-const { taskClassify, getTaskClassifyName } = useTaskClassify();
-
-const id = inject('id');
-
-let dialogVisible = ref(false);
-
-let { visible, selectTaskType, subtaskList, isHasSubtask, addSubtask, deleteSubtask } = useSubtask();
-
-let { audioAndVideo, curRecord, selectRecord } = useAudioVideo();
-
-let { value1, duration, durationList, selectStudent, selectTeacher, teacherList, studentList } = useFormData(id);
-
-let { startRecording, closeRecording, blob } = useRecording('audio');
-
-function stopAudio() {
-  console.log('stop', closeRecording());
-}
-
-watch(blob, (newVal) => {
-  console.log(newVal);
-});
-// let { startVideo } = useRecording('video');
+const curTaskTemplateList = inject('curTaskTemplateList');
+const curTaskTypeObj = inject('curTaskTypeObj');
 </script>
-
-<style lang="scss" scoped>
-$tip-color: #999;
-
-.task-template {
-  width: 828px;
-  margin: 56px auto;
-
-  &-index {
-    display: inline-block;
-    width: 26px;
-    height: 26px;
-    margin-right: 8px;
-    line-height: 26px;
-    color: #fff;
-    text-align: center;
-    background-color: #5498ff;
-    border-radius: 50%;
-  }
-
-  &-taskClassify-name {
-    display: inline-block;
-    padding: 2px 12px;
-    line-height: 22px;
-    color: #fff;
-    background-color: #666;
-    border-radius: 19px;
-  }
-
-  &-content {
-    display: flex;
-    flex-direction: column;
-    row-gap: 8px;
-    padding: 24px;
-    margin-top: 8px;
-    background-color: #fff;
-    border: 1px solid $border-color;
-    border-radius: 8px;
-    box-shadow: 0 2px 4px $border-color;
-
-    // 公用
-    .task-time {
-      display: flex;
-      column-gap: 8px;
-
-      &-item {
-        display: flex;
-        flex-direction: column;
-        row-gap: 8px;
-      }
-    }
-
-    :deep hr {
-      width: 100%;
-      margin: 23px 0;
-      border: 1px dashed $border-color;
-    }
-
-    .add-subtask {
-      width: 106px;
-      padding: 4px;
-      line-height: 22px;
-      color: #fff;
-      background-color: #67cfcf;
-
-      .plus {
-        width: 18px;
-        height: 18px;
-        margin-right: 4px;
-        vertical-align: sub;
-      }
-    }
-
-    // 课件
-    %courseware,
-    .courseware {
-      display: flex;
-      column-gap: 16px;
-      align-items: center;
-
-      &-select {
-        width: 90px;
-        color: #191919;
-      }
-
-      .tip {
-        color: $tip-color;
-      }
-    }
-
-    .courseware-list {
-      color: $tip-color;
-      border: 1px solid $border-color;
-
-      &-title {
-        display: flex;
-        border-bottom: 1px solid $border-color;
-
-        .file-name {
-          flex: 1;
-          padding: 10px 24px;
-          border-right: 1px solid $border-color;
-        }
-
-        .operation {
-          padding: 10px 24px;
-          text-align: center;
-        }
-      }
-
-      &-item {
-        display: flex;
-        align-items: center;
-        word-break: break-all;
-
-        > span {
-          padding: 10px 24px;
-        }
-
-        .file-name {
-          flex: 1;
-        }
-      }
-
-      &-none {
-        height: 210px;
-        line-height: 210px;
-        color: $tip-color;
-        text-align: center;
-        vertical-align: middle;
-      }
-    }
-
-    // 文件
-    .file {
-      @extend %courseware;
-    }
-
-    .file-list {
-      border: 1px solid $border-color;
-
-      &-title {
-        display: flex;
-        color: $tip-color;
-        border-bottom: 1px solid $border-color;
-
-        > div {
-          padding: 10px 24px;
-          border-right: 1px solid $border-color;
-        }
-
-        .file-name {
-          width: 310px;
-        }
-
-        .file-size {
-          width: 78px;
-        }
-
-        .file-status {
-          width: 310px;
-        }
-
-        .file-operation {
-          flex: 1;
-        }
-      }
-
-      &-item {
-        display: flex;
-        align-items: center;
-        word-break: break-all;
-
-        > span {
-          padding: 10px 24px;
-        }
-
-        .file-name {
-          width: 310px;
-        }
-
-        .file-size {
-          width: 78px;
-          text-align: center;
-        }
-
-        .file-status {
-          width: 310px;
-        }
-
-        .file-operation {
-          flex: 1;
-        }
-      }
-
-      &-none {
-        height: 210px;
-        line-height: 210px;
-        color: $tip-color;
-        text-align: center;
-      }
-    }
-
-    .file-upload {
-      display: flex;
-      justify-content: flex-end;
-      margin-top: 16px;
-
-      .cancel {
-        background-color: #e7e7e7;
-      }
-
-      .start-upload {
-        color: #fff;
-        background-color: #d4e3fc;
-      }
-    }
-
-    // 录音/录影
-    .sound-recording {
-      height: 230px;
-      background-color: #f4f4f4;
-      border: 1px solid $border-color;
-    }
-
-    .sound-input {
-      :deep .el-textarea__inner {
-        height: 92px;
-        padding: 8px 12px;
-        font-size: 16px;
-        border: 1px solid $border-color;
-        border-top-width: 0;
-      }
-    }
-
-    .video-recording {
-      height: 342px;
-      padding: 8px;
-      border: 1px solid $border-color;
-    }
-
-    .recording-operation {
-      display: flex;
-      justify-content: space-between;
-      padding: 8px 16px;
-      border: 1px solid $border-color;
-      border-top-width: 0;
-
-      .switch {
-        background-color: #e7e7e7;
-        border: 1px solid $border-color;
-        border-radius: 4px;
-
-        > span {
-          display: inline-block;
-          height: 28px;
-          padding: 2px 8px;
-          font-size: 16px;
-          line-height: 24px;
-          color: $tip-color;
-          cursor: pointer;
-          background-color: #e7e7e7;
-          border-radius: 3px;
-
-          &.active {
-            color: #000;
-            background-color: #fff;
-            box-shadow: 0 2px 4px rgba(0, 0, 0, 25%);
-          }
-        }
-      }
-
-      .press-sound {
-        padding: 4px 8px;
-        line-height: 22px;
-        cursor: pointer;
-        border-radius: 40px;
-
-        &.microphone {
-          color: #fff;
-          background-color: #de4444;
-        }
-
-        &.videocamera {
-          color: #de4444;
-          background-color: #fff;
-          border: 1px solid #de4444;
-        }
-
-        .button-icon {
-          margin-right: 4px;
-        }
-      }
-    }
-  }
-
-  .add-task {
-    width: 32px;
-    height: 32px;
-    padding: 4px;
-    margin: 24px auto 0;
-    cursor: pointer;
-    background-color: #5498ff;
-    border-radius: 4px;
-
-    .task-plus {
-      width: 24px;
-      height: 24px;
-      text-align: center;
-    }
-  }
-}
-</style>

+ 56 - 0
src/views/teacher/create_course/step_table/create_task/components/util/drag.js

@@ -0,0 +1,56 @@
+import { ref } from 'vue';
+
+export let customTypeList = ['taskClassifyType']; // 自定义状态列表
+
+/**
+ * 处理拖拽事件
+ * @params {Boolean} isCustomData 是否自定义数据
+ * @params {String} customType 自定义数据类型名称
+ */
+export function useDrag(isCustomData = false, customType = '') {
+  function onDragstart(e, type = '') {
+    if (isCustomData) {
+      e.dataTransfer.setData(customType, type); // 设置拖拽数据
+    }
+    e.target.style.opacity = 0.5; // 使其半透明
+  }
+
+  function onDragenter(e) {
+    e.preventDefault();
+  }
+
+  let dragover = ref(false);
+  function onDragover(e) {
+    e.preventDefault();
+    dragover.value = true;
+  }
+
+  function onDragleave(e) {
+    e.preventDefault();
+    dragover.value = false;
+  }
+
+  function onDragend(e) {
+    e.target.style.opacity = ''; // 恢复透明度
+  }
+
+  function onDrop(e, fn) {
+    e.preventDefault();
+    dragover.value = false;
+    if (isCustomData) {
+      fn(e.dataTransfer.getData(customType));
+    } else {
+      fn(e.dataTransfer);
+    }
+  }
+
+  return {
+    dragover,
+    onDragstart,
+    onDragenter,
+    onDragover,
+    onDragleave,
+    onDragend,
+    onDrop
+  };
+}

+ 15 - 6
src/views/teacher/create_course/step_table/create_task/components/mouseEvent.js → src/views/teacher/create_course/step_table/create_task/components/util/mouseEvent.js

@@ -5,10 +5,13 @@ import { onMounted, ref } from 'vue';
  * 处理鼠标点击事件
  * @param {Element} center
  */
-export function mouseEvent(center) {
+export function useMouseEvent(center) {
   const isMouseLeftPress = ref(false); // 鼠标左键是否按下
   const mouseOnMain = ref(false); // 鼠标是否在主要位置
-  const mainCursorDisplayStyle = ref('default'); // 鼠标指针样式
+  const centerStyle = ref({
+    cursor: 'default',
+    'user-select': 'auto'
+  }); // 鼠标指针样式
 
   /**
    * 按下空格时与鼠标左键时,移动鼠标移动主内容位置
@@ -45,8 +48,11 @@ export function mouseEvent(center) {
      */
     document.addEventListener('keydown', (e) => {
       if (e.key === ' ') e.preventDefault();
-      if (e.key === ' ' && mouseOnMain && mainCursorDisplayStyle.value !== 'grab') {
-        mainCursorDisplayStyle.value = 'grab';
+      if (e.key === ' ' && mouseOnMain && centerStyle.value.cursor !== 'grab') {
+        centerStyle.value = {
+          cursor: 'grab',
+          'user-select': 'none'
+        };
         center.value.addEventListener('mousemove', moveMainPosition);
       }
     });
@@ -57,7 +63,10 @@ export function mouseEvent(center) {
     document.addEventListener('keyup', (e) => {
       if (e.key === ' ') {
         e.preventDefault();
-        mainCursorDisplayStyle.value = 'default';
+        centerStyle.value = {
+          cursor: 'default',
+          'user-select': 'auto'
+        };
         center.value.removeEventListener('mousemove', moveMainPosition);
       }
     });
@@ -71,5 +80,5 @@ export function mouseEvent(center) {
     });
   });
 
-  return { mainCursorDisplayStyle, center };
+  return { centerStyle, center };
 }

+ 142 - 0
src/views/teacher/create_course/step_table/create_task/components/util/recording.js

@@ -0,0 +1,142 @@
+// 录制功能处理
+import { ref } from 'vue';
+import { Message } from 'element-ui';
+
+export function useRecording(type, el) {
+  // 获取设备权限
+  if (!navigator.mediaDevices.getUserMedia) alert('浏览器不支持 getUserMedia');
+  // 音视频预设
+  const devicePreset = {
+    audio: {
+      constraints: { audio: true },
+      options: {
+        // audioBitsPerSecond: 128000
+      },
+      blobOptions: { type: 'audio/mp3;codecs=opus' }
+    },
+    video: {
+      constraints: { audio: true, video: true },
+      options: {
+        // audioBitsPerSecond: 128000,
+        // videoBitsPerSecond: 2500000
+      },
+      blobOptions: { type: 'video/mp4;codecs=opus' }
+    }
+  };
+
+  // MediaRecorder 进行录制的接口
+  let mediaRecorder = ref(null);
+
+  let chunks = ref([]);
+  let videoBlob = ref(null);
+  let audioBlob = ref(null);
+  // 初始化
+  function init() {
+    return navigator.mediaDevices.getUserMedia(devicePreset[type].constraints).then(
+      (stream) => {
+        if (type === 'video') {
+          el.value.srcObject = stream;
+        }
+        mediaRecorder.value = new MediaRecorder(stream, devicePreset[type].options);
+        mediaRecorder.value.addEventListener('stop', () => {
+          let blob = type === 'video' ? videoBlob : audioBlob;
+          blob.value = new Blob(chunks.value, devicePreset[type].blobOptions);
+          chunks.value = [];
+          // let audioURL = window.URL.createObjectURL(blob);
+          // audioSrc.src = audioURL;
+        });
+        mediaRecorder.value.addEventListener('dataavailable', (e) => {
+          chunks.value.push(e.data);
+        });
+      },
+      () => {
+        Message({
+          type: 'warning',
+          message: '请授权音视频权限!'
+        });
+      }
+    );
+  }
+
+  // 开始录音
+  function startRecording() {
+    init().then(() => {
+      if (mediaRecorder.value.state === 'inactive') {
+        mediaRecorder.value.start();
+        console.log('开始录音');
+      } else {
+        Message({
+          type: 'warning',
+          message: '录音已开启'
+        });
+      }
+    });
+  }
+
+  // 结束录音
+  function closeRecording() {
+    if (mediaRecorder.value.state === 'recording') {
+      mediaRecorder.value.stop();
+    } else {
+      Message({
+        type: 'warning',
+        message: '未开启录音'
+      });
+    }
+  }
+
+  // 录像
+  function startVideo() {
+    init().then(() => {
+      if (mediaRecorder.value.state === 'inactive') {
+        mediaRecorder.value.start();
+        console.log('开始录像');
+      }
+    });
+  }
+
+  function closeVideo() {
+    if (mediaRecorder.value.state === 'recording') {
+      mediaRecorder.value.stop();
+      el.value.srcObject = null;
+    } else {
+      Message({
+        type: 'warning',
+        message: '未开启录像'
+      });
+    }
+  }
+
+  return { startRecording, closeRecording, audioBlob, startVideo, closeVideo, videoBlob };
+}
+
+/**
+ * 录音/录影页面数据
+ */
+export function useRecordingPageData() {
+  const audioAndVideo = ref([
+    {
+      type: 'audio',
+      name: '录音',
+      icon: 'microphone',
+      buttonName: '按住录音'
+    },
+    {
+      type: 'video',
+      name: '录影',
+      icon: 'videocamera',
+      buttonName: '开始录制视频'
+    }
+  ]);
+  let curRecord = ref(audioAndVideo.value[0].type);
+
+  function selectRecord(type) {
+    curRecord.value = type;
+  }
+
+  return {
+    audioAndVideo,
+    curRecord,
+    selectRecord
+  };
+}

+ 57 - 0
src/views/teacher/create_course/step_table/create_task/components/util/wheel.js

@@ -0,0 +1,57 @@
+import { ref, onMounted, onBeforeUnmount } from 'vue';
+
+/**
+ * 缩放比例控制
+ */
+export const scale = ref(1); // 缩放比例
+export function useScale() {
+  // 重置缩放
+  function resetScale() {
+    scale.value = 1;
+  }
+
+  function changeScale(isAdd) {
+    scale.value = isAdd ? Math.min(2, scale.value + 0.1) : Math.max(0.5, scale.value - 0.1);
+  }
+
+  return {
+    resetScale,
+    changeScale
+  };
+}
+
+/**
+ * ctrl + 滚轮 页面缩放
+ * @param {Element} center
+ */
+export function useWheel(center) {
+  const { changeScale } = useScale();
+
+  // 处理 ctrl + 滚轮事件
+  function handleCtrlAndWheel(e) {
+    if (e.ctrlKey) {
+      e.preventDefault();
+      changeScale(e.deltaY < 0);
+    }
+  }
+
+  function handleWheelEvent(isAdd) {
+    center.value?.[isAdd ? 'addEventListener' : 'removeEventListener']('wheel', handleCtrlAndWheel);
+  }
+
+  function handleWindowWheel(e) {
+    if (e.ctrlKey) e.preventDefault();
+  }
+
+  onMounted(() => {
+    handleWheelEvent(true);
+    window.addEventListener('wheel', handleWindowWheel, { passive: false });
+  });
+
+  onBeforeUnmount(() => {
+    handleWheelEvent(false);
+    window.removeEventListener('wheel', handleWindowWheel);
+  });
+
+  return { handleWheelEvent };
+}

+ 0 - 28
src/views/teacher/create_course/step_table/create_task/components/wheel.js

@@ -1,28 +0,0 @@
-import { ref, onMounted, onUnmounted } from 'vue';
-
-/**
- * 处理滚轮事件
- * @param {Element} center
- */
-export function handleWheel(center) {
-  const scale = ref(1); // 缩放比例
-
-  // 处理 ctrl + 滚轮事件
-  function handleCtrlAndWheel(e) {
-    if (e.ctrlKey) {
-      e.preventDefault();
-      scale.value = e.deltaY > 0 ? Math.max(0.5, scale.value - 0.1) : Math.min(2, scale.value + 0.1);
-    }
-  }
-
-  onMounted(() => {
-    // ctrl + 滚轮事件
-    center.value.addEventListener('wheel', handleCtrlAndWheel);
-  });
-
-  onUnmounted(() => {
-    center.value.removeEventListener('wheel', handleCtrlAndWheel);
-  });
-
-  return { scale };
-}

+ 104 - 0
src/views/teacher/create_course/step_table/create_task/index.js

@@ -0,0 +1,104 @@
+import { ref, provide, computed, watch } from 'vue';
+import { GetCSItemListByCourseID, AddCSItemToCourse, GetCSItemTaskList } from '@/api/course';
+import { useRoute } from 'vue-router/composables';
+import { GetTeacherListByCourseID, GetCourseStudentList } from '@/api/select.js';
+
+export function useCSItem(content) {
+  const route = useRoute();
+  const id = route.params.id; // 课程 id
+  provide('id', id);
+
+  const query = route.query;
+  const is_template = 'is_template' in query ? query.is_template === 'true' : false;
+  const closeLink = JSON.parse(is_template) ? '/main?tab=TemplateList' : '/create_course';
+
+  let isEditorClassName = ref(false); // 是否编辑课节标题中
+
+  let csItemList = ref(null); // 课节列表
+  let curCSItemIndex = ref(0); // 当前课节索引
+  GetCSItemListByCourseID({ course_id: id }).then(({ cs_item_list }) => {
+    if (cs_item_list.length > 0) return (csItemList.value = cs_item_list);
+    AddCSItemToCourse({ course_id: id, name: '课节 1' }).then(({ id }) => {
+      console.log(id);
+    });
+  });
+
+  // 当前课节 id
+  let cs_item_id = computed(() => {
+    return csItemList?.value?.[curCSItemIndex.value].id;
+  });
+  provide('cs_item_id', cs_item_id);
+  watch(
+    () => cs_item_id.value,
+    (newVal) => {
+      if (!newVal) return;
+      GetCSItemTaskList({ cs_item_id: newVal }).then((data) => {
+        console.log(data);
+      });
+    }
+  );
+
+  // 编辑课节名称
+  function editorClassName(isEditor) {
+    isEditorClassName.value = isEditor;
+    if (isEditor) {
+      content.value.focus(); // 聚焦到标题
+      const selection = window.getSelection(); // 获取 range
+      selection.selectAllChildren(content.value); // 选择 content 下所有子内容
+      selection.collapseToEnd(); // 光标移到最后
+    } else {
+      csItemList.value[curCSItemIndex.value].name = content.value.textContent;
+      content.value.blur();
+    }
+  }
+
+  let isCreateClassSection = ref(false); // 是否创建课节中
+  function handleClickClass(index) {
+    curCSItemIndex.value = index;
+    isCreateClassSection.value = false;
+  }
+
+  function addClassSection() {
+    isCreateClassSection.value = true;
+  }
+
+  return {
+    closeLink,
+    isCreateClassSection,
+    isEditorClassName,
+    csItemList,
+    curCSItemIndex,
+    id,
+    cs_item_id,
+    editorClassName,
+    handleClickClass,
+    addClassSection
+  };
+}
+
+/**
+ * 初始化必要的数据
+ */
+export function initData() {
+  const route = useRoute();
+  const id = route.params.id; // 课程 id
+  // 教师列表
+  let teacherList = ref([]);
+  GetTeacherListByCourseID({ course_id: id }).then(({ teacher_list }) => {
+    teacherList.value = teacher_list;
+  });
+  provide('teacherList', teacherList);
+
+  // 学生列表
+  let studentList = ref([]);
+  GetCourseStudentList({ course_id: id, audit_status_list: [1], pay_status: 1 }).then(({ student_list }) => {
+    studentList.value = student_list;
+  });
+  provide('studentList', studentList);
+
+  let durationList = []; // 持续时长列表
+  for (let i = 1; i <= 12; i++) {
+    durationList.push({ name: `${i}小时`, value: i * 60 * 60 });
+  }
+  provide('durationList', durationList);
+}

+ 49 - 43
src/views/teacher/create_course/step_table/create_task/index.vue

@@ -5,10 +5,16 @@
       <div class="back" @click="$router.push(closeLink)">
         <svg-icon icon-class="back-black" class-name="back-black" />返回
       </div>
-      <div class="class-item" @click="handleClickClass">
-        <svg-icon icon-class="class" class-name="class" /><span class="class-item-name nowrap-ellipsis">{{
-          name
-        }}</span>
+      <div
+        v-for="({ name, id }, i) in csItemList"
+        :key="id"
+        :class="['class-item', i === curCSItemIndex ? 'active' : '']"
+        @click="handleClickClass(i)"
+      >
+        <svg-icon :icon-class="i === curCSItemIndex ? 'class' : 'class-grey'" class-name="class" />
+        <span class="class-item-name nowrap-ellipsis">
+          {{ name }}
+        </span>
       </div>
       <div :class="['class-add', isCreateClassSection ? 'active' : '']" @click="addClassSection">
         <svg-icon icon-class="add" />
@@ -24,13 +30,15 @@
           :contenteditable="isEditorClassName"
           @keydown.enter="editorClassName(false)"
         >
-          课节1
+          {{ csItemList ? csItemList[curCSItemIndex].name : '' }}
         </div>
-        <svg-icon icon-class="write" @click="editorClassName(true)" />
+        <svg-icon icon-class="write" class-name="write" @click="editorClassName(true)" />
       </div>
       <div class="class-title-operation">
         <div class="preview"><svg-icon icon-class="preview" /><span class="button-name">预览</span></div>
-        <div class="preserve"><svg-icon icon-class="preserve" /><span class="button-name">保存</span></div>
+        <div class="preserve" @click="saveCSItem(cs_item_id)">
+          <svg-icon icon-class="preserve" /><span class="button-name">保存</span>
+        </div>
       </div>
     </div>
     <!-- 创建课节 -->
@@ -41,45 +49,27 @@
 </template>
 
 <script setup>
-import { ref, provide } from 'vue';
-import { useRoute } from 'vue-router/composables';
+import { ref } from 'vue';
+import { useCSItem, initData } from './index';
+import { saveCSItem } from './components/data/TaskData';
+
 import TaskEditor from './components/TaskEditor.vue';
 import CreateClassSection from './components/createClassSection.vue';
 
-let route = useRoute();
-const id = route.params.id; // 课程 id
-
-provide('id', id);
-
 const content = ref(); // refs
-let query = route.query;
-const is_template = 'is_template' in query ? query.is_template === 'true' : false;
-const closeLink = JSON.parse(is_template) ? '/main?tab=TemplateList' : '/create_course';
-
-let name = ref('课节 1');
-let isEditorClassName = ref(false); // 是否编辑课节标题中
-
-function editorClassName(isEditor) {
-  isEditorClassName.value = isEditor;
-  if (isEditor) {
-    content.value.focus(); // 聚焦到标题
-    const selection = window.getSelection(); // 获取 range
-    selection.selectAllChildren(content); // 选择 content 下所有子内容
-    selection.collapseToEnd(); // 光标移到最后
-  } else {
-    content.value.blur();
-  }
-}
-
-// 是否创建课节中
-let isCreateClassSection = ref(false);
-function handleClickClass() {
-  isCreateClassSection.value = false;
-}
-
-function addClassSection() {
-  isCreateClassSection.value = true;
-}
+const {
+  closeLink,
+  isEditorClassName,
+  isCreateClassSection,
+  csItemList,
+  curCSItemIndex,
+  cs_item_id,
+  editorClassName,
+  handleClickClass,
+  addClassSection
+} = useCSItem(content);
+
+initData();
 </script>
 
 <style lang="scss" scoped>
@@ -111,12 +101,24 @@ $basic-background-color: #f7f7f7;
       align-items: center;
       padding: 12px;
       line-height: 24px;
+      color: #b2b2b2;
       cursor: pointer;
-      background-color: $basic-background-color;
+      background-color: #fff;
       border-right: 1px solid $border-color;
+      border-bottom: 1px solid $border-color;
       border-left: 1px solid $border-color;
       box-shadow: 0 1px $basic-background-color;
 
+      & + .class-item {
+        border-left-width: 0;
+      }
+
+      &.active {
+        color: #000;
+        background-color: $basic-background-color;
+        border-bottom-width: 0;
+      }
+
       .svg-icon.class {
         width: 20px;
         height: 20px;
@@ -161,6 +163,10 @@ $basic-background-color: #f7f7f7;
           background-color: #628fd3;
         }
       }
+
+      .write {
+        cursor: pointer;
+      }
     }
 
     &-operation {