zq 1 rok pred
rodič
commit
24a0b1878d

+ 43 - 0
src/utils/transform.js

@@ -0,0 +1,43 @@
+// 对小于 10 的补零
+export function zeroFill(val) {
+  return val < 10 ? `0${val}` : val;
+}
+
+/**
+ * 将秒转为时:分:秒格式
+ * @param {Number|String} val 秒
+ * @param {'normal'|'chinese'} type 格式类型
+ * @returns {string} 'normal' hh:MM:ss 小于1小时返回 MM:ss 'chinese' h时m分s秒
+ */
+export function secondFormatConversion(val = 0, type = 'normal') {
+  const seconds = parseInt(val); // 输入的秒数
+  const hours = Math.floor(seconds / 3600); // 小时部分
+  const minutes = Math.floor((seconds % 3600) / 60); // 分钟部分
+  const remainingSeconds = seconds % 60; // 剩余的秒数
+
+  // 使用零填充函数来格式化小时、分钟和秒
+  const formattedHours = zeroFill(hours);
+  const formattedMinutes = zeroFill(minutes);
+  const formattedSeconds = zeroFill(remainingSeconds);
+
+  // 根据时间范围返回不同的格式
+  if (hours > 0) {
+    if (type === 'chinese') {
+      return `${hours}时${minutes}分${remainingSeconds}秒`;
+    }
+    return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
+  }
+  if (type === 'chinese') {
+    return `${minutes}分${remainingSeconds}秒`;
+  }
+  return `${formattedMinutes}:${formattedSeconds}`;
+}
+
+/**
+ * 将时间转为 时:分:秒 格式
+ * @param {Date} date 时间戳
+ * @returns {String} hh:MM:ss
+ */
+export function timeFormatConversion(date) {
+  return `${zeroFill(date.getHours())}:${zeroFill(date.getMinutes())}:${zeroFill(date.getSeconds())}`;
+}

+ 1 - 1
src/views/book/chapter.vue

@@ -54,7 +54,7 @@ export default {
   provide() {
     return {
       selectNode: this.selectNode,
-      getCurChaterId: () => this.curChapterId,
+      getCurChapterId: () => this.curChapterId,
     };
   },
   data() {

+ 2 - 2
src/views/book/components/catalogueTree.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="node-wrapper">
-    <div v-for="node in nodes" :key="node.id" :class="['node', { active: getCurChaterId() === node.id }]">
+    <div v-for="node in nodes" :key="node.id" :class="['node', { active: getCurChapterId() === node.id }]">
       <div
         :class="['node-name', { content: node.is_leaf_chapter === 'true' }]"
         @click="handleSelectNode(node.is_leaf_chapter, node.id)"
@@ -15,7 +15,7 @@
 <script>
 export default {
   name: 'CatalogueTree',
-  inject: ['selectNode', 'getCurChaterId'],
+  inject: ['selectNode', 'getCurChapterId'],
   props: {
     nodes: {
       type: Array,

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

@@ -138,7 +138,7 @@ export default {
     // 文件校验
     afterSelectFile(file) {
       const fileName = file.name;
-      let singleSizeTip = `文件[${fileName}]大小超过 ${conversionSize(this.content.single_size)},被移除!`;
+      let singleSizeTip = `文件[${fileName}]大小超过 ${this.content.single_size}MB,被移除!`;
       if (file.size > this.content.single_size * 1024 * 1024) {
         this.$message.error(singleSizeTip);
         this.$refs.upload.handleRemove(file);
@@ -174,7 +174,7 @@ export default {
       }
       const totalSize = files.reduce((sum, cur) => sum + Number(cur.size || 0), 0);
       if (totalSize > this.content.total_size * 1024 * 1024) {
-        this.$message.error(`文件总大小不能超过${conversionSize(this.content.total_size)}!`);
+        this.$message.error(`文件总大小不能超过${conversionSize(this.content.total_size * 1024 * 1024)}!`);
         return false;
       }
 
@@ -220,7 +220,6 @@ export default {
 
     // 给文件加介绍
     fillDescribeToFile(file) {
-      console.log(file);
       let en = this.content.file_info_list.find((p) => p.file_id === file.file_id);
       if (en) {
         Object.assign(en, file);

+ 1 - 1
src/views/book/courseware/create/components/base/picture/PictureSetting.vue

@@ -128,7 +128,7 @@ export default {
   methods: {
     /**
      * @description 设置属性
-     * @param {Object} setting 属性
+     * @param {Object} property 属性
      */
     setSetting(property) {
       this.isSet = true;

+ 1 - 0
src/views/book/courseware/create/components/common/ModuleMixin.js

@@ -41,6 +41,7 @@ const mixin = {
     GetCoursewareComponentContent({ courseware_id: this.courseware_id, component_id: this.id }).then(({ content }) => {
       if (content) {
         this.$nextTick(() => {
+          console.log(this.id);
           // 数据加载完成后的操作
           this.data = JSON.parse(content);
         });

+ 146 - 19
src/views/book/courseware/preview/components/audio/Audio.vue

@@ -1,37 +1,164 @@
 <template>
-  <ul>
-    <li v-for="(file, i) in file_list" :key="i">
-      <audio :id="file.file_id" :src="file.file_url" controls></audio>
-    </li>
-  </ul>
+  <div class="audio_area">
+    <div class="top">
+      <span v-show="'top-start' === data.property.sn_position">{{ data.property.serial_number }}</span>
+      <span v-show="'top' === data.property.sn_position" style="justify-content: center">{{
+        data.property.serial_number
+      }}</span>
+      <span v-show="'top-end' === data.property.sn_position">{{ data.property.serial_number }}</span>
+    </div>
+    <div class="left">
+      <span v-show="'left-start' === data.property.sn_position">{{ data.property.serial_number }}</span>
+      <span v-show="'left' === data.property.sn_position" style="justify-content: center">{{
+        data.property.serial_number
+      }}</span>
+      <span v-show="'left-end' === data.property.sn_position">{{ data.property.serial_number }}</span>
+    </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">
+          <AudioPlay view-size="small" :file-id="file.file_id" :audio-index="i" view-method="list" />
+        </li>
+      </ul>
+      <div
+        v-if="'independent' === data.property.view_method || 'icon' === data.property.view_method"
+        class="view_independent"
+      >
+        <el-carousel ref="audio_carousel" indicator-position="none" direction="vertical" :autoplay="false">
+          <el-carousel-item v-for="(file, i) in data.file_list" :key="i">
+            <AudioPlay view-size="big" :file-id="file.file_id" :show-slider="true" :cur-audio-index="curAudioIndex" />
+          </el-carousel-item>
+        </el-carousel>
+        <ul class="view_independent_list">
+          <li v-for="(file, i) in data.file_list" :key="i" @click="handleAudioClick(i)">
+            <AudioPlay
+              view-size="small"
+              :file-id="file.file_id"
+              :show-slider="false"
+              :audio-index="i"
+              :cur-audio-index="curAudioIndex"
+            />
+          </li>
+        </ul>
+      </div>
+    </div>
+    <div class="right">
+      <span v-show="'right-start' === data.property.sn_position">{{ data.property.serial_number }}</span>
+      <span v-show="'right' === data.property.sn_position" style="justify-content: center">{{
+        data.property.serial_number
+      }}</span>
+      <span v-show="'right-end' === data.property.sn_position">{{ data.property.serial_number }}</span>
+    </div>
+    <div class="bottom">
+      <span v-show="'bottom-start' === data.property.sn_position">{{ data.property.serial_number }}</span>
+      <span v-show="'bottom' === data.property.sn_position" style="justify-content: center">{{
+        data.property.serial_number
+      }}</span>
+      <span v-show="'bottom-end' === data.property.sn_position">{{ data.property.serial_number }}</span>
+    </div>
+  </div>
 </template>
 
 <script>
 import { getAudioData } from '@/views/book/courseware/data/audio';
-
 import PreviewMixin from '../common/PreviewMixin';
+import AudioPlay from '../common/AudioPlay.vue';
 
 export default {
   name: 'AudioPreview',
+  components: { AudioPlay },
   mixins: [PreviewMixin],
   data() {
     return {
       data: getAudioData(),
-      file_list: [
-        {
-          file_id: '1',
-          file_url:
-            'https://file-kf.helxsoft.cn/CSFileServer/URL/002/69F3878866F2A1B7DF04C4EFE9ACD04120240402145413PKA8LLFPXRT1BKURSBNIFXGK8EV5VCBIVL9WGCFB_00201-20240402-14-UAG696HY.mp3',
-        },
-      ],
-      labelText: '音频',
-      acceptFileType: '.mp3,.acc,.wma',
-      uploadTip: '支持上传mp3、acc、wma,等格式音频文件,单个文件最大100MB,总文件体积不超1G。',
-      iconClass: 'note',
+      curAudioIndex: 0,
     };
   },
-  methods: {},
+  methods: {
+    handleAudioClick(index) {
+      // 获取 Carousel 实例
+      const carousel = this.$refs.audio_carousel;
+      // 切换到对应索引的图片
+      carousel.setActiveItem(index);
+      this.curAudioIndex = index;
+    },
+  },
 };
 </script>
 
-<style lang="scss" scoped></style>
+<style lang="scss" scoped>
+.audio_area {
+  display: grid;
+  grid:
+    'top top top top top'
+    'left main main main right'
+    'bottom bottom bottom bottom bottom';
+
+  > div {
+    display: flex;
+    justify-content: space-between;
+    margin: 0 auto;
+    text-align: center;
+  }
+
+  .top {
+    grid-area: top;
+    width: 100%;
+  }
+
+  .left {
+    flex-direction: column;
+    grid-area: left;
+  }
+
+  .main {
+    grid-area: main;
+    width: 100%;
+    height: 100%;
+
+    .view_list {
+      display: flex;
+      gap: 32px 28px;
+
+      > li {
+        width: 250px;
+        height: 46px;
+      }
+    }
+
+    .view_independent {
+      display: flex;
+      column-gap: 28px;
+
+      :deep .el-carousel {
+        width: 855px;
+        padding: 200px;
+        background-color: #d9d9d9;
+
+        &__container {
+          height: 150px;
+          margin: 0 auto;
+        }
+      }
+
+      .view_independent_list {
+        display: flex;
+        flex-direction: column;
+        row-gap: 2px;
+        width: 250px;
+        height: 46px;
+      }
+    }
+  }
+
+  .right {
+    flex-direction: column;
+    grid-area: right;
+  }
+
+  .bottom {
+    grid-area: bottom;
+    width: 100%;
+  }
+}
+</style>

+ 253 - 0
src/views/book/courseware/preview/components/common/AudioPlay.vue

@@ -0,0 +1,253 @@
+<template>
+  <div class="audio-wrapper">
+    <template v-if="'big' === viewSize">
+      <div class="audio-icon">
+        <SvgIcon icon-class="pre" />
+        <SvgIcon :icon-class="iconClass" size="30" @click="playAudio" />
+        <SvgIcon icon-class="next" />
+        <SvgIcon icon-class="1x" size="12" />
+      </div>
+      <div v-if="showSlider" class="slider-area">
+        <span class="audio-time">{{ secondFormatConversion(audio.current_time) }} / {{ audio_allTime }}</span>
+        <el-slider
+          v-model="play_value"
+          class="audio-slider"
+          :format-tooltip="formatProcessToolTip"
+          @change="changeCurrentTime"
+        />
+      </div>
+    </template>
+    <div v-else>
+      <div class="audio-small">
+        <span>{{ audioIndex + 1 }}.</span>
+        <SvgIcon
+          v-if="(audioIndex === curAudioIndex && 'list' != viewMethod) || 'list' === viewMethod"
+          :icon-class="iconClass"
+          size="14"
+          @click="playAudio"
+        />
+        <span class="audio-time">{{ audio_allTime }}</span>
+      </div>
+    </div>
+
+    <audio
+      :id="fileId"
+      :ref="fileId"
+      :src="url"
+      preload="metadata"
+      @loadedmetadata="onLoadedmetadata"
+      @timeupdate="onTimeupdate"
+      @canplaythrough="oncanplaythrough"
+    ></audio>
+  </div>
+</template>
+
+<script>
+import { GetFileURLMap } from '@/api/app';
+import { secondFormatConversion } from '@/utils/transform';
+
+export default {
+  name: 'AudioPlay',
+  props: {
+    fileId: {
+      type: String,
+      required: true,
+    },
+    viewSize: {
+      type: String,
+      required: true,
+    },
+    viewMethod: {
+      type: String,
+      default: 'independent',
+    },
+    audioIndex: {
+      type: Number,
+      default: 0,
+    },
+    curAudioIndex: {
+      type: Number,
+      default: 0,
+    },
+    showSlider: {
+      type: Boolean,
+      default: false,
+    },
+    // 是否显示音频进度条
+    showProgress: {
+      type: Boolean,
+      default: true,
+    },
+  },
+  data() {
+    return {
+      secondFormatConversion,
+      url: '',
+      audio: {
+        paused: true,
+        playing: false,
+        // 音频当前播放时长
+        current_time: 0,
+        // 音频最大播放时长
+        max_time: 0,
+        isPlaying: false,
+        loading: false,
+      },
+      play_value: 0,
+      audio_allTime: null, // 展示总时间
+    };
+  },
+  computed: {
+    iconClass() {
+      return this.audio.paused ? 'paused' : 'playing';
+    },
+  },
+  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.fileId) return;
+    this.$refs[this.fileId].addEventListener('ended', () => {
+      this.audio.paused = true;
+    });
+    this.$refs[this.fileId].addEventListener('pause', () => {
+      this.audio.paused = true;
+    });
+    this.$refs[this.fileId].addEventListener('play', () => {
+      this.audio.paused = false;
+    });
+  },
+  methods: {
+    playAudio() {
+      if (!this.url) return;
+      const audio = this.$refs[this.fileId];
+      let audioArr = document.getElementsByTagName('audio');
+      if (audioArr && audioArr.length > 0) {
+        for (let i = 0; i < audioArr.length; i++) {
+          if (audioArr[i].src === this.url) {
+            if (audioArr[i].id !== this.fileId) {
+              audioArr[i].pause();
+            }
+          } else {
+            audioArr[i].pause();
+          }
+        }
+      }
+      audio.paused ? audio.play() : audio.pause();
+    },
+    // 进度条格式化toolTip
+    formatProcessToolTip(index) {
+      let _index = parseInt((this.audio.max_time / 100) * index);
+      return secondFormatConversion(_index);
+    },
+    // 点击 拖拽播放音频
+    changeCurrentTime(value) {
+      let audioId = this.fileId;
+      this.$refs[audioId].play();
+      this.audio.playing = true;
+      this.$refs[audioId].currentTime = parseInt((value / 100) * this.audio.max_time);
+    },
+    // 音频加载完之后
+    onLoadedmetadata(res) {
+      this.audio.max_time = parseInt(res.target.duration);
+      this.audio_allTime = secondFormatConversion(this.audio.max_time);
+    },
+    // 当音频当前时间改变后,进度条也要改变
+    onTimeupdate(res) {
+      let audioId = this.fileId;
+      this.audio.current_time = res.target.currentTime;
+      this.play_value = (this.audio.current_time / this.audio.max_time) * 100;
+      if (this.audio.current_time * 1000 > this.ed) {
+        this.$refs[audioId].pause();
+      }
+    },
+    onTimeupdateTime(res, playFlag) {
+      if (!res && res !== 0) return;
+      let audioId = this.audioId;
+      this.$refs[audioId].currentTime = res;
+      this.play_value = (res / this.audio.max_time) * 100;
+      if (playFlag) {
+        let audio = document.getElementsByTagName('audio');
+        audio.forEach((item) => {
+          if (item.id !== audioId) {
+            item.pause();
+          }
+        });
+        this.$refs[audioId].play();
+      }
+    },
+    oncanplaythrough() {
+      this.audio.loading = false;
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.audio-wrapper {
+  display: flex;
+  flex-direction: column;
+  row-gap: 38px;
+
+  .audio-icon {
+    display: flex;
+    column-gap: 80px;
+    align-items: center;
+    justify-content: end;
+
+    .svg-icon {
+      cursor: pointer;
+    }
+  }
+
+  .slider-area {
+    font-size: 14px;
+    text-align: left;
+
+    .audio-slider {
+      width: 100%;
+
+      :deep .el-slider__runway {
+        height: 4px;
+        margin-top: 4px;
+        background-color: #fff;
+      }
+
+      :deep .el-slider__button {
+        width: 4px;
+        height: 4px;
+        border: none;
+      }
+
+      :deep .el-slider__bar {
+        height: 4px;
+        background-color: #1853c6;
+      }
+
+      :deep .el-slider__button-wrapper {
+        top: -16px;
+      }
+    }
+  }
+
+  .audio-small {
+    display: flex;
+    column-gap: 8px;
+    align-items: center;
+    padding: 13px;
+    background-color: #d9d9d9;
+
+    .svg-icon {
+      cursor: pointer;
+    }
+  }
+}
+</style>

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

@@ -1,7 +1,11 @@
 import AudioPreview from '../audio/Audio.vue';
 import DividerPreview from '../divider/Divider.vue';
+import SpacingPreview from '../spacing/Spacing.vue';
+import PicturePreview from '../picture/Picture.vue';
 
 export const previewComponentMap = {
   audio: AudioPreview,
   divider: DividerPreview,
+  spacing: SpacingPreview,
+  picture: PicturePreview,
 };

+ 0 - 1
src/views/book/courseware/preview/components/divider/Divider.vue

@@ -4,7 +4,6 @@
 
 <script>
 import { getDividerData } from '@/views/book/courseware/data/divider';
-
 import PreviewMixin from '../common/PreviewMixin';
 
 export default {

+ 130 - 24
src/views/book/courseware/preview/components/picture/Picture.vue

@@ -1,40 +1,146 @@
 <template>
-  <ModuleBase :type="data.type">
-    <template #content>
-      <UploadFile
-        :id="id"
-        :module-data="data"
-        :label-text="labelText"
-        :accept-file-type="acceptFileType"
-        :upload-tip="uploadTip"
-        :icon-class="iconClass"
-      />
-    </template>
-  </ModuleBase>
+  <div class="picture_area">
+    <div class="top">
+      <span v-show="'top-start' === data.property.sn_position">{{ data.property.serial_number }}</span>
+      <span v-show="'top' === data.property.sn_position" style="justify-content: center">{{
+        data.property.serial_number
+      }}</span>
+      <span v-show="'top-end' === data.property.sn_position">{{ data.property.serial_number }}</span>
+    </div>
+    <div class="left">
+      <span v-show="'left-start' === data.property.sn_position">{{ data.property.serial_number }}</span>
+      <span v-show="'left' === data.property.sn_position" style="justify-content: center">{{
+        data.property.serial_number
+      }}</span>
+      <span v-show="'left-end' === data.property.sn_position">{{ data.property.serial_number }}</span>
+    </div>
+    <div class="main">
+      <div class="view_area">
+        <el-carousel
+          v-if="'list' === data.property.view_method"
+          ref="picture_carousel"
+          class="view_independent"
+          indicator-position="none"
+        >
+          <el-carousel-item v-for="(file, i) in data.file_list" :key="i">
+            <el-image :id="file.file_id" :src="file.file_url" fit="contain" />
+          </el-carousel-item>
+        </el-carousel>
+        <ul class="view_list">
+          <li v-for="(file, i) in data.file_list" :key="i" @click="handleIndicatorClick(i)">
+            <el-image :id="file.file_id" :src="file.file_url" fit="contain" />
+          </li>
+        </ul>
+      </div>
+    </div>
+    <div class="right">
+      <span v-show="'right-start' === data.property.sn_position">{{ data.property.serial_number }}</span>
+      <span v-show="'right' === data.property.sn_position" style="justify-content: center">{{
+        data.property.serial_number
+      }}</span>
+      <span v-show="'right-end' === data.property.sn_position">{{ data.property.serial_number }}</span>
+    </div>
+    <div class="bottom">
+      <span v-show="'bottom-start' === data.property.sn_position">{{ data.property.serial_number }}</span>
+      <span v-show="'bottom' === data.property.sn_position" style="justify-content: center">{{
+        data.property.serial_number
+      }}</span>
+      <span v-show="'bottom-end' === data.property.sn_position">{{ data.property.serial_number }}</span>
+    </div>
+  </div>
 </template>
 
 <script>
 import { getPictureData } from '@/views/book/courseware/data/picture';
-import ModuleMixin from '../../common/ModuleMixin';
-import UploadFile from '../common/UploadFile.vue';
+import PreviewMixin from '../common/PreviewMixin';
 
 export default {
-  name: 'PicturePage',
-  components: { UploadFile },
-  mixins: [ModuleMixin],
+  name: 'PicturePreview',
+  mixins: [PreviewMixin],
   data() {
     return {
       data: getPictureData(),
-      file_info_list: [],
-      labelText: '图片',
-      acceptFileType: '.jpg,.png,.jpeg',
-      uploadTip: '支持上传jpg、png、jpeg,等格式图片文件,单个文件最大1MB,总文件体积不超100MB。',
-      iconClass: 'picture',
+      curImgIndex: 0,
     };
   },
   computed: {},
-  methods: {},
+  methods: {
+    handleIndicatorClick(index) {
+      // 获取 Carousel 实例
+      const carousel = this.$refs.picture_carousel;
+      // 切换到对应索引的图片
+      carousel.setActiveItem(index);
+    },
+  },
 };
 </script>
 
-<style lang="scss" scoped></style>
+<style lang="scss" scoped>
+.picture_area {
+  display: grid;
+  grid:
+    'top top top top top'
+    'left main main main right'
+    'bottom bottom bottom bottom bottom';
+
+  > div {
+    display: flex;
+    justify-content: space-between;
+    margin: 0 auto;
+    text-align: center;
+  }
+
+  .top {
+    grid-area: top;
+    width: 100%;
+  }
+
+  .left {
+    flex-direction: column;
+    grid-area: left;
+  }
+
+  .main {
+    grid-area: main;
+    width: 100%;
+    height: 100%;
+
+    .view_area {
+      width: 1086px;
+      height: auto;
+      margin: 0 auto;
+
+      :deep .el-carousel {
+        margin-bottom: 16px;
+        background-color: #d9d9d9;
+
+        &__container {
+          height: 598px;
+        }
+      }
+
+      .view_list {
+        display: flex;
+        column-gap: 44px;
+
+        .el-image {
+          width: 144px;
+          height: 144px;
+          cursor: pointer;
+          background-color: #d9d9d9;
+        }
+      }
+    }
+  }
+
+  .right {
+    flex-direction: column;
+    grid-area: right;
+  }
+
+  .bottom {
+    grid-area: bottom;
+    width: 100%;
+  }
+}
+</style>

+ 6 - 10
src/views/book/courseware/preview/components/spacing/Spacing.vue

@@ -1,20 +1,16 @@
 <template>
-  <ModuleBase :type="data.type">
-    <template #content>
-      <div :style="settingStyle"></div>
-    </template>
-  </ModuleBase>
+  <div>
+    <div :style="settingStyle"></div>
+  </div>
 </template>
 
 <script>
 import { getSpacingData } from '@/views/book/courseware/data/spacing';
-
-import ModuleMixin from '../../common/ModuleMixin';
+import PreviewMixin from '../common/PreviewMixin';
 
 export default {
-  name: 'SpacingPage',
-  components: {},
-  mixins: [ModuleMixin],
+  name: 'SpacingPreview',
+  mixins: [PreviewMixin],
   data() {
     return {
       data: getSpacingData(),

+ 1 - 1
src/views/book/courseware/preview/index.vue

@@ -78,7 +78,7 @@ export default {
   provide() {
     return {
       selectNode: this.selectNode,
-      getCurChaterId: () => this.curChapterId,
+      getCurChapterId: () => this.curChapterId,
     };
   },
   data() {