Browse Source

语音矩阵

dusenyao 11 months ago
parent
commit
aeeb7124b5
42 changed files with 2281 additions and 19 deletions
  1. 6 0
      package-lock.json
  2. 1 0
      package.json
  3. BIN
      src/assets/voice_matrix/compare-disable-24.png
  4. BIN
      src/assets/voice_matrix/compare-disable.png
  5. BIN
      src/assets/voice_matrix/compare-pause-blue.png
  6. BIN
      src/assets/voice_matrix/full-screen-blue.png
  7. BIN
      src/assets/voice_matrix/icon-pause-blue.png
  8. BIN
      src/assets/voice_matrix/icon-play-blue.png
  9. BIN
      src/assets/voice_matrix/icon-popover-actiive.png
  10. BIN
      src/assets/voice_matrix/icon-popover.png
  11. BIN
      src/assets/voice_matrix/icon-voice-play-blue.png
  12. BIN
      src/assets/voice_matrix/icon-voice.png
  13. BIN
      src/assets/voice_matrix/loading-blue.png
  14. BIN
      src/assets/voice_matrix/luyin-active.png
  15. BIN
      src/assets/voice_matrix/luyin-delete-active.png
  16. BIN
      src/assets/voice_matrix/luyin-delete.png
  17. BIN
      src/assets/voice_matrix/luyin-play-active.png
  18. BIN
      src/assets/voice_matrix/luyin-play-stop.png
  19. BIN
      src/assets/voice_matrix/luyin-play.png
  20. BIN
      src/assets/voice_matrix/luyin-stop.png
  21. BIN
      src/assets/voice_matrix/luyin.png
  22. BIN
      src/assets/voice_matrix/pauseC-16-normal-blue copy.png
  23. BIN
      src/assets/voice_matrix/pauseC-16-normal-blue.png
  24. BIN
      src/assets/voice_matrix/tts-blue.png
  25. BIN
      src/assets/voice_matrix/tts-play-blue.png
  26. 3 1
      src/icons/svg/components/note.svg
  27. 2 0
      src/views/book/chapter.vue
  28. 2 1
      src/views/book/courseware/create/components/CreateCanvas.vue
  29. 1 1
      src/views/book/courseware/create/components/base/common/UploadFile.vue
  30. 5 0
      src/views/book/courseware/create/components/common/SelectUpload.vue
  31. 191 10
      src/views/book/courseware/create/components/question/voice_matrix/VoiceMatrix.vue
  32. 2 1
      src/views/book/courseware/data/bookType.js
  33. 20 2
      src/views/book/courseware/data/voiceMatrix.js
  34. 1 1
      src/views/book/courseware/preview/CoursewarePreview.vue
  35. 863 0
      src/views/book/courseware/preview/components/voice_matrix/VoiceMatrixPreview.vue
  36. 162 0
      src/views/book/courseware/preview/components/voice_matrix/components/AudioCompareMatrix.vue
  37. 477 0
      src/views/book/courseware/preview/components/voice_matrix/components/AudioLine.vue
  38. 115 0
      src/views/book/courseware/preview/components/voice_matrix/components/AudioRed.vue
  39. 2 0
      src/views/book/courseware/preview/components/voice_matrix/components/Bus.js
  40. 425 0
      src/views/book/courseware/preview/components/voice_matrix/components/SoundRecord.vue
  41. 2 1
      src/views/book/courseware/preview/index.vue
  42. 1 1
      src/views/home/index.vue

+ 6 - 0
package-lock.json

@@ -14,6 +14,7 @@
         "core-js": "^3.37.0",
         "dompurify": "^3.1.2",
         "element-ui": "^2.15.14",
+        "js-audio-recorder": "^1.0.7",
         "js-cookie": "^3.0.5",
         "md5": "^2.3.0",
         "nprogress": "^0.2.0",
@@ -10311,6 +10312,11 @@
         "@sideway/pinpoint": "^2.0.0"
       }
     },
+    "node_modules/js-audio-recorder": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmmirror.com/js-audio-recorder/-/js-audio-recorder-1.0.7.tgz",
+      "integrity": "sha512-JiDODCElVHGrFyjGYwYyNi7zCbKk9va9C77w+zCPMmi4C6ix7zsX2h3ddHugmo4dOTOTCym9++b/wVW9nC0IaA=="
+    },
     "node_modules/js-base64": {
       "version": "2.6.4",
       "resolved": "https://registry.npmmirror.com/js-base64/-/js-base64-2.6.4.tgz",

+ 1 - 0
package.json

@@ -21,6 +21,7 @@
     "dompurify": "^3.1.2",
     "element-ui": "^2.15.14",
     "js-cookie": "^3.0.5",
+    "js-audio-recorder": "^1.0.7",
     "md5": "^2.3.0",
     "nprogress": "^0.2.0",
     "tinymce": "^5.10.9",

BIN
src/assets/voice_matrix/compare-disable-24.png


BIN
src/assets/voice_matrix/compare-disable.png


BIN
src/assets/voice_matrix/compare-pause-blue.png


BIN
src/assets/voice_matrix/full-screen-blue.png


BIN
src/assets/voice_matrix/icon-pause-blue.png


BIN
src/assets/voice_matrix/icon-play-blue.png


BIN
src/assets/voice_matrix/icon-popover-actiive.png


BIN
src/assets/voice_matrix/icon-popover.png


BIN
src/assets/voice_matrix/icon-voice-play-blue.png


BIN
src/assets/voice_matrix/icon-voice.png


BIN
src/assets/voice_matrix/loading-blue.png


BIN
src/assets/voice_matrix/luyin-active.png


BIN
src/assets/voice_matrix/luyin-delete-active.png


BIN
src/assets/voice_matrix/luyin-delete.png


BIN
src/assets/voice_matrix/luyin-play-active.png


BIN
src/assets/voice_matrix/luyin-play-stop.png


BIN
src/assets/voice_matrix/luyin-play.png


BIN
src/assets/voice_matrix/luyin-stop.png


BIN
src/assets/voice_matrix/luyin.png


BIN
src/assets/voice_matrix/pauseC-16-normal-blue copy.png


BIN
src/assets/voice_matrix/pauseC-16-normal-blue.png


BIN
src/assets/voice_matrix/tts-blue.png


BIN
src/assets/voice_matrix/tts-play-blue.png


+ 3 - 1
src/icons/svg/components/note.svg

@@ -1,3 +1,5 @@
 <svg width="10" height="12" viewBox="0 0 10 12" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4 7.0234V0H9.33333V1.33333H5.33333V9.33333C5.33333 10.8061 4.1394 12 2.66667 12C1.19391 12 0 10.8061 0 9.33333C0 7.8606 1.19391 6.66667 2.66667 6.66667C3.1524 6.66667 3.6078 6.79653 4 7.0234ZM2.66667 10.6667C3.40307 10.6667 4 10.0697 4 9.33333C4 8.59693 3.40307 8 2.66667 8C1.93029 8 1.33333 8.59693 1.33333 9.33333C1.33333 10.0697 1.93029 10.6667 2.66667 10.6667Z" fill="black"/>
+  <path
+    d="M4 7.0234V0H9.33333V1.33333H5.33333V9.33333C5.33333 10.8061 4.1394 12 2.66667 12C1.19391 12 0 10.8061 0 9.33333C0 7.8606 1.19391 6.66667 2.66667 6.66667C3.1524 6.66667 3.6078 6.79653 4 7.0234ZM2.66667 10.6667C3.40307 10.6667 4 10.0697 4 9.33333C4 8.59693 3.40307 8 2.66667 8C1.93029 8 1.33333 8.59693 1.33333 9.33333C1.33333 10.0697 1.93029 10.6667 2.66667 10.6667Z"
+    fill="black" />
 </svg>

+ 2 - 0
src/views/book/chapter.vue

@@ -297,6 +297,8 @@ export default {
       }
 
       .content {
+        display: flex;
+        justify-content: center;
         margin: 24px;
       }
 

+ 2 - 1
src/views/book/courseware/create/components/CreateCanvas.vue

@@ -845,9 +845,10 @@ export default {
   display: flex;
   flex-direction: column;
   row-gap: 6px;
-  width: 100%;
+  width: 1100px;
   min-height: calc(100% - 56px);
   padding: 24px;
+  margin: 0 auto;
   background-color: #fff;
   background-repeat: no-repeat;
   border-radius: 4px;

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

@@ -48,7 +48,7 @@
 <script>
 import { fileUpload } from '@/api/app';
 import { conversionSize } from '@/utils/common';
-import FillDescribe from '../../common/FillDescribe';
+import FillDescribe from '../../common/FillDescribe.vue';
 
 export default {
   name: 'UploadFile',

+ 5 - 0
src/views/book/courseware/create/components/common/SelectUpload.vue

@@ -9,6 +9,7 @@
       :multiple="multiple"
       :show-file-list="false"
       :auto-upload="false"
+      :limit="limit"
       :on-change="onFileChange"
     >
       <el-button>{{ showText }}</el-button>
@@ -39,6 +40,10 @@ export default {
       type: String,
       default: '100%',
     },
+    limit: {
+      type: Number,
+      default: 999,
+    },
   },
   data() {
     return {

+ 191 - 10
src/views/book/courseware/create/components/question/voice_matrix/VoiceMatrix.vue

@@ -1,11 +1,39 @@
 <template>
   <ModuleBase :type="data.type">
     <template #content>
-      <div v-for="(item, i) in data.option_list" :key="i" class="voice-matrix">
-        <div v-for="li in item" :key="li.mark"></div>
+      <div class="option-list">
+        <div v-for="(item, i) in data.option_list" :key="i" class="voice-matrix">
+          <div v-for="li in item" :key="li.mark" class="matrix-item">
+            <!-- eslint-disable max-len -->
+            <RichText
+              v-model="li.content"
+              :inline="true"
+              toolbar="fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright"
+            />
+          </div>
+        </div>
+      </div>
+
+      <SelectUpload label="矩阵音频" type="audio" width="500px" @uploadSuccess="uploadAudioSuccess" />
+      <div v-if="data.audio_data.url.length > 0" class="upload-file">
+        <div class="file-name">
+          <span>
+            <SvgIcon icon-class="note" size="12" />
+            <span>{{ data.audio_data.name }}</span>
+          </span>
+        </div>
+        <SvgIcon icon-class="delete-black" size="12" @click="removeFile('audio')" />
+      </div>
+      <SelectUpload label="lrc 文件" :limit="1" type="lrc" width="500px" @uploadSuccess="uploadLrcSuccess" />
+      <div v-if="data.lrc_data.url.length > 0" class="upload-file">
+        <div class="file-name">
+          <span>
+            <SvgIcon icon-class="note" size="12" />
+            <span>{{ data.lrc_data.name }}</span>
+          </span>
+        </div>
+        <SvgIcon icon-class="delete-black" size="12" @click="removeFile('lrc')" />
       </div>
-      <SelectUpload label="矩阵音频" type="audio" width="500px" />
-      <SelectUpload label="lrc 文件" type="lrc" width="500px" />
     </template>
   </ModuleBase>
 </template>
@@ -14,7 +42,8 @@
 import ModuleMixin from '../../common/ModuleMixin';
 import SelectUpload from '@/views/book/courseware/create/components/common/SelectUpload.vue';
 
-import { getVoiceMatrixData } from '@/views/book/courseware/data/voiceMatrix';
+import { getVoiceMatrixData, getOption } from '@/views/book/courseware/data/voiceMatrix';
+import { GetStaticResources } from '@/api/app';
 
 export default {
   name: 'VoiceMatrix',
@@ -29,19 +58,171 @@ export default {
   },
   watch: {
     'data.property.row_count': {
-      handler(val) {},
+      handler(val) {
+        if (val < this.data.option_list.length) {
+          this.data.option_list = this.data.option_list.slice(0, val);
+        } else {
+          const diff = val - this.data.option_list.length;
+          for (let i = 0; i < diff; i++) {
+            this.data.option_list.push(Array.from({ length: this.data.property.column_count }, getOption));
+          }
+        }
+      },
     },
     'data.property.column_count': {
-      handler(val) {},
+      handler(val) {
+        this.data.option_list = this.data.option_list.map((row) => {
+          if (val < row.length) {
+            return row.slice(0, val);
+          }
+          const diff = val - row.length;
+          return row.concat(Array.from({ length: diff }, getOption));
+        });
+      },
+    },
+  },
+  methods: {
+    /**
+     * 解析lrc文件
+     */
+    parseLrcFile() {
+      if (this.data.lrc_data.file_id.length === 0) {
+        return this.$message.warning('请先上传lrc文件');
+      }
+      const loading = this.$loading({ text: '解析lrc文件中' });
+      GetStaticResources('tool-ParseLRCFile', {
+        content_type: 'FILE',
+        file_id: this.data.lrc_data.file_id,
+      }).then(({ lrc_list }) => {
+        this.data.lrc_arr = lrc_list;
+        this.distribution();
+        loading.close();
+      });
+    },
+    /**
+     * 分配标记
+     */
+    distribution() {
+      const lrcArr = this.data.lrc_arr;
+      if (lrcArr.length === 0) {
+        return this.$message.warning('没有标记可分配');
+      }
+      let curIndex = 0;
+      this.data.option_list.forEach((row) => {
+        row.forEach((item, i) => {
+          const lrcData = lrcArr[curIndex];
+          if (lrcData) {
+            row[i].lrc_data = lrcData;
+            curIndex += 1;
+          }
+        });
+      });
+    },
+    uploadLrcSuccess(fileList) {
+      if (fileList.length > 0) {
+        const { file_name: name, file_url: url, file_id } = fileList[0];
+        this.data.lrc_data = {
+          name,
+          url,
+          id: `[FID##${file_id}##FID]`,
+          file_id,
+        };
+        this.parseLrcFile();
+      }
+    },
+    uploadAudioSuccess(fileList) {
+      if (fileList.length > 0) {
+        const { file_name: name, file_url: temporary_url, file_id, media_duration } = fileList[0];
+        this.data.audio_data = {
+          name,
+          media_duration,
+          temporary_url,
+          url: `[FID##${file_id}##FID]`,
+          file_id,
+        };
+      }
+    },
+    /**
+     * 删除文件
+     * @param {'audio' | 'lrc'} type
+     */
+    removeFile(type) {
+      if (type === 'audio') {
+        this.data.audio_data = {
+          name: '',
+          media_duration: 0,
+          temporary_url: '',
+          url: '',
+          file_id: '',
+        };
+      } else if (type === 'lrc') {
+        this.data.lrc_data = {
+          name: '',
+          url: '',
+          id: '',
+          file_id: '',
+        };
+      }
     },
   },
-  methods: {},
 };
 </script>
 
 <style lang="scss" scoped>
-.voice-matrix {
+.option-list {
+  margin-bottom: 12px;
+
+  .voice-matrix {
+    display: flex;
+    row-gap: 16px;
+
+    + .voice-matrix {
+      margin-top: 16px;
+    }
+
+    .matrix-item {
+      flex: 1;
+      min-width: 75px;
+      padding: 8px;
+      border: 1px solid $border-color;
+
+      &:not(:last-child) {
+        border-right: 0;
+      }
+
+      :deep p {
+        margin: 0;
+      }
+    }
+  }
+}
+
+.upload-file {
   display: flex;
-  row-gap: 16px;
+  column-gap: 12px;
+  align-items: center;
+  margin: 8px 0;
+
+  .file-name {
+    display: flex;
+    column-gap: 14px;
+    align-items: center;
+    justify-content: space-between;
+    max-width: 360px;
+    padding: 8px 12px;
+    font-size: 14px;
+    color: #1d2129;
+    background-color: #f7f8fa;
+
+    span {
+      display: flex;
+      column-gap: 14px;
+      align-items: center;
+    }
+  }
+
+  .svg-icon {
+    cursor: pointer;
+  }
 }
 </style>

+ 2 - 1
src/views/book/courseware/data/bookType.js

@@ -34,6 +34,7 @@ import LabelPreview from '@/views/book/courseware/preview/components/label/Label
 import SelectPreview from '@/views/book/courseware/preview/components/select/SelectPreview.vue';
 import MatchingPreview from '@/views/book/courseware/preview/components/matching/MatchingPreview.vue';
 import SortPreview from '@/views/book/courseware/preview/components/sort/SortPreview.vue';
+import VoiceMatrixPreview from '@/views/book/courseware/preview/components/voice_matrix/VoiceMatrixPreview.vue';
 
 export const bookTypeOption = [
   {
@@ -141,7 +142,7 @@ export const bookTypeOption = [
         icon: '',
         component: VoiceMatrix,
         set: VoiceMatrixSetting,
-        preview: '',
+        preview: VoiceMatrixPreview,
       },
     ],
   },

+ 20 - 2
src/views/book/courseware/data/voiceMatrix.js

@@ -21,10 +21,11 @@ export function getVoiceMatrixProperty() {
   };
 }
 
-export function getVoiceMatrixOption() {
+export function getOption() {
   return {
     content: '',
     mark: getRandomNumber(),
+    lrc_data: {}, // lrc 数据
   };
 }
 
@@ -32,7 +33,24 @@ export function getVoiceMatrixData() {
   return {
     type: 'voice_matrix',
     title: '语音矩阵',
-    option_list: Array.from({ length: 2 }, () => Array.from({ length: 5 }, getVoiceMatrixOption)),
+    lrc_arr: [], // lrc 文件解析后的数据
+    // lrc 文件数据
+    lrc_data: {
+      name: '',
+      url: '',
+      id: '',
+      file_id: '',
+    },
+    // 音频文件数据
+    audio_data: {
+      name: '',
+      media_duration: 0,
+      temporary_url: '',
+      url: '',
+      file_id: '',
+    },
+    option_list: Array.from({ length: 2 }, () => Array.from({ length: 5 }, getOption)),
+    record_list: [],
     min_height: 100,
     min_width: 300,
     property: getVoiceMatrixProperty(),

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

@@ -183,7 +183,7 @@ export default {
   display: flex;
   flex-direction: column;
   row-gap: 6px;
-  width: 100%;
+  width: 1100px;
   min-height: 500px;
   padding: 24px;
   background-color: #fff;

+ 863 - 0
src/views/book/courseware/preview/components/voice_matrix/VoiceMatrixPreview.vue

@@ -0,0 +1,863 @@
+<!-- eslint-disable vue/no-v-html -->
+<template>
+  <div class="voice-matrix-preview" :style="getAreaStyle()">
+    <div class="select-preview" :style="getAreaStyle()">
+      <SerialNumberPosition :property="data.property" />
+    </div>
+
+    <div class="main">
+      <div class="voice-matrix-audio">
+        <div v-show="hasSelectedCell" class="audio-simple">
+          <img
+            class="audio-simple-image"
+            :src="
+              playing
+                ? require(`@/assets/voice_matrix/icon-voice-play-blue.png`)
+                : require(`@/assets/voice_matrix/icon-voice.png`)
+            "
+            @click="playAudio"
+          />
+          <span
+            :class="['Repeat-16', 'audio-simple-repeat', isRepeat ? '' : 'disabled']"
+            @click="isRepeat = !isRepeat"
+          ></span>
+        </div>
+        <AudioLine
+          v-show="!hasSelectedCell"
+          ref="audioLine"
+          audio-id="voiceMatrixAudio"
+          :mp3="mp3Url"
+          :get-cur-time="getCurTime"
+          :stop-audio="stopAudio"
+          :mp3-source="mp3Source"
+          :audio-data="data"
+          @handleChangeStopAudio="handleChangeStopAudio"
+          @playChange="playChange"
+        />
+      </div>
+      <!-- 语音矩阵 -->
+      <div class="voice-matrix-container">
+        <div
+          v-if="data.option_list.length > 0"
+          class="matrix"
+          :style="{
+            'grid-template': `36px repeat(${data.option_list.length}, auto) minmax(36px, 1fr) / 36px repeat(${data.option_list[0].length}, auto) minmax(36px, 1fr)`,
+          }"
+          @mouseleave="clearSelectCell"
+        >
+          <!-- 顶部单元格 -->
+          <div class="matrix-top" @mouseenter="clearSelectCell"></div>
+          <template v-for="(row, i) in data.option_list[0]">
+            <div
+              :key="`top-${i}`"
+              :class="[
+                'matrix-top',
+                data.property.is_enable_column_play &&
+                (selectColumn === i || (selectedLine.type === 'column' && selectedLine.index === i))
+                  ? 'read'
+                  : '',
+              ]"
+              @mouseenter="checkboxMouseenter(selectColumn === i, 'column')"
+            >
+              <span
+                v-if="row.type !== 'connection' && data.property.is_enable_column_play"
+                v-show="selectColumn === i || (selectedLine.type === 'column' && selectedLine.index === i)"
+                :class="[
+                  `matrix-checkbox-row-${themeColor}`,
+                  selectedLine.type === 'column' && selectedLine.index === i ? 'active' : '',
+                ]"
+                @click="selectRowOrColumn(i, 'column')"
+              ></span>
+            </div>
+          </template>
+          <div class="matrix-top" @mouseenter="clearSelectCell"></div>
+          <!-- 主矩阵 -->
+          <template v-for="(row, i) in data.option_list">
+            <div
+              :key="`start-${i}`"
+              :class="[
+                'column-wrapper',
+                data.property.is_enable_row_play &&
+                (selectRow === i || (selectedLine.type === 'row' && selectedLine.index === i))
+                  ? 'read'
+                  : '',
+              ]"
+              @mouseenter="checkboxMouseenter(selectRow === i, 'row')"
+            >
+              <span
+                v-if="data.property.is_enable_row_play"
+                v-show="selectRow === i || (selectedLine.type === 'row' && selectedLine.index === i)"
+                :class="[
+                  `matrix-checkbox-column-${themeColor}`,
+                  selectedLine.type === 'row' && selectedLine.index === i ? 'active' : '',
+                ]"
+                @click="selectRowOrColumn(i, 'row')"
+              ></span>
+            </div>
+            <!-- 单元格 -->
+            <template v-for="(column, j) in row">
+              <div
+                :key="`wrapper-${i}-${j}`"
+                :class="[
+                  'column-wrapper',
+                  (data.property.is_enable_row_play && selectRow === i) ||
+                  (data.property.is_enable_column_play && selectColumn === j) ||
+                  (data.property.is_enable_column_play && selectedLine.type === 'column' && selectedLine.index === j) ||
+                  (data.property.is_enable_row_play && selectedLine.type === 'row' && selectedLine.index === i)
+                    ? 'read'
+                    : '',
+                ]"
+                @mouseenter="matrixCellMouseenter(i, j, column.type)"
+              >
+                <!-- 文本 -->
+                <div
+                  :key="`column-${i}-${j}`"
+                  :class="[
+                    column.content.length === 0 ? 'space' : `column-${themeColor}`,
+                    (selectCell.row === i && selectCell.column === j) ||
+                    (selectedLine.type === 'column' && selectedLine.index === j) ||
+                    (selectedLine.type === 'row' && selectedLine.index === i)
+                      ? 'selected'
+                      : '',
+                    playing &&
+                    column.lrc_data.begin_time / 1000 <= curTime &&
+                    (curTime < column.lrc_data.end_time / 1000 || column.lrc_data.end_time === -1)
+                      ? 'playing'
+                      : '',
+                    column.isTitle ? 'title' : '',
+                  ]"
+                  @click="matrixCellClick(i, j)"
+                >
+                  <span class="content rich-text" v-html="sanitizeHTML(column.content)"></span>
+                </div>
+              </div>
+            </template>
+
+            <div
+              :key="`end-${i}`"
+              :class="[
+                data.property.is_enable_row_play &&
+                (selectRow === i || (selectedLine.type === 'row' && selectedLine.index === i))
+                  ? 'read'
+                  : '',
+              ]"
+              @mouseenter="clearSelectCell"
+            ></div>
+          </template>
+          <!-- 底部格子 -->
+          <div class="matrix-bottom" @mouseenter="clearSelectCell"></div>
+          <template v-for="(row, i) in data.option_list[0]">
+            <div
+              :key="`bottom-${i}`"
+              :class="[
+                'matrix-bottom',
+                data.property.is_enable_column_play &&
+                (selectColumn === i || (selectedLine.type === 'column' && selectedLine.index === i))
+                  ? 'read'
+                  : '',
+              ]"
+              @mouseenter="clearSelectCell"
+            ></div>
+          </template>
+          <div class="matrix-bottom" @mouseenter="clearSelectCell"></div>
+        </div>
+      </div>
+      <!-- 录音 -->
+      <div class="voice-luyin">
+        <SoundRecord
+          v-if="refresh"
+          ref="luyin"
+          type="promax"
+          class="luyin-box"
+          :file-name="fileName"
+          :select-data="selectData"
+          :task-model="TaskModel"
+          :answer-record-list="data.record_list"
+          @getWavblob="getWavblob"
+          @getSelectData="getSelectData"
+          @handleParentPlay="pauseOtherAudio"
+          @sentPause="sentPause"
+          @handleWav="handleWav"
+        />
+        <AudioCompare
+          :style="{ flex: 1 }"
+          :theme-color="themeColor"
+          :wavblob="wavblob"
+          :url="mp3Url"
+          :is-record="isRecord"
+          :sent-pause="sentPause"
+          :matrix-select-lrc="matrixSelectLrc"
+          :get-cur-time="getCurTime"
+          :cur-time="curTime"
+          :handle-change-stop-audio="handleChangeStopAudio"
+          @playing="playChange"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { getVoiceMatrixData } from '@/views/book/courseware/data/voiceMatrix';
+import { getRandomNumber } from '@/utils/index.js';
+
+import PreviewMixin from '../common/PreviewMixin';
+import Bus from './components/Bus.js';
+import SoundRecord from './components/SoundRecord.vue';
+import AudioCompare from './components/AudioCompareMatrix.vue';
+import AudioLine from './components/AudioLine.vue';
+
+export default {
+  name: 'VoiceMatrixPreview',
+  components: {
+    SoundRecord,
+    AudioCompare,
+    AudioLine,
+  },
+  mixins: [PreviewMixin],
+  data() {
+    return {
+      data: getVoiceMatrixData(),
+      cid: getRandomNumber(),
+      themeColor: 'red',
+      TaskModel: '',
+      curTime: 0,
+      playing: false,
+      stopAudio: true,
+      unWatch: null,
+      lrcArray: [],
+      fileName: '',
+      // 底色行、列
+      selectRow: -1,
+      selectColumn: -1,
+      // 行、列选中
+      selectedLine: {
+        type: '',
+        index: 0,
+      },
+      // 点击选中
+      selectCell: {
+        row: -1,
+        column: -1,
+      },
+      isRepeat: false,
+      // 跟读所需属性
+      wavblob: null,
+      isRecord: false,
+      refresh: true,
+      matrixSelectLrc: null,
+    };
+  },
+  computed: {
+    hasSelectedCell() {
+      const { type, index } = this.selectedLine;
+      const { row, column } = this.selectCell;
+      return (type.length > 0 && index >= 0) || (row >= 0 && column >= 0);
+    },
+    mp3Url() {
+      const audioData = this.data.audio_data;
+      return audioData.url.match(/^\[FID##/) ? audioData.temporary_url : audioData.url;
+    },
+    mp3Source() {
+      const audioData = this.data.audio_data;
+      return 'source' in audioData ? audioData.source : '';
+    },
+    mp3Duration() {
+      return this.data.audio_data.media_duration * 1000;
+    },
+    selectData() {
+      const { type, index } = this.selectedLine;
+      const { row, column } = this.selectCell;
+      return {
+        type: type.length > 0 && index >= 0 ? type : 'cell',
+        index,
+        row,
+        column,
+      };
+    },
+  },
+  watch: {
+    hasSelectedCell() {
+      this.handleParentPlay();
+    },
+  },
+  created() {
+    Bus.$on('audioPause', (id) => {
+      if (this.cid === id) return;
+      if (this.$refs.luyin?.microphoneStatus) this.$refs.luyin.microphone();
+      this.handleParentPlay();
+    });
+  },
+  mounted() {
+    document.querySelector('body').addEventListener('click', this.restoreAudioStatus);
+  },
+  beforeDestroy() {
+    document.querySelector('body').removeEventListener('click', this.restoreAudioStatus);
+  },
+  methods: {
+    handleWav(data) {
+      this.data.record_list = data;
+    },
+    // 鼠标移入移出
+    matrixCellMouseenter(i, j, type) {
+      if (type === 'connection') {
+        this.selectRow = -1;
+        this.selectColumn = -1;
+      } else {
+        this.selectRow = i;
+        this.selectColumn = j;
+      }
+    },
+
+    clearSelectCell() {
+      this.selectRow = -1;
+      this.selectColumn = -1;
+    },
+
+    // 单击单元格
+    matrixCellClick(row, column) {
+      if (this.playing) this.handleParentPlay();
+      if (this.unWatch) this.unWatch();
+      this.lrcArray = [];
+      if (row === this.selectCell.row && column === this.selectCell.column) {
+        this.selectCell = { row: -1, column: -1 };
+        return;
+      }
+      this.selectedLine = { type: '', index: -1 };
+      this.selectCell = { row, column };
+      this.handleChangeTime(this.data.option_list[row][column].lrc_data);
+      // 设置录音文件名
+      this.setRecordingFileName(row, column);
+    },
+
+    setRecordingFileName(row, column) {
+      return `录音第${column}列第${row}行`;
+    },
+
+    /**
+     * 判断 click 点击是否语音矩阵可操作区域
+     * @param {PointerEvent} event
+     */
+    restoreAudioStatus(event) {
+      const whitePath = [
+        'column-green',
+        'column-red',
+        'column-brown',
+        'matrix-checkbox-column-',
+        'matrix-checkbox-row-',
+        'audio-simple-image',
+        'audio-simple-repeat',
+        'luyin-box',
+      ];
+      const operable = event.composedPath().some((item) => {
+        const className = item.className;
+        if (!className) return false;
+        return whitePath.some((path) => className.includes(path));
+      });
+      if (!operable) {
+        this.selectedLine = { type: '', index: -1 };
+        this.selectCell = { row: -1, column: -1 };
+        if (this.playing) this.handleParentPlay();
+        if (this.unWatch) this.unWatch();
+      }
+    },
+
+    checkboxMouseenter(isSelected, type) {
+      if (!isSelected) return this.clearSelectCell();
+      if (type === 'row') this.selectColumn = -1;
+      if (type === 'column') this.selectRow = -1;
+    },
+
+    // 选中行、列
+    selectRowOrColumn(index, type) {
+      this.handleParentPlay();
+      this.lrcArray = [];
+      this.selectCell = { row: -1, column: -1 };
+      if (this.unWatch) this.unWatch();
+      if (this.selectedLine.type === type && this.selectedLine.index === index) {
+        this.selectedLine = { type: '', index: -1 };
+        return;
+      }
+      this.selectedLine = { type, index };
+      let number = index;
+      if (type === 'column') {
+        this.data.option_list[index].forEach((item, i) => {
+          if (i >= index) return;
+        });
+      }
+      this.fileName = `第 ${number + 1} ${type === 'row' ? '行' : '列'}`;
+    },
+
+    playAudio() {
+      if (!this.hasSelectedCell) return;
+      if (this.playing) return this.handleParentPlay();
+      if (this.lrcArray.length > 0) return this.$refs.audioLine.PlayAudio();
+      if (this.unWatch) this.unWatch();
+
+      this.lrcArray = [];
+      const { type, index } = this.selectedLine;
+
+      if (type.length > 0 && index >= 0 && type === 'row') {
+        this.data.option_list[index].forEach((item) => {
+          const data = this.getLrcData(item);
+          if (data) this.lrcArray.push(data);
+        });
+        if (this.lrcArray.length > 0) this.lrcPlay(this.lrcArray[0], 0);
+        return;
+      }
+
+      if (type.length > 0 && index >= 0 && type === 'column') {
+        this.data.option_list.forEach((item) => {
+          const data = this.getLrcData(item[index]);
+          console.log(data);
+          if (data) this.lrcArray.push(data);
+        });
+        console.log(this.lrcArray);
+        if (this.lrcArray.length > 0) this.lrcPlay(this.lrcArray[0], 0);
+        return;
+      }
+
+      const { row, column } = this.selectCell;
+      if (row >= 0 && column >= 0) {
+        this.handleChangeTime(this.data.option_list[row][column].lrc_data);
+      }
+    },
+
+    lrcPlay({ begin_time, end_time }, index) {
+      this.handleParentPlay();
+      this.$nextTick(() => {
+        this.$refs.audioLine.onTimeupdateTime(begin_time / 1000);
+        this.$refs.audioLine.PlayAudio();
+        if (end_time === -1) return;
+        const end = end_time / 1000 - 0.01;
+        this.unWatch = this.$watch('curTime', (val) => {
+          if (val >= end) {
+            if (!this.hasSelectedCell) return this.unWatch();
+            this.handleParentPlay();
+            this.$refs.audioLine.onTimeupdateTime(end);
+            this.unWatch();
+            const i = index + 1;
+            if (i < this.lrcArray.length) {
+              return this.lrcPlay(this.lrcArray[i], i);
+            }
+            if (this.isRepeat) {
+              return this.lrcPlay(this.lrcArray[0], 0);
+            }
+            this.lrcArray = [];
+          }
+        });
+      });
+    },
+
+    playChange(playing) {
+      this.playing = playing;
+      // 子组件通信,同时只能播放一个音频
+      if (playing) Bus.$emit('audioPause', this.cid);
+    },
+
+    pauseOtherAudio() {
+      Bus.$emit('audioPause', this.cid);
+      this.stopAudio = true;
+    },
+
+    // 暂停音频播放
+    handleParentPlay() {
+      this.stopAudio = true;
+    },
+    // 音频播放时改变布尔值
+    handleChangeStopAudio() {
+      this.stopAudio = false;
+    },
+
+    getCurTime(curTime) {
+      this.curTime = curTime;
+    },
+
+    getWavblob(wavblob) {
+      this.wavblob = wavblob;
+    },
+
+    getSelectData({ type, index, row, column }) {
+      if (type === '') return;
+      const arr = [];
+      if (type.length > 0 && index >= 0 && type === 'row') {
+        this.data.option_list[index].forEach((item) => {
+          const data = this.getLrcData(item);
+          if (data) arr.push(data);
+        });
+        this.matrixSelectLrc = arr;
+        return;
+      }
+
+      if (type.length > 0 && index >= 0 && type === 'column') {
+        this.data.option_list.forEach((item) => {
+          const data = this.getLrcData(item[index]);
+          if (data) arr.push(data);
+        });
+        this.matrixSelectLrc = arr;
+        return;
+      }
+
+      if (type === 'cell' && row >= 0 && column >= 0) {
+        const lrcData = this.data.option_list[row][column].lrc_data;
+        if (lrcData.end_time === -1) lrcData.end_time = this.mp3Duration;
+        this.matrixSelectLrc = [lrcData];
+      }
+    },
+
+    getLrcData({ content, lrc_data }) {
+      if (content.length > 0) {
+        if (lrc_data.end_time === -1) {
+          return {
+            begin_time: lrc_data.begin_time,
+            end_time: this.mp3Duration,
+            text: lrc_data.text,
+          };
+        }
+        return lrc_data;
+      }
+      return false;
+    },
+
+    sentPause(isRecord) {
+      this.isRecord = isRecord;
+    },
+
+    handleChangeTime({ begin_time, end_time }) {
+      if (this.unWatch) this.unWatch();
+      this.handleParentPlay();
+      this.$nextTick(() => {
+        this.$refs.audioLine.onTimeupdateTime(begin_time / 1000);
+        this.$refs.audioLine.PlayAudio();
+        // 监听是否已到结束时间,为了选中效果 - 0.01
+        if (end_time === -1) return;
+        const end = end_time / 1000 - 0.01;
+        this.unWatch = this.$watch('curTime', (val) => {
+          if (val >= end) {
+            this.handleParentPlay();
+            this.$refs.audioLine.onTimeupdateTime(end);
+            this.unWatch();
+            this.unWatch = null;
+            if (this.isRepeat) {
+              this.handleChangeTime({ begin_time, end_time });
+            }
+          }
+        });
+      });
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+$select-color: #1890ff;
+$border-color: #e6e6e6;
+
+.voice-matrix-preview {
+  @include preview-base;
+
+  .main {
+    color: #262626;
+
+    .voice-matrix-audio {
+      display: flex;
+      height: 42px;
+      border: 1px solid $border-color;
+      border-radius: 8px 8px 0 0;
+
+      .audio-number {
+        padding: 12px 0 0 12px;
+
+        %serial-number,
+        .serial-number {
+          display: inline-block;
+          width: 16px;
+          height: 16px;
+          font-family: 'robot';
+          font-size: 16px;
+          line-height: 16px;
+          color: #000;
+          text-align: center;
+          border-radius: 50%;
+        }
+      }
+
+      .audio-simple {
+        display: flex;
+        flex-grow: 1;
+        align-items: center;
+        justify-content: space-between;
+        height: 100%;
+        line-height: 46px;
+
+        img {
+          width: 16px;
+          height: 16px;
+          margin-left: 12px;
+          cursor: pointer;
+        }
+
+        .Repeat-16 {
+          display: inline-block;
+          width: 16px;
+          height: 16px;
+          margin-right: 12px;
+          cursor: pointer;
+        }
+      }
+    }
+
+    // 语音矩阵
+    .voice-matrix-container {
+      height: calc(100% - 80px);
+      font-size: 16px;
+      word-break: break-word;
+      background-color: #f5f5f5;
+      border-right: 1px solid $border-color;
+      border-left: 1px solid $border-color;
+
+      .matrix {
+        display: inline-grid;
+        width: 100%;
+        height: 100%;
+
+        %matrix-checkbox {
+          position: relative;
+          top: calc(50% - 5px);
+          display: block;
+          width: 14px;
+          height: 14px;
+          margin: 0 auto;
+          cursor: pointer;
+          border: 1.5px solid #b0b0b0;
+          border-radius: 4px;
+
+          &.active {
+            border-color: $select-color;
+
+            &::after {
+              position: absolute;
+              left: 4px;
+              box-sizing: content-box;
+              width: 3px;
+              height: 7px;
+              content: '';
+              border: 1px solid $select-color;
+              border-top: 0;
+              border-left: 0;
+              transition: transform 0.15s ease-in 0.05s;
+              transform: rotate(45deg) scaleY(1);
+              transform-origin: center;
+            }
+          }
+        }
+
+        .matrix-checkbox-row-,
+        .matrix-checkbox-row-red {
+          @extend %matrix-checkbox;
+        }
+
+        %matrix-checkbox-column,
+        .matrix-checkbox-column-,
+        .matrix-checkbox-column-red {
+          @extend %matrix-checkbox;
+
+          top: calc(50% - 7px);
+          right: -2px;
+        }
+
+        .read {
+          background-color: #eaeaea;
+        }
+
+        .highlight-,
+        .highlight-red {
+          color: $select-color;
+        }
+
+        .column-wrapper {
+          padding: 4px;
+
+          %column {
+            width: 100%;
+            height: 100%;
+            min-height: 32px;
+            cursor: pointer;
+            user-select: none;
+            background-color: #fff;
+            border: 1px solid $border-color;
+            border-radius: 8px;
+            transition: 0.2s;
+
+            &:hover {
+              border-color: #8c8c8c;
+            }
+
+            &.selected {
+              color: $select-color;
+              border-color: $select-color;
+            }
+
+            &.playing {
+              background-color: rgba(24, 144, 255, 10%);
+            }
+
+            &.title {
+              background-color: transparent;
+              border-color: transparent;
+            }
+
+            > span {
+              display: inline-block;
+              padding: 4px 12px;
+              line-height: 24px;
+            }
+          }
+
+          %column-red,
+          .column-,
+          .column-red {
+            @extend %column;
+
+            position: relative;
+
+            &::before {
+              display: inline-block;
+              vertical-align: middle;
+              content: '';
+            }
+          }
+
+          %sentence,
+          .sentence-,
+          .sentence-red {
+            @extend %column;
+
+            display: inline-grid;
+            column-gap: 8px;
+            justify-content: start;
+            justify-items: center;
+            padding: 4px 12px;
+            line-height: 24px;
+
+            > span {
+              padding: 0;
+            }
+
+            .pinyin {
+              font-family: 'GB-PINYINOK-B';
+              font-size: 12px;
+              line-height: 20px;
+              opacity: 0.45;
+            }
+
+            .chs {
+              font-size: 16px;
+              line-height: 24px;
+            }
+          }
+
+          .connection {
+            position: relative;
+            top: calc(50% - 1px);
+            width: 16px;
+            height: 2px;
+            margin: 0 -4px;
+            background-color: #252525;
+            border-radius: 4px;
+
+            &.highlight-bc-,
+            &.highlight-bc-red {
+              background-color: $select-color;
+            }
+          }
+
+          // 拼音 + 文字
+          %pinyinEnglish,
+          .pinyinEnglish-,
+          .pinyinEnglish-red {
+            @extend %column;
+
+            .inside-wrapper {
+              padding: 4px 12px;
+
+              .pinyin {
+                font-family: 'GB-PINYINOK-B';
+                font-size: 16px;
+                line-height: 24px;
+              }
+
+              .english {
+                font-family: 'robot';
+                font-size: 12px;
+                line-height: 20px;
+                opacity: 0.45;
+              }
+            }
+          }
+          %textBrackets,
+          .textBrackets-,
+          .textBrackets-red {
+            @extend %column;
+
+            .brackets-text {
+              font-family: 'GB-PINYINOK-B';
+            }
+
+            .brackets {
+              font-size: 16px;
+            }
+          }
+        }
+      }
+
+      .matrix-audio {
+        width: 228px;
+        height: 40px;
+        padding: 4px 4px 4px 16px;
+        margin: 24px 24px 0 0;
+        background-color: #fff;
+        border: 1px solid $border-color;
+        border-radius: 8px;
+      }
+    }
+
+    .voice-luyin {
+      display: flex;
+      align-items: center;
+      height: 40px;
+      padding: 3px 16px;
+      border: 1px solid $border-color;
+      border-radius: 0 0 8px 8px;
+    }
+  }
+}
+
+.NNPE-tableList-tr-last {
+  .voice-matrix {
+    padding-bottom: 0;
+  }
+}
+</style>
+
+<style lang="scss">
+.voice-matrix {
+  &-audio {
+    .audioLine {
+      border-radius: 8px 8px 0 0 !important;
+    }
+
+    .el-slider {
+      width: 100% !important;
+    }
+  }
+
+  .luyin-box {
+    .el-select .el-input {
+      width: 136px;
+    }
+  }
+}
+</style>

+ 162 - 0
src/views/book/courseware/preview/components/voice_matrix/components/AudioCompareMatrix.vue

@@ -0,0 +1,162 @@
+<template>
+  <div class="compara-content">
+    <template v-if="wavblob">
+      <div
+        v-if="!isRecord"
+        :class="[type == 'full' ? 'compare-box-big' : 'compare-box']"
+        @click.stop.capture="playAudio"
+      >
+        <audio-line
+          ref="audioLine"
+          :mp3="url"
+          :get-cur-time="getCurTime"
+          audio-id="audioCompareMatrix"
+          :stop-audio="stopAudio"
+          :hide-slider="true"
+          :type="type"
+          @handleChangeStopAudio="handleChangeStopAudio"
+          @sentPause="sentPause"
+          @playChange="playChange"
+        />
+      </div>
+      <div v-else :class="[type == 'full' ? 'compare-box-big' : 'compare-box']">
+        <audio-red
+          ref="audioRed"
+          :mp3="wavblob"
+          :is-compare="true"
+          :theme-color="themeColor"
+          :type="type"
+          @sentPause="sentPause"
+        />
+      </div>
+    </template>
+    <template v-else>
+      <img
+        :src="
+          type == 'full'
+            ? require('@/assets/voice_matrix/compare-disable-24.png')
+            : require('@/assets/voice_matrix/compare-disable.png')
+        "
+        :class="['compare-disable', type == 'full' ? 'compare-disable-big' : '']"
+      />
+    </template>
+  </div>
+</template>
+
+<script>
+import AudioLine from '../components/AudioLine.vue';
+import AudioRed from '../components/AudioRed.vue';
+
+export default {
+  components: {
+    AudioLine,
+    AudioRed,
+  },
+  props: ['themeColor', 'isRecord', 'wavblob', 'url', 'type', 'sentPause', 'matrixSelectLrc', 'getCurTime', 'curTime'],
+  data() {
+    return {
+      playing: false,
+      stopAudio: true,
+      unWatch: null,
+    };
+  },
+  watch: {
+    matrixSelectLrc(newVal) {
+      if (this.unWatch) {
+        this.unWatch();
+        this.unWatch = null;
+      }
+      this.handleParentPlay();
+      this.playChange(false);
+      if (this.isRecord) this.$refs.audioRed.stopAudio();
+    },
+    isRecord(newVal) {
+      if (!newVal) this.unWatch = null;
+    },
+    wavblob(newVal) {
+      if (!newVal) {
+        this.playChange(false);
+      }
+    },
+  },
+  methods: {
+    playAudio() {
+      if (this.playing) return this.handleParentPlay();
+      if (this.unWatch) return this.$refs.audioLine.PlayAudio();
+      console.log(this.matrixSelectLrc);
+      if (this.matrixSelectLrc === null) {
+        this.sentPause(true);
+        this.playChange(false);
+        return;
+      }
+      if (this.matrixSelectLrc.length <= 0) return;
+
+      this.lrcPlay(this.matrixSelectLrc[0], 0);
+    },
+    lrcPlay({ begin_time, end_time }, index) {
+      this.handleParentPlay();
+      this.$nextTick(() => {
+        this.$refs.audioLine.onTimeupdateTime(begin_time / 1000);
+        this.$refs.audioLine.PlayAudio();
+        if (end_time === -1) return;
+        let end = end_time / 1000 - 0.01;
+        this.unWatch = this.$watch('curTime', (val) => {
+          if (val >= end) {
+            this.unWatch();
+            this.handleParentPlay();
+            this.$refs.audioLine.onTimeupdateTime(end);
+            let i = index + 1;
+            if (i < this.matrixSelectLrc.length) {
+              return this.lrcPlay(this.matrixSelectLrc[i], i);
+            }
+            this.sentPause(true);
+            this.playChange(false);
+          }
+        });
+      });
+    },
+
+    playChange(playing) {
+      this.playing = playing;
+      this.$emit('playing', playing);
+    },
+
+    // 暂停音频播放
+    handleParentPlay() {
+      this.stopAudio = true;
+    },
+    // 音频播放时改变布尔值
+    handleChangeStopAudio() {
+      this.stopAudio = false;
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.compare-box {
+  width: 16px;
+  height: 16px;
+  margin-left: 8px;
+
+  &-big {
+    width: 24px;
+    height: 24px;
+    margin-left: 0;
+  }
+}
+
+.compare-disable {
+  display: block;
+  width: 16px;
+  height: 16px;
+  margin-left: 8px;
+
+  &-big {
+    display: block;
+    width: 24px;
+    height: 24px;
+    margin-left: 0;
+  }
+}
+</style>

+ 477 - 0
src/views/book/courseware/preview/components/voice_matrix/components/AudioLine.vue

@@ -0,0 +1,477 @@
+<!-- eslint-disable vue/no-v-html -->
+<template>
+  <div :class="['AudioNNPE', mp3Source && mp3Source == 'tts' ? 'Audio-tts' : '']">
+    <div v-if="!hideSlider" class="audioLine">
+      <div class="playBox" @click="PlayAudio">
+        <div class="play" :class="[audio.loading ? 'loadBtn' : audio.playing ? 'playBtn' : 'pauseBtn']"></div>
+      </div>
+
+      <template v-if="!isRepeat">
+        <el-slider
+          v-model="playValue"
+          :style="{ width: sliderWidth + 'px', height: '2px' }"
+          :format-tooltip="formatProcessToolTip"
+          @change="changeCurrentTime"
+        />
+        <span
+          ><template v-if="audio.playing">-</template
+          >{{ audio.maxTime ? realFormatSecond(audio.maxTime - audio.currentTime) : '' }}</span
+        >
+      </template>
+      <el-popover
+        v-if="audioData && (audioData.script || audioData.translation)"
+        class="NNPE-popover"
+        placement="bottom"
+        width="446"
+        trigger="hover"
+        @show="showPopover(1)"
+        @hide="hidePopover"
+      >
+        <template slot="reference">
+          <img v-if="activeIndex == 1" class="icon_popover" src="@/assets/voice_matrix/icon-popover-actiive.png" />
+          <img v-else class="icon_popover" src="@/assets/voice_matrix/icon-popover.png" />
+        </template>
+        <div class="popover-content">
+          <div v-if="activeContent == 1" class="popover-script">
+            {{ audioData.script }}
+            <div class="tiny-boxpopover-script" v-html="audioData.tinyscript"></div>
+          </div>
+          <div v-if="activeContent == 2" class="popover-script popover-translation">
+            {{ audioData.translation }}
+            <div class="tiny-boxpopover-script" v-html="audioData.tinytranslation"></div>
+          </div>
+          <div class="popover-bottom">
+            <a :class="[activeContent == 1 ? 'active' : '']" @click="activeContent = 1">脚本</a>
+            <a :class="[activeContent == 2 ? 'active' : '']" @click="activeContent = 2">译文</a>
+          </div>
+        </div>
+      </el-popover>
+    </div>
+    <div v-else class="audioLine2">
+      <div
+        class="play-icon"
+        :class="[
+          audio.loading ? 'loadBtn' : audio.playing ? 'playBtn-icon' : 'pauseBtn-icon',
+          type == 'full' ? 'play-icon-big' : '',
+        ]"
+        @click="PlayAudio"
+      ></div>
+      <el-popover
+        v-if="audioData && (audioData.script || audioData.translation)"
+        class="NNPE-popover"
+        placement="bottom"
+        width="446"
+        trigger="hover"
+        @show="showPopover(1)"
+        @hide="hidePopover"
+      >
+        <template slot="reference">
+          <img v-if="activeIndex == 1" class="icon_popover" src="@/assets/voice_matrix/icon-popover-actiive.png" />
+          <img v-else class="icon_popover" src="@/assets/voice_matrix/icon-popover.png" />
+        </template>
+        <div class="popover-content">
+          <div v-if="activeContent == 1" class="popover-script">
+            {{ audioData.script }}
+          </div>
+          <div v-if="activeContent == 2" class="popover-script popover-translation">
+            {{ audioData.translation }}
+          </div>
+          <div class="popover-bottom">
+            <a :class="[activeContent == 1 ? 'active' : '']" @click="activeContent = 1">脚本</a>
+            <a :class="[activeContent == 2 ? 'active' : '']" @click="activeContent = 2">译文</a>
+          </div>
+        </div>
+      </el-popover>
+    </div>
+    <audio
+      :id="audioId"
+      :ref="audioId"
+      :src="mp3"
+      preload="meta"
+      @loadedmetadata="onLoadedmetadata"
+      @timeupdate="onTimeupdate"
+      @canplaythrough="oncanplaythrough"
+    ></audio>
+  </div>
+</template>
+
+<script>
+export default {
+  props: [
+    'mp3',
+    'mp3Source',
+    'getCurTime',
+    'stopAudio',
+    'width',
+    'isRepeat',
+    'themeColor',
+    'hideSlider',
+    'ed',
+    'bg',
+    'audioId',
+    'type',
+    'audioData',
+  ],
+  data() {
+    // 这里存放数据
+    return {
+      playValue: 0,
+      audio: {
+        // 该字段是音频是否处于播放状态的属性
+        playing: false,
+        // 音频当前播放时长
+        currentTime: 0,
+        // 音频最大播放时长
+        maxTime: 0,
+        isPlaying: false,
+        loading: false,
+      },
+      audioAllTime: null, // 展示总时间
+      duioCurrentTime: null, // 剩余时间
+      count: 0,
+      isClick: false,
+      activeIndex: null,
+      activeContent: 1,
+    };
+  },
+  // 计算属性 类似于data概念
+  computed: {
+    sliderWidth() {
+      let width = 0;
+      if (this.width) {
+        width = this.width;
+      } else {
+        width = 662;
+      }
+      return width;
+    },
+  },
+  // 监控data中数据变化
+  watch: {
+    stopAudio: {
+      handler(val) {
+        if (val) {
+          this.$refs[this.audioId].pause();
+          this.audio.playing = false;
+        }
+      },
+      // 深度观察监听
+      deep: true,
+    },
+    'audio.playing': {
+      handler(val) {
+        this.$emit('playChange', val);
+        if (val) this.$emit('handleChangeStopAudio');
+      },
+    },
+  },
+  mounted() {
+    let audioId = this.audioId;
+    this.$refs[audioId].addEventListener('loadstart', () => {});
+    this.$refs[audioId].addEventListener('play', () => {
+      this.audio.playing = true;
+      this.audio.isPlaying = true;
+      this.audio.loading = false;
+    });
+    this.$refs[audioId].addEventListener('pause', () => {
+      this.audio.playing = false;
+      if (this.hideSlider && this.audio.currentTime * 1000 + 500 > this.ed) {
+        this.$emit('sentPause', true);
+      }
+      this.$emit('handleListenRead', false);
+    });
+    this.$refs[audioId].addEventListener('ended', () => {
+      this.audio.playing = false;
+      this.audio.isPlaying = false;
+      this.isClick = false;
+      this.$emit('handleListenRead', false);
+    });
+
+    this.$nextTick(() => {
+      if (
+        document.getElementsByClassName('el-slider__button-wrapper') &&
+        document.getElementsByClassName('el-slider__button-wrapper')[0]
+      ) {
+        document.getElementsByClassName('el-slider__button-wrapper')[0].addEventListener('mousedown', () => {
+          this.$refs[audioId].pause();
+          this.audio.playing = false;
+        });
+      }
+    });
+  },
+  methods: {
+    PlayAudio() {
+      let audioId = this.audioId;
+      let audio = document.getElementsByTagName('audio');
+      if (audio && audio.length > 0) {
+        Array.from(audio).forEach((item) => {
+          if (item.src === this.mp3) {
+            if (item.id !== audioId) {
+              item.pause();
+            }
+          } else {
+            item.pause();
+          }
+        });
+      }
+      if (this.audio.playing) {
+        this.$refs[audioId].pause();
+        this.audio.playing = false;
+        this.$emit('handleListenRead', false);
+        this.isClick = false;
+      } else {
+        if (this.count === 0) {
+          this.audio.loading = true;
+          this.count += 1;
+        }
+        if (this.hideSlider) {
+          this.$refs[audioId].play();
+          this.onTimeupdateTime(this.bg / 1000);
+        } else {
+          this.$refs[audioId].pause();
+          this.$refs[audioId].play();
+        }
+
+        this.$emit('handleChangeStopAudio');
+        this.$emit('handleListenRead', true);
+        this.isClick = true;
+      }
+    },
+    oncanplaythrough() {
+      this.audio.loading = false;
+    },
+    // 点击 拖拽播放音频
+    changeCurrentTime(value) {
+      let audioId = this.audioId;
+      this.$refs[audioId].play();
+      this.audio.playing = true;
+      this.$refs[audioId].currentTime = parseInt((value / 100) * this.audio.maxTime);
+    },
+    mousedown() {
+      let audioId = this.audioId;
+      this.$refs[audioId].pause();
+      this.audio.playing = false;
+    },
+    // 进度条格式化toolTip
+    formatProcessToolTip(index) {
+      return this.realFormatSecond(parseInt((this.audio.maxTime / 100) * index));
+    },
+    // 音频加载完之后
+    onLoadedmetadata(res) {
+      this.audio.maxTime = parseInt(res.target.duration);
+      this.audioAllTime = this.realFormatSecond(this.audio.maxTime);
+    },
+    // 当音频当前时间改变后,进度条也要改变
+    onTimeupdate(res) {
+      let audioId = this.audioId;
+      this.audio.currentTime = res.target.currentTime;
+      this.getCurTime(res.target.currentTime);
+      this.playValue = (this.audio.currentTime / this.audio.maxTime) * 100;
+      if (this.type === 'audioLine') {
+        setTimeout(() => {
+          if (!this.isClick && this.audio.currentTime * 1000 > this.ed) {
+            if (this.$refs[audioId]) {
+              this.$refs[audioId].pause();
+              this.$emit('emptyEd');
+            }
+          }
+        }, 50);
+      } else if (this.hideSlider) {
+        if (this.audio.currentTime * 1000 + 500 > this.ed) {
+          this.$refs[audioId].pause();
+        }
+      }
+    },
+    onTimeupdateTime(res, playFlag) {
+      if (!res && res !== 0) return;
+      let audioId = this.audioId;
+      this.$refs[audioId].currentTime = res;
+      this.playValue = (res / this.audio.maxTime) * 100;
+      if (playFlag) {
+        let audio = document.getElementsByTagName('audio');
+        if (audio && audio.length > 0 && window.location.href.indexOf('GCLS-Learn') === -1) {
+          audio.forEach((item) => {
+            if (item.id !== audioId) {
+              item.pause();
+            }
+          });
+        }
+        this.$refs[audioId].play();
+      }
+    },
+    // 将整数转换成 时:分:秒的格式
+    realFormatSecond(value) {
+      let theTime = parseInt(value); // 秒
+      let theTime1 = 0; // 分
+      let theTime2 = 0; // 小时
+      if (theTime > 60) {
+        theTime1 = parseInt(theTime / 60);
+        theTime = parseInt(theTime % 60);
+        if (theTime1 > 60) {
+          theTime2 = parseInt(theTime1 / 60);
+          theTime1 = parseInt(theTime1 % 60);
+        }
+      }
+      let result = String(parseInt(theTime));
+      if (result < 10) {
+        result = `0${result}`;
+      }
+      if (theTime1 > 0) {
+        result = `${String(parseInt(theTime1))}:${result}`;
+        if (theTime1 < 10) {
+          result = `0${result}`;
+        }
+      } else {
+        result = `00:${result}`;
+      }
+      if (theTime2 > 0) {
+        result = `${String(parseInt(theTime2))}:${result}`;
+        if (theTime2 < 10) {
+          result = `0${result}`;
+        }
+      } else {
+        // result = "00:" + result;
+      }
+      return result;
+    },
+    showPopover(e) {
+      this.activeIndex = e;
+    },
+    hidePopover() {
+      this.activeIndex = null;
+      this.activeContent = 1;
+    },
+  },
+};
+</script>
+<style lang="scss" scoped>
+.AudioNNPE {
+  width: 100%;
+
+  .audioLine {
+    box-sizing: border-box;
+    display: flex;
+    align-items: center;
+    width: 100%;
+    height: 40px;
+    background: #fff;
+    border-radius: 8px;
+
+    .playBox {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      min-width: 40px;
+      height: 40px;
+      margin-right: 7px;
+      cursor: pointer;
+    }
+
+    .play {
+      display: block;
+      width: 16px;
+      height: 16px;
+      cursor: pointer;
+
+      &.playBtn {
+        background: url('@/assets/voice_matrix/icon-pause-blue.png') no-repeat left top;
+        background-size: 100% 100%;
+      }
+
+      &.pauseBtn {
+        background: url('@/assets/voice_matrix/icon-play-blue.png') no-repeat left top;
+        background-size: 100% 100%;
+      }
+    }
+
+    span {
+      min-width: 56px;
+      margin-right: 12px;
+      margin-left: 8px;
+      font-size: 16px;
+      line-height: 19px;
+      color: #000;
+      text-align: right;
+    }
+  }
+
+  .audioLine2 {
+    .play-icon {
+      width: 16px;
+      height: 16px;
+      cursor: pointer;
+
+      &-big {
+        width: 24px;
+        height: 24px;
+      }
+
+      &.playBtn-icon {
+        background: url('@/assets/voice_matrix/pauseC-16-normal-blue.png') no-repeat left top;
+        background-size: 100% 100%;
+      }
+
+      &.pauseBtn-icon {
+        background: url('@/assets/voice_matrix/compare-pause-blue.png') no-repeat left top;
+        background-size: 100% 100%;
+      }
+    }
+  }
+
+  .loadBtn {
+    background: url('@/assets/voice_matrix/loading-blue.png') no-repeat left top;
+    background-size: 100% 100%;
+  }
+
+  &.Audio-tts {
+    .audioLine {
+      .playBtn {
+        background: url('@/assets/voice_matrix/tts-play-blue.png') no-repeat left top;
+        background-size: 100% 100%;
+      }
+
+      .pauseBtn {
+        background: url('@/assets/voice_matrix/tts-blue.png') no-repeat left top;
+        background-size: 100% 100%;
+      }
+    }
+  }
+}
+</style>
+<style lang="scss">
+.AudioNNPE {
+  .el-slider__button-wrapper {
+    position: relative;
+    z-index: 0;
+  }
+
+  .el-slider__button {
+    position: absolute;
+    top: 12px;
+    width: 8px;
+    height: 8px;
+  }
+
+  .el-slider__runway {
+    height: 2px;
+    padding: 0;
+    margin: 0;
+    background: #e5e5e5;
+    border-radius: 0;
+  }
+
+  .el-slider {
+    position: relative;
+  }
+
+  .el-slider__bar {
+    height: 2px;
+    background: #1890ff;
+  }
+
+  .el-slider__button {
+    background: #1890ff;
+    border: none;
+  }
+}
+</style>

+ 115 - 0
src/views/book/courseware/preview/components/voice_matrix/components/AudioRed.vue

@@ -0,0 +1,115 @@
+<!--  -->
+<template>
+  <div v-if="mp3" class="content-voices" @click="handlePlayVoice">
+    <img :src="voiceSrc" :class="type == 'full' ? 'icon-big' : ''" />
+  </div>
+</template>
+
+<script>
+export default {
+  components: {},
+  props: ['seconds', 'mp3', 'wav', 'isCompare', 'type'],
+  data() {
+    return {
+      audio: new Audio(),
+      voiceSrc: '',
+      voicePauseSrc: require('@/assets/voice_matrix/icon-voice.png'),
+      voicePlaySrc: require('@/assets/voice_matrix/icon-voice-play-blue.png'),
+    };
+  },
+  computed: {
+    comparePauseSrc() {
+      return require('@/assets/voice_matrix/compare-pause-blue.png');
+    },
+    comparePlaySrc() {
+      return require('@/assets/voice_matrix/pauseC-16-normal-blue.png');
+    },
+  },
+  created() {
+    window.stopAudioVoice = () => {
+      if (this.audio) {
+        this.audio.pause();
+      }
+    };
+  },
+  mounted() {
+    this.voiceSrc = this.isCompare ? this.comparePlaySrc : this.voicePauseSrc;
+    if (this.isCompare) {
+      this.audio.pause();
+      this.audio.load();
+      this.audio.src = this.mp3;
+      this.audio.loop = false;
+      this.audio.play();
+    }
+    this.audio.addEventListener('play', () => {
+      this.voiceSrc = this.isCompare ? this.comparePlaySrc : this.voicePlaySrc;
+      this.$emit('getPlayStatus', true);
+    });
+    this.audio.addEventListener('pause', () => {
+      this.voiceSrc = this.isCompare ? this.comparePauseSrc : this.voicePauseSrc;
+      this.$emit('getPlayStatus', false);
+    });
+    this.audio.addEventListener('ended', () => {
+      this.voiceSrc = this.isCompare ? this.comparePauseSrc : this.voicePauseSrc;
+      this.$emit('sentPause', false);
+      this.$emit('getPlayStatus', false);
+    });
+  },
+  // 方法集合
+  methods: {
+    handlePlayVoice() {
+      if (!this.audio.paused) {
+        this.audio.pause();
+      } else if (this.isCompare) {
+        this.audio.pause();
+        this.audio.play();
+      } else {
+        this.audio.pause();
+        this.audio.load();
+        this.audio.src = this.mp3;
+        this.audio.loop = false;
+        this.audio.play();
+      }
+    },
+    stopAudio() {
+      if (this.audio) {
+        this.audio.pause();
+      }
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.content-voices {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  font-size: 0;
+  cursor: pointer;
+
+  span {
+    float: left;
+    font-family: 'sourceR';
+    font-size: 24px;
+    line-height: 30px;
+    color: #2c2c2c;
+
+    &.noMp3 {
+      margin-left: 0;
+    }
+  }
+
+  img {
+    float: left;
+    width: 16px;
+    height: 16px;
+  }
+
+  .icon-big {
+    width: 24px;
+    height: 24px;
+  }
+}
+</style>

+ 2 - 0
src/views/book/courseware/preview/components/voice_matrix/components/Bus.js

@@ -0,0 +1,2 @@
+import Vue from 'vue';
+export default new Vue();

+ 425 - 0
src/views/book/courseware/preview/components/voice_matrix/components/SoundRecord.vue

@@ -0,0 +1,425 @@
+<template>
+  <div class="NNPE-Book-record">
+    <template v-if="(type && type == 'normal') || (type && type == 'mini')">
+      <template v-if="!TaskModel || TaskModel != 'ANSWER'">
+        <div :class="['record', microphoneStatus ? 'active' : '']" @click="microphone"></div>
+      </template>
+      <template v-else>
+        <div :class="['record']"></div>
+      </template>
+      <span
+        v-if="type && type == 'normal'"
+        :class="[
+          'record-time',
+          microphoneStatus ? 'record-ing' : '',
+          selectIndex || selectIndex == 0 ? 'record-black' : '',
+          type && type == 'normal' ? 'record-time-flex' : '',
+        ]"
+        >{{ isPlaying ? '-' : '' }}{{ handleDateTime(recordtime) }}</span
+      >
+      <div v-if="type && type == 'normal'" class="line"></div>
+      <div
+        :class="['playBack', hasMicro]"
+        @click="playmicrophone(selectIndex || selectIndex == 0 ? recordList[selectIndex].toltime : '')"
+      ></div>
+    </template>
+    <template v-else-if="type && type == 'pro'">
+      <template v-if="!TaskModel || TaskModel != 'ANSWER'">
+        <div :class="['record', microphoneStatus ? 'active' : '']" @click="microphone"></div>
+      </template>
+      <template v-else>
+        <div :class="['record']"></div>
+      </template>
+      <el-select v-model="selectIndex" placeholder="无录音" class="proSelect" @change="handleChangeRecord">
+        <el-option v-for="(item, i) in recordList" :key="i + item.id" :label="item.name" :value="i" />
+      </el-select>
+      <div
+        :class="['playBack', hasMicro]"
+        @click="playmicrophone(selectIndex || selectIndex == 0 ? recordList[selectIndex].toltime : '')"
+      ></div>
+      <a :class="['record-delete', hasMicro ? 'record-delete-has' : '']" @click="handleDelete"></a>
+    </template>
+    <template v-else>
+      <template v-if="!TaskModel || TaskModel != 'ANSWER'">
+        <div :class="['record', microphoneStatus ? 'active' : '']" @click="microphone"></div>
+      </template>
+      <template v-else>
+        <div :class="['record']"></div>
+      </template>
+      <span
+        :class="[
+          'record-time',
+          microphoneStatus ? 'record-ing' : '',
+          selectIndex || selectIndex == 0 ? 'record-black' : '',
+        ]"
+        >{{ isPlaying ? '-' : '' }}{{ handleDateTime(recordtime) }}</span
+      >
+      <el-select v-model="selectIndex" placeholder="无录音" @change="handleChangeRecord">
+        <el-option v-for="(item, i) in recordList" :key="i + item.id" :label="item.name" :value="i" />
+      </el-select>
+      <div
+        :class="['playBack', hasMicro]"
+        @click="playmicrophone(selectIndex || selectIndex == 0 ? recordList[selectIndex].toltime : '')"
+      ></div>
+      <a :class="['record-delete', hasMicro ? 'record-delete-has' : '']" @click="handleDelete"></a>
+    </template>
+  </div>
+</template>
+
+<script>
+import Recorder from 'js-audio-recorder'; // 录音插件
+
+export default {
+  components: {},
+  props: ['wavData', 'type', 'fileName', 'selectData', 'answerRecordList', 'index', 'TaskModel', 'tmIndex'],
+  data() {
+    return {
+      recorder: new Recorder({
+        sampleBits: 16, // 采样位数,支持 8 或 16,默认是16
+        sampleRate: 16000, // 采样率,支持 11025、16000、22050、24000、44100、48000,根据浏览器默认值,我的chrome是48000
+        numChannels: 1, // 声道,支持 1 或 2, 默认是1
+      }),
+      microphoneStatus: false,
+      hasMicro: '', // 录音后的样式class
+      wavblob: null,
+      audio: new window.Audio(),
+      recordList: [], // 录音文件数组
+      recordtime: 0, // 录音时长
+      timer: null, // 计时器
+      recordFile: 1, // 录音文件名
+      selectIndex: null, // 选中的录音索引
+      oldIndex: null, // 存储播放录音索引
+      playtime: 0, // 播放时间
+      isPlaying: false,
+    };
+  },
+  watch: {
+    answerRecordList(newVal) {
+      this.recordList = JSON.parse(JSON.stringify(newVal));
+      this.oldIndex = null;
+      this.selectIndex = this.recordList.length > 0 ? this.recordList.length - 1 : null;
+    },
+  },
+  created() {
+    this.handleActive();
+    window.stopAudioSound = () => {
+      if (this.audio) {
+        this.audio.pause();
+      }
+    };
+  },
+  mounted() {
+    this.recordList = this.answerRecordList ? JSON.parse(JSON.stringify(this.answerRecordList)) : [];
+    if (this.recordList.length > 0) {
+      this.selectIndex = 0;
+      this.$emit('getSelectData', this.recordList[0].selectData);
+      this.recordFile = this.recordList.length + 1;
+      this.handleChangeRecord(0);
+    }
+    this.audio.addEventListener('play', () => {
+      this.changeStatus('active');
+      this.isPlaying = true;
+    });
+    this.audio.addEventListener('pause', () => {
+      this.changeStatus('normal');
+    });
+    this.audio.addEventListener('ended', () => {
+      this.changeStatus('normal');
+      this.isPlaying = false;
+    });
+  },
+  beforeDestroy() {
+    this.audio.pause();
+  }, // 生命周期 - 销毁之前
+  methods: {
+    // 开始录音
+    microphone() {
+      if (!this.TaskModel || this.TaskModel !== 'ANSWER') {
+        if (this.microphoneStatus) {
+          this.hasMicro = 'normal';
+          this.recorder.stop();
+          this.$root.isRecording = false;
+          clearInterval(this.timer);
+          let toltime = this.recorder.duration; // 录音总时长
+          let fileSize = this.recorder.fileSize; // 录音总大小
+          // 录音结束,获取取录音数据
+          let wav = this.recorder.getWAVBlob(); // 获取 WAV 数据
+          // this.wavblob = wav;
+          this.microphoneStatus = false;
+          let reader = new window.FileReader();
+          reader.readAsDataURL(wav);
+          reader.onloadend = () => {
+            this.recordList[this.selectIndex].wavData = reader.result;
+            this.recordList[this.selectIndex].toltime = Math.floor(toltime);
+            this.recordList[this.selectIndex].fileSize = fileSize;
+            this.wavblob = this.recordList[this.selectIndex].wavData;
+            this.$emit('getWavblob', this.wavblob);
+            this.$emit('handleWav', JSON.parse(JSON.stringify(this.recordList)), this.tmIndex, this.index, this.indexs);
+            if (this.recordList[this.selectIndex].selectData) {
+              this.$emit('getSelectData', this.recordList[this.selectIndex].selectData);
+            }
+          };
+        } else {
+          this.hasMicro = '';
+          this.$root.isRecording = true;
+          this.$emit('getWavblob', null);
+          this.$emit('getSelectData', { type: '' });
+          // 开始录音
+          this.recorder.start();
+          this.microphoneStatus = true;
+          this.recordtime = 0;
+          this.isPlaying = false;
+          clearInterval(this.timer);
+          this.timer = setInterval(() => {
+            this.recordtime += 1;
+          }, 1000);
+          this.$emit('handleParentPlay');
+          let obj = {
+            name: this.fileName ? this.fileName + this.recordFile : `新录音${this.recordFile}`,
+            id: (this.recordFile + Math.round(Math.random() * 10)).toString(),
+          };
+          if (this.selectData) obj.selectData = this.selectData;
+          this.recordList.push(obj);
+          this.recordFile += 1;
+          this.selectIndex = this.recordList.length - 1;
+        }
+      }
+    },
+    playmicrophone(totalTimes) {
+      if (this.hasMicro) {
+        this.isPlaying = true;
+        if (this.selectIndex || this.selectIndex === 0) {
+          let totalTime = totalTimes;
+          if (!this.audio.paused) {
+            this.audio.pause();
+            clearInterval(this.timer);
+          } else if (this.audio.paused && this.oldIndex === this.selectIndex) {
+            this.audio.play();
+            if (this.recordtime === 0) {
+              this.recordtime = totalTimes;
+              this.playtime = 0;
+            }
+            this.timer = setInterval(() => {
+              if (this.playtime < totalTime) {
+                this.playtime += 1;
+                this.recordtime = totalTime - this.playtime;
+              } else {
+                clearInterval(this.timer);
+              }
+            }, 1000);
+          } else {
+            this.audio.pause();
+            this.audio.load();
+            this.audio.src = this.wavblob;
+            this.oldIndex = this.selectIndex;
+            this.audio.play();
+            this.playtime = 0;
+            this.recordtime = totalTime;
+            clearInterval(this.timer);
+            this.timer = setInterval(() => {
+              if (this.playtime < totalTime) {
+                this.playtime += 1;
+                this.recordtime = totalTime - this.playtime;
+              } else {
+                clearInterval(this.timer);
+              }
+            }, 1000);
+          }
+        }
+      }
+    },
+    // 高亮初始值
+    handleActive() {
+      if (this.wavData) {
+        this.hasMicro = 'normal';
+      } else {
+        this.hasMicro = '';
+      }
+    },
+    changeStatus(status) {
+      this.hasMicro = status;
+    },
+    // 格式化录音时长
+    handleDateTime(time) {
+      let _time = '';
+      if (parseInt(time / 60) < 10) {
+        _time = `${`0${parseInt(time / 60)}`.substring(`0${parseInt(time / 60)}`.length - 2)}:${`0${
+          time % 60
+        }`.substring(`0${time % 60}`.length - 2)}`;
+      } else {
+        _time = `${parseInt(time / 60)}:${`0${time % 60}`.substring(`0${time % 60}`.length - 2)}`;
+      }
+      return _time;
+    },
+    handleChangeRecord(index) {
+      this.recordtime = this.recordList[index].toltime;
+      this.wavblob = this.recordList[index].wavData;
+      this.hasMicro = 'normal';
+      clearInterval(this.timer);
+      this.audio.pause();
+      this.oldIndex = null;
+      this.$emit('getWavblob', this.wavblob);
+      if (this.recordList[index].selectData) this.$emit('getSelectData', this.recordList[index].selectData);
+      this.$emit('sentPause', false);
+    },
+    handleDelete() {
+      if (this.hasMicro && (!this.TaskModel || this.TaskModel !== 'ANSWER')) {
+        if (this.selectIndex || this.selectIndex === 0) {
+          this.recordList.splice(this.selectIndex, 1);
+          this.$emit('handleWav', JSON.parse(JSON.stringify(this.recordList)), this.tmIndex, this.index, this.indexs);
+          this.selectIndex = this.recordList.length > 0 ? this.recordList.length - 1 : null;
+
+          this.hasMicro = this.recordList.length > 0 ? 'normal' : '';
+          this.recordtime = this.recordList.length > 0 ? this.recordList[this.selectIndex].toltime : 0;
+          this.audio.pause();
+          this.audio = new window.Audio();
+          this.wavblob = this.recordList.length > 0 ? this.recordList[this.selectIndex].wavData : null;
+          this.oldIndex = null;
+          this.isPlaying = false;
+          clearInterval(this.timer);
+          this.audio.addEventListener('ended', () => {
+            this.changeStatus('normal');
+            this.isPlaying = false;
+          });
+        }
+      }
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.NNPE-Book-record {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 32px;
+
+  .playBack {
+    width: 16px;
+    height: 16px;
+    margin-left: 8px;
+    cursor: pointer;
+    background: url('@/assets/voice_matrix/luyin-play.png') center no-repeat;
+    background-size: 100%;
+
+    &.normal {
+      background: url('@/assets/voice_matrix/luyin-play-active.png') center no-repeat;
+      background-size: 100%;
+    }
+
+    &.active {
+      background: url('@/assets/voice_matrix/luyin-play-stop.png') center no-repeat;
+      background-size: 100%;
+    }
+  }
+
+  .line {
+    width: 1px;
+    height: 16px;
+    margin-left: 8px;
+    background: rgba(0, 0, 0, 85%);
+    opacity: 0.2;
+  }
+
+  .record {
+    width: 16px;
+    height: 16px;
+    cursor: pointer;
+    background: url('@/assets/voice_matrix/luyin.png') center no-repeat;
+    background-size: 100%;
+
+    &.active {
+      background: url('@/assets/voice_matrix/luyin-active.png') center no-repeat;
+      background-size: 100%;
+    }
+
+    &.active:hover {
+      background: url('@/assets/voice_matrix/luyin-stop.png') center no-repeat;
+      background-size: 100%;
+    }
+  }
+
+  .record-time {
+    margin-left: 8px;
+    font-family: 'robot';
+    font-size: 16px;
+    line-height: 150%;
+    color: rgba(0, 0, 0, 30%);
+
+    &.record-black {
+      color: #000;
+    }
+
+    &.record-ing {
+      color: #de4444;
+    }
+
+    &.record-time-flex {
+      flex: 1;
+    }
+  }
+
+  .el-select {
+    margin-left: 8px;
+  }
+
+  .record-delete {
+    display: block;
+    width: 16px;
+    height: 16px;
+    margin-left: 8px;
+    background: url('@/assets/voice_matrix/luyin-delete.png') center no-repeat;
+    background-size: 100%;
+
+    &.record-delete-has {
+      &:hover {
+        background: url('@/assets/voice_matrix/luyin-delete-active.png') center no-repeat;
+        background-size: 100%;
+      }
+    }
+  }
+}
+</style>
+
+<style lang="scss">
+.NNPE-Book-record {
+  .el-select {
+    flex: 1;
+    height: 24px;
+
+    &.proSelect {
+      width: 78px;
+    }
+
+    .el-input__inner {
+      box-sizing: border-box;
+      height: 24px;
+      padding: 0 11px;
+      font-family: 'Smartisan';
+      font-size: 14px;
+      line-height: 22px;
+      color: rgba(0, 0, 0, 85%);
+      border: 1px solid rgba(0, 0, 0, 10%);
+      border-radius: 4px;
+    }
+
+    .el-input {
+      .el-select__caret {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        color: #000;
+      }
+    }
+
+    .el-input__icon {
+      width: 16px;
+    }
+
+    .el-input__suffix {
+      right: 3px;
+    }
+  }
+}
+</style>

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

@@ -306,7 +306,8 @@ export default {
     }
 
     .content {
-      margin-top: 24px;
+      width: 1106px;
+      margin: 24px auto 0;
       background-color: #fff;
       border: 3px solid #f44444;
       border-radius: 16px;

+ 1 - 1
src/views/home/index.vue

@@ -225,7 +225,7 @@ export default {
         type: 'warning',
       })
         .then(() => {
-          DeleteBook({ book_id: id, is_force_delete: 'true' }).then(() => {
+          DeleteBook({ id, is_force_delete: 'true' }).then(() => {
             this.pageQueryBookList();
           });
         })