Переглянути джерело

视频组件完成,题干组件初建

zq 1 рік тому
батько
коміт
418192b75e

+ 3 - 0
src/views/book/courseware/create/components/base/common/UploadFile.vue

@@ -197,6 +197,9 @@ export default {
       } else if (this.type === 'picture') {
         fileType = ['jpg', 'png', 'jpeg'];
         typeTip = '图片文件只能是 jpg、png、jpeg 格式!';
+      } else if (this.type === 'video') {
+        fileType = ['mp4'];
+        typeTip = '视频文件只能是 mp4 格式!';
       }
       const isNeedType = fileType.includes(suffix);
       if (!isNeedType) {

+ 22 - 0
src/views/book/courseware/create/components/base/describe/Describe.vue

@@ -0,0 +1,22 @@
+<template>
+  <ModuleBase :type="标签">
+    <template #content>
+      <div>农行</div>
+    </template>
+  </ModuleBase>
+</template>
+<script>
+import { getDescribeData } from '@/views/book/courseware/data/describe';
+import ModuleMixin from '../../common/ModuleMixin';
+export default {
+  name: 'DescribePage',
+  mixins: [ModuleMixin],
+  data() {
+    return {
+      data: getDescribeData(),
+    };
+  },
+};
+</script>
+
+<style lang="scss" scoped></style>

+ 29 - 0
src/views/book/courseware/create/components/base/describe/DescribeSetting.vue

@@ -0,0 +1,29 @@
+<template>
+  <div>
+    <el-form :model="property" :label-position="labelPosition" label-width="56px">
+      <el-form-item label="高度">
+        <el-input v-model="property.height" />
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import SettingMixin from '@/views/book/courseware/create/components/common/SettingMixin';
+
+export default {
+  name: 'DescribeSetting',
+  mixins: [SettingMixin],
+  data() {
+    return {
+      labelPosition: 'left',
+      property: {
+        height: 40,
+      },
+    };
+  },
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped></style>

+ 5 - 1
src/views/book/courseware/create/components/base/label/Label.vue

@@ -6,13 +6,17 @@
   </ModuleBase>
 </template>
 <script>
+import { getLabelData } from '@/views/book/courseware/data/video';
+import ModuleMixin from '../../common/ModuleMixin';
 export default {
   name: 'LabelPage',
+  mixins: [ModuleMixin],
   data() {
     return {
+      data: getLabelData(),
     };
   },
 };
 </script>
 
-<style lang="scss" scoped></style>
+<style lang="scss" scoped></style>

+ 29 - 0
src/views/book/courseware/create/components/base/label/LabelSetting.vue

@@ -0,0 +1,29 @@
+<template>
+  <div>
+    <el-form :model="property" :label-position="labelPosition" label-width="56px">
+      <el-form-item label="高度">
+        <el-input v-model="property.height" />
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import SettingMixin from '@/views/book/courseware/create/components/common/SettingMixin';
+
+export default {
+  name: 'LabelSetting',
+  mixins: [SettingMixin],
+  data() {
+    return {
+      labelPosition: 'left',
+      property: {
+        height: 40,
+      },
+    };
+  },
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped></style>

+ 27 - 0
src/views/book/courseware/create/components/base/stem/Stem.vue

@@ -0,0 +1,27 @@
+<template>
+  <ModuleBase :type="data.type">
+    <template #content>
+      <RichText v-model="data.stem" :font-size="18" placeholder="输入题干" />
+    </template>
+  </ModuleBase>
+</template>
+
+<script>
+import { getStemData } from '@/views/book/courseware/data/stem';
+import ModuleMixin from '../../common/ModuleMixin';
+import RichText from '@/components/RichText.vue';
+
+export default {
+  name: 'StemPage',
+  components: { RichText },
+  mixins: [ModuleMixin],
+  data() {
+    return {
+      data: getStemData(),
+    };
+  },
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped></style>

+ 67 - 0
src/views/book/courseware/create/components/base/stem/StemSetting.vue

@@ -0,0 +1,67 @@
+<template>
+  <div>
+    <el-form :model="property" :label-position="labelPosition" label-width="56px">
+      <el-form-item label="序号" class="serial-number">
+        <el-input v-model="property.serial_number" />
+        <SvgIcon icon-class="switch" size="14" @click="switchSerialNumber(property)" />
+      </el-form-item>
+      <el-form-item>
+        <el-radio
+          v-for="{ value, label } in snGenerationMethodList"
+          :key="value"
+          v-model="property.sn_generation_method"
+          :label="value"
+        >
+          {{ label }}
+        </el-radio>
+      </el-form-item>
+      <el-form-item label="序号位置">
+        <div class="grid-container">
+          <el-button
+            v-for="{ value } in serialNumberPositionList"
+            :key="value"
+            :class="[value, { active: value === property.sn_position }]"
+            :style="{ gridArea: value }"
+            @click="changeNumberPosition(value)"
+          />
+          <div class="main"></div>
+        </div>
+      </el-form-item>
+      <el-divider />
+    </el-form>
+  </div>
+</template>
+
+<script>
+import SettingMixin from '@/views/book/courseware/create/components/common/SettingMixin';
+import {
+  snGenerationMethodList,
+  switchSerialNumber,
+  checkString,
+  serialNumberPositionList,
+} from '@/views/book/courseware/data/common';
+
+export default {
+  name: 'StemSetting',
+  mixins: [SettingMixin],
+  data() {
+    return {
+      switchSerialNumber,
+      checkString,
+      serialNumberPositionList,
+      snGenerationMethodList,
+      viewMethodList,
+      labelPosition: 'left',
+      property: {
+        serial_number: 1, // 序号
+        sn_type: 'number',
+        sn_position: 'top-start', // 序号位置:top-start top top-end 等
+        sn_generation_method: snGenerationMethodList[0].value, // 序号生成方式:recalculate 重新计算follow 跟随
+      },
+    };
+  },
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped></style>

+ 51 - 0
src/views/book/courseware/create/components/base/video/Video.vue

@@ -0,0 +1,51 @@
+<template>
+  <ModuleBase :type="data.type">
+    <template #content>
+      <UploadFile
+        :courseware-id="courseware_id"
+        :component-id="id"
+        :type="data.type"
+        :single-size="data.single_size"
+        :total-size="data.total_size"
+        :file-list="data.file_list"
+        :file-id-list="data.file_id_list"
+        :file-info-list="data.file_info_list"
+        :label-text="labelText"
+        :accept-file-type="acceptFileType"
+        :upload-tip="uploadTip"
+        :icon-class="iconClass"
+        @updateFileList="updateFileList"
+      />
+    </template>
+  </ModuleBase>
+</template>
+
+<script>
+import { getVideoData } from '@/views/book/courseware/data/video';
+import ModuleMixin from '../../common/ModuleMixin';
+import UploadFile from '../common/UploadFile.vue';
+
+export default {
+  name: 'VideoPage',
+  components: { UploadFile },
+  mixins: [ModuleMixin],
+  data() {
+    return {
+      data: getVideoData(),
+      labelText: '视频',
+      acceptFileType: '.mp4',
+      uploadTip: '支持上传mp4格式视频文件,单个视频文件最大2GB,总文件体积不超10GB。',
+      iconClass: 'video',
+    };
+  },
+  methods: {
+    updateFileList({ file_list, file_id_list, file_info_list }) {
+      this.data.file_list = file_list;
+      this.data.file_id_list = file_id_list;
+      this.data.file_info_list = file_info_list;
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped></style>

+ 166 - 0
src/views/book/courseware/create/components/base/video/VideoSetting.vue

@@ -0,0 +1,166 @@
+<template>
+  <div>
+    <el-form :model="property" :label-position="labelPosition" label-width="72px">
+      <el-form-item label="序号" class="serial-number">
+        <el-input v-model="property.serial_number" />
+        <SvgIcon icon-class="switch" size="16" @click="switchSerialNumber(property)" />
+      </el-form-item>
+      <el-form-item>
+        <el-radio
+          v-for="{ value, label } in snGenerationMethodList"
+          :key="value"
+          v-model="property.sn_generation_method"
+          :label="value"
+        >
+          {{ label }}
+        </el-radio>
+      </el-form-item>
+      <el-form-item label="序号位置">
+        <div class="grid-container">
+          <el-button
+            v-for="{ value } in serialNumberPositionList"
+            :key="value"
+            :class="[value, { active: value === property.sn_position }]"
+            :style="{ gridArea: value }"
+            @click="changeNumberPosition(value)"
+          />
+
+          <div class="main"></div>
+        </div>
+      </el-form-item>
+      <el-divider />
+      <el-form-item label="查看方式">
+        <el-radio v-for="{ value, label } in viewMethodList" :key="value" v-model="property.view_method" :label="value">
+          {{ label }}
+        </el-radio>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import SettingMixin from '@/views/book/courseware/create/components/common/SettingMixin';
+import {
+  snGenerationMethodList,
+  viewMethodList,
+  switchSerialNumber,
+  checkString,
+  serialNumberPositionList,
+} from '@/views/book/courseware/data/common';
+
+export default {
+  name: 'VideoSetting',
+  mixins: [SettingMixin],
+  data() {
+    return {
+      switchSerialNumber,
+      checkString,
+      serialNumberPositionList,
+      snGenerationMethodList,
+      viewMethodList,
+      labelPosition: 'left',
+      property: {
+        serial_number: 1, // 序号
+        sn_type: 'number',
+        sn_position: 'top-start', // 序号位置:top-start top top-end 等
+        sn_generation_method: snGenerationMethodList[0].value, // 序号生成方式:recalculate 重新计算follow 跟随
+        view_method: viewMethodList[0].value, // 查看方式
+      },
+    };
+  },
+  watch: {
+    property: {
+      handler(val) {
+        if (this.isSet) {
+          val.sn_type = checkString(val.serial_number);
+          this.$emit('updateSetting', val);
+        }
+      },
+      deep: true,
+    },
+  },
+  methods: {
+    /**
+     * @description 设置属性
+     * @param {object} property 属性
+     */
+    setSetting(property) {
+      this.isSet = true;
+      this.property = property;
+    },
+
+    /**
+     * @description 改变序号位置
+     * @param {String} sn_position
+     */
+    changeNumberPosition(sn_position) {
+      this.property.sn_position = sn_position;
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.el-form {
+  .serial-number {
+    :deep .el-form-item__content {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+
+    .svg-icon {
+      cursor: pointer;
+    }
+  }
+
+  .el-input {
+    margin-right: 16px;
+  }
+
+  .main {
+    grid-area: main;
+  }
+
+  .grid-container {
+    display: grid;
+    grid:
+      '. top-start top top-end .'
+      'left-start main main main right-start'
+      'left main main main right'
+      'left-end main main main right-end'
+      '. bottom-start bottom bottom-end .';
+    place-items: center center;
+    width: 134px;
+    height: 80px;
+    padding: 8px;
+    line-height: 10px;
+    border: 1px solid #ebebeb;
+
+    .el-button {
+      width: 16px;
+      height: 8px;
+      padding: 0;
+      margin: 0;
+      border: 1px solid #e4e4e4;
+      border-radius: 2px;
+
+      &.active {
+        background-color: $setting-active-color;
+      }
+    }
+
+    .main {
+      width: 64px;
+      height: 32px;
+      background-color: #f8f8f8;
+      border: 1px solid #e4e4e4;
+      border-radius: 2px;
+    }
+  }
+
+  .el-divider {
+    margin: 16px 0;
+  }
+}
+</style>

+ 16 - 8
src/views/book/courseware/data/bookType.js

@@ -6,6 +6,14 @@ import AudioPage from '../create/components/base/audio/Audio.vue';
 import AudioSetting from '../create/components/base/audio/AudioSetting.vue';
 import PicturePage from '../create/components/base/picture/Picture.vue';
 import PictureSetting from '../create/components/base/picture/PictureSetting.vue';
+import VideoPage from '../create/components/base/video/Video.vue';
+import VideoSetting from '../create/components/base/video/VideoSetting.vue';
+import StemPage from '../create/components/base/stem/Stem.vue';
+import StemSetting from '../create/components/base/stem/StemSetting.vue';
+import DescribePage from '../create/components/base/describe/Describe.vue';
+import DescribeSetting from '../create/components/base/describe/DescribeSetting.vue';
+import LabelPage from '../create/components/base/label/Label.vue';
+import LabelSetting from '../create/components/base/label/LabelSetting.vue';
 
 export const bookTypeOption = [
   {
@@ -16,23 +24,23 @@ export const bookTypeOption = [
         value: 'stem',
         label: '题干',
         icon: 'stem',
-        component: '',
+        component: StemPage,
         // 设置页面
-        set: '',
+        set: StemSetting,
       },
       {
         value: 'describe',
         label: '描述',
         icon: 'describe',
-        component: '',
-        set: '',
+        component: DescribePage,
+        set: DescribeSetting,
       },
       {
         value: 'label',
         label: '标签',
         icon: 'label',
-        component: '',
-        set: '',
+        component: LabelPage,
+        set: LabelSetting,
       },
       {
         value: 'audio',
@@ -45,8 +53,8 @@ export const bookTypeOption = [
         value: 'video',
         label: '视频',
         icon: 'video',
-        component: '',
-        set: '',
+        component: VideoPage,
+        set: VideoSetting,
       },
       {
         value: 'picture',

+ 6 - 0
src/views/book/courseware/data/common.js

@@ -41,6 +41,12 @@ export const serialNumberPositionList = [
   { value: 'bottom-end', justifyContent: 'flex-end' },
 ];
 
+// 拼音位置
+export const pinyinPositionList = [
+  { value: 'top', label: '上' },
+  { value: 'bottom', label: '下' },
+];
+
 /**
  * 判断序号类型
  * @param {string} str

+ 24 - 0
src/views/book/courseware/data/describe.js

@@ -0,0 +1,24 @@
+import {
+  snGenerationMethodList,
+  serialNumberTypeList,
+  serialNumberPositionList,
+  pinyinPositionList,
+} from '@/views/book/courseware/data/common';
+
+export function getDescribeData() {
+  return {
+    type: 'describe',
+    title: '描述',
+    property: {
+      serial_number: 1, // 序号
+      sn_type: serialNumberTypeList[0].value, // 序号类型:letter字母 number数字  capital大写字母 bracket_number括号数字
+      sn_position: serialNumberPositionList[0].value, // 序号位置:top-start top top-end,left-start left left-end等
+      sn_generation_method: snGenerationMethodList[0].value, // 序号生成方式:recalculate重新计算 follow 跟随
+      sn_style: '', // 序号样式三角形 圆形
+      sn_background_color: '', // 序号背景色
+      view_pinyin: 'true', // 显示拼音
+      pinyin_position: pinyinPositionList[0].value, // top bottom
+      sentence_case: 'true', // 句首大写
+    },
+  };
+}

+ 22 - 0
src/views/book/courseware/data/label.js

@@ -0,0 +1,22 @@
+import {
+  snGenerationMethodList,
+  serialNumberTypeList,
+  serialNumberPositionList,
+} from '@/views/book/courseware/data/common';
+
+export function getLabelData() {
+  return {
+    type: 'label',
+    title: '标签',
+    property: {
+      serial_number: 1, // 序号
+      sn_type: serialNumberTypeList[0].value, // 序号类型:letter字母 number数字  capital大写字母 bracket_number括号数字
+      sn_position: serialNumberPositionList[0].value, // 序号位置:top-start top top-end,left-start left left-end等
+      sn_generation_method: snGenerationMethodList[0].value, // 序号生成方式:recalculate重新计算 follow 跟随
+      sn_style: '', // 序号样式三角形 圆形
+      sn_background_color: '', // 序号背景色
+      label_color: '随机', // 标签颜色
+      label_font: '中文', // 标签字体
+    },
+  };
+}

+ 24 - 0
src/views/book/courseware/data/stem.js

@@ -0,0 +1,24 @@
+import {
+  snGenerationMethodList,
+  serialNumberTypeList,
+  serialNumberPositionList,
+  pinyinPositionList,
+} from '@/views/book/courseware/data/common';
+
+export function getStemData() {
+  return {
+    type: 'stem',
+    title: '题干',
+    property: {
+      serial_number: 1, // 序号
+      sn_type: serialNumberTypeList[0].value, // 序号类型:letter字母 number数字  capital大写字母 bracket_number括号数字
+      sn_position: serialNumberPositionList[0].value, // 序号位置:top-start top top-end,left-start left left-end等
+      sn_generation_method: snGenerationMethodList[0].value, // 序号生成方式:recalculate重新计算 follow 跟随
+      sn_style: '', // 序号样式三角形 圆形
+      sn_background_color: '', // 序号背景色
+      view_pinyin: 'true', // 显示拼音
+      pinyin_position: pinyinPositionList[0].value, // top bottom
+      sentence_case: 'true', // 句首大写
+    },
+  };
+}

+ 26 - 0
src/views/book/courseware/data/video.js

@@ -0,0 +1,26 @@
+import {
+  snGenerationMethodList,
+  audioViewMethodList,
+  serialNumberTypeList,
+  serialNumberPositionList,
+} from '@/views/book/courseware/data/common';
+
+export function getVideoData() {
+  return {
+    type: 'video',
+    title: '视频',
+    single_size: 2 * 1024, // 单位MB
+    total_size: 10 * 1024, // 单位MB
+    property: {
+      serial_number: 1, // 序号
+      sn_type: serialNumberTypeList[0].value, // 序号类型:letter字母 number数字  capital大写字母 bracket_number括号数字
+      sn_position: serialNumberPositionList[0].value, // 序号位置:top-start top top-end,left-start left left-end等
+      sn_generation_method: snGenerationMethodList[0].value, // 序号生成方式:recalculate重新计算 follow 跟随
+      view_method: audioViewMethodList[0].value, // 查看方式:independent独立 list列表 icon图标
+    },
+    file_info_list: [],
+    file_id_list: [], // 文件 id['20032-121212', '20032-121216']
+    // 内容中包含的文件列表,
+    file_list: [],
+  };
+}

+ 3 - 0
src/views/book/courseware/preview/components/audio/Audio.vue

@@ -185,6 +185,7 @@ export default {
 
         &__container {
           height: 100px;
+          max-height: 500px;
           margin: 0 auto;
         }
 
@@ -198,6 +199,8 @@ export default {
         flex-direction: column;
         row-gap: 2px;
         width: 25%;
+        max-height: 500px;
+        overflow: auto;
       }
     }
   }

+ 4 - 3
src/views/book/courseware/preview/components/common/AudioPlay.vue

@@ -18,14 +18,15 @@
       </div>
     </template>
     <div v-else>
-      <div class="audio-small">
+      <div class="audio-small" :style="{ cursor: 'list' === viewMethod ? 'default' : 'pointer' }">
         <span>{{ audioIndex + 1 }}.</span>
         <SvgIcon
-          v-if="(audioIndex === curAudioIndex && 'list' != viewMethod) || 'list' === viewMethod"
+          v-if="audioIndex === curAudioIndex && 'list' != viewMethod"
           :icon-class="iconClass"
           size="14"
-          @click="playAudio"
+          style="cursor: default"
         />
+        <SvgIcon v-if="'list' === viewMethod" :icon-class="iconClass" size="14" @click="playAudio" />
         <span class="audio-time">{{ audio_allTime }}</span>
       </div>
     </div>

+ 226 - 0
src/views/book/courseware/preview/components/common/VideoPlay.vue

@@ -0,0 +1,226 @@
+<template>
+  <div class="video-play">
+    <template v-if="'big' === viewSize">
+      <div class="video-box">
+        <video
+          ref="veo"
+          :src="url"
+          :style="`object-fit: ${zoom};`"
+          :poster="veoPoster"
+          :autoplay="autoplay"
+          :controls="!originPlay && controls"
+          :loop="loop"
+          :muted="autoplay || muted"
+          controlsList="nodownload"
+          @click.prevent.once="onPlay"
+          @pause="showPlay ? onPause() : () => false"
+          @playing="showPlay ? onPlaying() : () => false"
+          @loadeddata="poster ? () => false : getPoster()"
+        >
+          <h2>您的浏览器<strong>不支持video标签</strong></h2>
+        </video>
+        <SvgIcon v-show="originPlay || showPlay" :class="{ hidden: hidden }" icon-class="paused" size="24" />
+      </div>
+    </template>
+    <div v-else class="video-box small-box">
+      <template v-if="'list' === viewMethod">
+        <video
+          ref="veo"
+          :src="url"
+          :style="`object-fit: ${zoom};`"
+          :poster="veoPoster"
+          :autoplay="autoplay"
+          :controls="!originPlay && controls"
+          :loop="loop"
+          :muted="autoplay || muted"
+          controlsList="nodownload"
+          @click.prevent.once="onPlay"
+          @pause="showPlay ? onPause() : () => false"
+          @playing="showPlay ? onPlaying() : () => false"
+          @loadeddata="poster ? () => false : getPoster()"
+        >
+          <h2>您的浏览器<strong>不支持video标签</strong></h2>
+        </video>
+      </template>
+      <template v-else>
+        <video ref="veo" :src="url" :poster="veoPoster">
+          <h2>您的浏览器<strong>不支持video标签</strong></h2>
+        </video>
+      </template>
+      <SvgIcon v-show="originPlay || showPlay" :class="{ hidden: hidden }" icon-class="paused" size="16" />
+    </div>
+  </div>
+</template>
+<script>
+import { GetFileURLMap } from '@/api/app';
+export default {
+  name: 'VideoPlay',
+  props: {
+    fileId: {
+      type: String,
+      required: true,
+    },
+    autoplay: {
+      // 视频就绪后是否马上播放
+      type: Boolean,
+      default: false,
+    },
+    curVideoIndex: {
+      type: Number,
+      default: 0,
+    },
+    viewSize: {
+      type: String,
+      required: true,
+    },
+    viewMethod: {
+      type: String,
+      default: 'independent',
+    },
+    showPlay: {
+      // 播放暂停时是否显示播放器中间的暂停图标
+      type: Boolean,
+      default: true,
+    },
+    preload: {
+      // 是否在页面加载后载入视频,如果设置了autoplay属性,则preload将被忽略;
+      type: String,
+      // auto:一旦页面加载,则开始加载视频; metadata:当页面加载后仅加载视频的元数据 none:页面加载后不应加载视频
+      default: 'metadata',
+    },
+    controls: {
+      // 是否向用户显示控件,比如进度条,全屏
+      type: Boolean,
+      default: true,
+    },
+    loop: {
+      // 视频播放完成后,是否循环播放
+      type: Boolean,
+      default: false,
+    },
+    muted: {
+      // 是否静音
+      type: Boolean,
+      default: false,
+    },
+    poster: {
+      // 视频封面url,支持网络地址 https 和相对地址 require('@/assets/images/Bao.jpg')
+      type: String,
+      default: '',
+    },
+    second: {
+      // 在未设置封面时,自动截取视频第 second 秒对应帧作为视频封面
+      type: Number,
+      default: 0.5,
+    },
+    zoom: {
+      // video的poster默认图片和视频内容缩放规则
+      type: String,
+      // none:(默认)保存原有内容,不进行缩放; fill:不保持原有比例,内容拉伸填充整个内容容器; contain:保存原有比例,内容以包含方式缩放; cover:保存原有比例,内容以覆盖方式缩放
+      default: 'contain',
+    },
+    playWidth: {
+      // 中间播放暂停按钮的边长
+      type: Number,
+      default: 96,
+    },
+  },
+  data() {
+    return {
+      url: '',
+      hidden: false,
+      veoPoster: this.poster,
+      originPlay: true,
+    };
+  },
+  watch: {
+    fileId: {
+      handler(val) {
+        if (!val) return;
+        GetFileURLMap({ file_id_list: [val] }).then(({ url_map }) => {
+          this.url = url_map[val];
+        });
+      },
+      immediate: true,
+    },
+  },
+  mounted() {
+    if (this.autoplay) {
+      this.hidden = true;
+      this.originPlay = false;
+    }
+    /*
+      自定义设置播放速度,经测试:
+      在vue2中需设置:this.$refs.veo.playbackRate = 2
+      在vue3中需设置:veo.value.defaultPlaybackRate = 2
+    */
+    // this.$refs.veo.playbackRate = 2
+  },
+  methods: {
+    getPoster() {
+      // 在未设置封面时,自动获取视频0.5s对应帧作为视频封面
+      // 由于不少视频第一帧为黑屏,故设置视频开始播放时间为0.5s,即取该时刻帧作为封面图
+      this.$refs.veo.currentTime = this.second;
+      // 创建canvas元素
+      const canvas = document.createElement('canvas');
+      const ctx = canvas.getContext('2d');
+      // canvas画图
+      canvas.width = this.$refs.veo.videoWidth;
+      canvas.height = this.$refs.veo.videoHeight;
+      ctx.drawImage(this.$refs.veo, 0, 0, canvas.width, canvas.height);
+      // 把canvas转成base64编码格式
+      this.veoPoster = canvas.toDataURL('image/png');
+    },
+    onPlay() {
+      if (this.originPlay) {
+        this.$refs.veo.currentTime = 0;
+        this.originPlay = false;
+      }
+      if (this.autoplay) {
+        this.$refs.veo.pause();
+      } else {
+        this.hidden = true;
+        this.$refs.veo.play();
+      }
+    },
+    onPause() {
+      this.hidden = false;
+    },
+    onPlaying() {
+      this.hidden = true;
+    },
+  },
+};
+</script>
+<style lang="scss" scoped>
+.video-play {
+  .video-box {
+    position: relative;
+    display: flex;
+    width: 100%;
+    height: 100%;
+    cursor: pointer;
+
+    video {
+      width: 100%;
+      height: 100%;
+    }
+
+    .svg-icon {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      pointer-events: none;
+      transform: translate(-50%, -50%);
+    }
+
+    .hidden {
+      opacity: 0;
+    }
+  }
+
+  .small-box {
+    background-color: #d9d9d9;
+  }
+}
+</style>

+ 2 - 0
src/views/book/courseware/preview/components/common/data.js

@@ -2,10 +2,12 @@ import AudioPreview from '../audio/Audio.vue';
 import DividerPreview from '../divider/Divider.vue';
 import SpacingPreview from '../spacing/Spacing.vue';
 import PicturePreview from '../picture/Picture.vue';
+import VideoPreview from '../video/Video.vue';
 
 export const previewComponentMap = {
   audio: AudioPreview,
   divider: DividerPreview,
   spacing: SpacingPreview,
   picture: PicturePreview,
+  video: VideoPreview,
 };

+ 212 - 0
src/views/book/courseware/preview/components/video/Video.vue

@@ -0,0 +1,212 @@
+<template>
+  <div class="video_area">
+    <div
+      v-show="data.property.sn_position.includes('top')"
+      class="top"
+      :style="getJustifyContentStyle(data.property.sn_position)"
+    >
+      {{ data.property.serial_number }}
+    </div>
+    <div
+      v-show="data.property.sn_position.includes('left')"
+      class="left"
+      :style="getJustifyContentStyle(data.property.sn_position)"
+    >
+      {{ data.property.serial_number }}
+    </div>
+    <div class="main">
+      <ul v-if="'list' === data.property.view_method" class="view_list">
+        <li v-for="(file, i) in data.file_list" :key="i">
+          <VideoPlay
+            view-size="small"
+            view-method="list"
+            :file-id="file.file_id"
+            :cur-video-index="curVideoIndex"
+            @changeFile="changeFile"
+          />
+        </li>
+      </ul>
+      <div v-if="'independent' === data.property.view_method" class="view_independent">
+        <el-carousel
+          ref="video_carousel"
+          indicator-position="none"
+          direction="vertical"
+          :autoplay="false"
+          :interval="0"
+        >
+          <el-carousel-item v-for="(file, i) in data.file_list" :key="i">
+            <VideoPlay
+              view-size="big"
+              :file-id="file.file_id"
+              :cur-video-index="curVideoIndex"
+              @changeFile="changeFile"
+            />
+          </el-carousel-item>
+        </el-carousel>
+        <ul class="view_independent_list">
+          <li v-for="(file, i) in data.file_list" :key="i" @click="handleAudioClick(i)">
+            <VideoPlay view-size="small" :file-id="file.file_id" :audio-index="i" :cur-video-index="curVideoIndex" />
+          </li>
+        </ul>
+      </div>
+    </div>
+    <div
+      v-show="data.property.sn_position.includes('right')"
+      class="right"
+      :style="getJustifyContentStyle(data.property.sn_position)"
+    >
+      {{ data.property.serial_number }}
+    </div>
+    <div
+      v-show="data.property.sn_position.includes('bottom')"
+      class="bottom"
+      :style="getJustifyContentStyle(data.property.sn_position)"
+    >
+      {{ data.property.serial_number }}
+    </div>
+  </div>
+</template>
+
+<script>
+import { getVideoData } from '@/views/book/courseware/data/video';
+import PreviewMixin from '../common/PreviewMixin';
+import VideoPlay from '../common/VideoPlay.vue';
+
+export default {
+  name: 'VideoPreview',
+  components: { VideoPlay },
+  mixins: [PreviewMixin],
+  data() {
+    return {
+      data: getVideoData(),
+      curVideoIndex: 0,
+    };
+  },
+  methods: {
+    handleAudioClick(index) {
+      // 获取 Carousel 实例
+      const carousel = this.$refs.video_carousel;
+      // 切换到对应索引的文件
+      carousel.setActiveItem(index);
+      this.curVideoIndex = index;
+    },
+    changeFile(type) {
+      // 获取 Carousel 实例
+      const carousel = this.$refs.video_carousel;
+      // 切换到对应索引的文件
+      if (type === 'prev') {
+        carousel.prev();
+        this.curVideoIndex += -1;
+      } else {
+        carousel.next();
+        this.curVideoIndex += 1;
+      }
+      if (this.curVideoIndex >= this.data.file_id_list.length) {
+        this.curVideoIndex = 0;
+      }
+      if (this.curVideoIndex < 0) {
+        this.curVideoIndex = this.data.file_id_list.length - 1;
+      }
+    },
+    /**
+     * 得到位置样式
+     * @param {string} position 位置
+     */
+    getJustifyContentStyle(position) {
+      if (position.includes('start')) {
+        return 'justify-content: start';
+      } else if (position.includes('end')) {
+        return 'justify-content: end';
+      }
+      return 'justify-content: center';
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.video_area {
+  display: grid;
+  grid:
+    'top top top'
+    'left main right'
+    'bottom bottom bottom';
+  grid-template-columns: 1fr 28fr 1fr;
+  width: 100%;
+
+  > div {
+    display: flex;
+    margin: 4px auto;
+
+    > span {
+      display: flex;
+    }
+  }
+
+  .top {
+    grid-area: top;
+    width: 92%;
+  }
+
+  .left {
+    flex-direction: column;
+    grid-area: left;
+  }
+
+  .main {
+    grid-area: main;
+    width: 100%;
+
+    .view_list {
+      display: flex;
+      gap: 20px 32px;
+      width: 100%;
+
+      > li {
+        width: 25%;
+      }
+    }
+
+    .view_independent {
+      display: flex;
+      column-gap: 3%;
+      width: 100%;
+
+      :deep .el-carousel {
+        width: 90%;
+        background-color: #d9d9d9;
+
+        &__container {
+          height: 100%;
+          max-height: 500px;
+          padding: 2px;
+          margin: 0 auto;
+        }
+
+        &__item {
+          transition: none !important;
+        }
+      }
+
+      .view_independent_list {
+        display: flex;
+        flex-direction: column;
+        row-gap: 20px;
+        width: 25%;
+        max-height: 500px;
+        overflow: auto;
+      }
+    }
+  }
+
+  .right {
+    flex-direction: column;
+    grid-area: right;
+  }
+
+  .bottom {
+    grid-area: bottom;
+    width: 92%;
+  }
+}
+</style>