Browse Source

填空题

dusenyao 11 months ago
parent
commit
9bfc3f745f
28 changed files with 1274 additions and 64 deletions
  1. BIN
      src/assets/fill/audio-pause.png
  2. BIN
      src/assets/fill/record-ing.png
  3. BIN
      src/assets/fill/voice-pause-red.png
  4. BIN
      src/assets/fill/voice-play-gray.png
  5. BIN
      src/assets/fill/voice-play-red.png
  6. 5 0
      src/icons/svg/delete-back-line.svg
  7. 5 0
      src/icons/svg/mic-line.svg
  8. 15 0
      src/icons/svg/upload.svg
  9. 5 0
      src/icons/svg/voiceprint-line.svg
  10. 1 0
      src/main.js
  11. BIN
      src/styles/font/GB PINYINOK-B.TTF
  12. 4 0
      src/styles/font/font.css
  13. 2 1
      src/views/book/courseware/create/components/common/ModuleMixin.js
  14. 237 0
      src/views/book/courseware/create/components/question/fill/Fill.vue
  15. 94 0
      src/views/book/courseware/create/components/question/fill/FillSetting.vue
  16. 232 0
      src/views/book/courseware/create/components/question/fill/components/SoundRecord.vue
  17. 141 0
      src/views/book/courseware/create/components/question/fill/components/UploadAudio.vue
  18. 25 7
      src/views/book/courseware/create/components/question/matching/MatchingSetting.vue
  19. 11 0
      src/views/book/courseware/data/bookType.js
  20. 72 0
      src/views/book/courseware/data/common.js
  21. 66 0
      src/views/book/courseware/data/fill.js
  22. 9 1
      src/views/book/courseware/data/matching.js
  23. 50 28
      src/views/book/courseware/preview/common/SoundRecord.vue
  24. 131 0
      src/views/book/courseware/preview/components/fill/FillPreview.vue
  25. 134 0
      src/views/book/courseware/preview/components/fill/components/AudioFillPlay.vue
  26. 33 20
      src/views/book/courseware/preview/components/matching/MatchingPreview.vue
  27. 1 7
      src/views/book/courseware/preview/components/voice_matrix/VoiceMatrixPreview.vue
  28. 1 0
      src/views/home/index.vue

BIN
src/assets/fill/audio-pause.png


BIN
src/assets/fill/record-ing.png


BIN
src/assets/fill/voice-pause-red.png


BIN
src/assets/fill/voice-play-gray.png


BIN
src/assets/fill/voice-play-red.png


+ 5 - 0
src/icons/svg/delete-back-line.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="delete-back-2-line">
+<path id="Vector" d="M4.3565 2H13.9997C14.3679 2 14.6664 2.29848 14.6664 2.66667V13.3333C14.6664 13.7015 14.3679 14 13.9997 14H4.3565C4.1336 14 3.92544 13.8886 3.8018 13.7031L0.246244 8.3698C0.0969552 8.14587 0.0969552 7.85413 0.246244 7.6302L3.8018 2.29687C3.92544 2.1114 4.1336 2 4.3565 2ZM4.71329 3.33333L1.60218 8L4.71329 12.6667H13.333V3.33333H4.71329ZM8.66636 7.0572L10.552 5.17157L11.4948 6.11438L9.60916 8L11.4948 9.8856L10.552 10.8284L8.66636 8.9428L6.78076 10.8284L5.83795 9.8856L7.72356 8L5.83795 6.11438L6.78076 5.17157L8.66636 7.0572Z" fill="currentColor"/>
+</g>
+</svg>

+ 5 - 0
src/icons/svg/mic-line.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="mic-line">
+<path id="Vector" d="M8.00003 2.00033C6.89543 2.00033 6 2.89576 6 4.00033V6.66699C6 7.77159 6.89543 8.66699 8.00003 8.66699C9.10456 8.66699 10 7.77159 10 6.66699V4.00033C10 2.89576 9.10456 2.00033 8.00003 2.00033ZM8.00003 0.666992C9.84096 0.666992 11.3334 2.15938 11.3334 4.00033V6.66699C11.3334 8.50793 9.84096 10.0003 8.00003 10.0003C6.15905 10.0003 4.66667 8.50793 4.66667 6.66699V4.00033C4.66667 2.15938 6.15905 0.666992 8.00003 0.666992ZM2.03662 7.33366H3.38059C3.70408 9.59519 5.64902 11.3337 8.00003 11.3337C10.351 11.3337 12.2959 9.59519 12.6194 7.33366H13.9634C13.656 10.1147 11.4478 12.3229 8.66669 12.6304V15.3337H7.33336V12.6304C4.55225 12.3229 2.34405 10.1147 2.03662 7.33366Z" fill="currentColor"/>
+</g>
+</svg>

+ 15 - 0
src/icons/svg/upload.svg

@@ -0,0 +1,15 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_956_3987)">
+<g clip-path="url(#clip1_956_3987)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8.0004 1.72363L11.4955 5.21873L10.5527 6.16154L8.66742 4.27627L8.66742 11.0472H7.33409L7.33409 4.27556L5.44812 6.16154L4.50531 5.21873L8.0004 1.72363ZM3.33333 12.9998V11.6664H2V14.3331H14V11.6664H12.6667V12.9998H3.33333Z" fill="#4E5969"/>
+</g>
+</g>
+<defs>
+<clipPath id="clip0_956_3987">
+<rect width="16" height="16" fill="white"/>
+</clipPath>
+<clipPath id="clip1_956_3987">
+<rect width="16" height="16" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 5 - 0
src/icons/svg/voiceprint-line.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="voiceprint-line">
+<path id="Vector" d="M3.33366 4.66536H4.66699V11.332H3.33366V4.66536ZM0.666992 6.66536H2.00033V9.33203H0.666992V6.66536ZM6.00033 1.33203H7.33366V13.332H6.00033V1.33203ZM8.66699 2.66536H10.0003V14.6654H8.66699V2.66536ZM11.3337 4.66536H12.667V11.332H11.3337V4.66536ZM14.0003 6.66536H15.3337V9.33203H14.0003V6.66536Z" fill="currentColor"/>
+</g>
+</svg>

+ 1 - 0
src/main.js

@@ -8,6 +8,7 @@ import '@/icons';
 import '@/styles/index.scss';
 import '@/utils/filter';
 import '@/utils/directive';
+import '@/styles/font/font.css';
 
 import ElementUI from 'element-ui';
 import '@/styles/element-variables.scss';

BIN
src/styles/font/GB PINYINOK-B.TTF


+ 4 - 0
src/styles/font/font.css

@@ -0,0 +1,4 @@
+@font-face {
+  font-family: 'PINYIN-B';
+  src: url('GB PINYINOK-B.TTF');
+}

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

@@ -2,7 +2,7 @@
 import ModuleBase from './ModuleBase.vue';
 import RichText from '@/components/RichText.vue';
 
-import { snGenerationMethodList, viewMethodList } from '@/views/book/courseware/data/common';
+import { snGenerationMethodList, viewMethodList, isEnable } from '@/views/book/courseware/data/common';
 import { SaveCoursewareComponentContent, GetCoursewareComponentContent } from '@/api/book';
 
 const mixin = {
@@ -10,6 +10,7 @@ const mixin = {
     return {
       snGenerationMethodList,
       viewMethodList,
+      isEnable,
       property: {
         isGetContent: false, // 是否已获取内容
       },

+ 237 - 0
src/views/book/courseware/create/components/question/fill/Fill.vue

@@ -0,0 +1,237 @@
+<template>
+  <ModuleBase :type="data.type">
+    <template #content>
+      <!-- eslint-disable max-len -->
+      <div class="fill-wrapper">
+        <RichText
+          v-model="data.content"
+          toolbar="fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright"
+          :wordlimit-num="false"
+        />
+        <span class="tips">在需要加空的内容处插入 3 个或以上的下划线“_”。</span>
+        <div v-if="data.audio_file_id">
+          <SoundRecord :wav-blob.sync="data.audio_file_id" />
+        </div>
+        <template v-else>
+          <div :class="['upload-audio-play']">
+            <UploadAudio
+              v-if="data.property.audio_generation_method === 'upload'"
+              :file-id="data.audio_file_id"
+              :show-upload="!data.audio_file_id"
+              @upload="uploads"
+              @deleteFile="deleteFiles"
+            />
+            <div v-else-if="data.property.audio_generation_method === 'auto'" class="auto-matic" @click="handleMatic">
+              <SvgIcon icon-class="voiceprint-line" class="record" />
+              <span class="auto-btn">{{ data.audio_file_id ? '已生成' : '生成音频' }}</span
+              >{{ data.audio_file_id ? '成功' : '' }}
+            </div>
+            <SoundRecord v-else :wav-blob.sync="data.audio_file_id" />
+          </div>
+        </template>
+        <el-button @click="identifyText">识别</el-button>
+        <div class="correct-answer">
+          <el-input
+            v-for="(item, i) in data.answer.answer_list.filter(({ type }) => type === 'any_one')"
+            :key="item.mark"
+            v-model="item.value"
+            @blur="handleTone(item.value, i)"
+          >
+            <span slot="prefix">{{ i + 1 }}.</span>
+          </el-input>
+        </div>
+      </div>
+    </template>
+  </ModuleBase>
+</template>
+
+<script>
+import ModuleMixin from '../../common/ModuleMixin';
+import SoundRecord from '@/views/book/courseware/create/components/question/fill/components/SoundRecord.vue';
+import UploadAudio from '@/views/book/courseware/create/components/question/fill/components/UploadAudio.vue';
+
+import { getFillData } from '@/views/book/courseware/data/fill';
+import { addTone, handleToneValue } from '@/views/book/courseware/data/common';
+import { getRandomNumber } from '@/utils';
+import { GetStaticResources } from '@/api/app';
+
+export default {
+  name: 'FillPage',
+  components: {
+    SoundRecord,
+    UploadAudio,
+  },
+  mixins: [ModuleMixin],
+  data() {
+    return {
+      data: getFillData(),
+    };
+  },
+  methods: {
+    // 识别文本
+    identifyText() {
+      this.data.model_essay = [];
+      this.data.answer.answer_list = [];
+
+      this.data.content
+        .split(/<(p|div)[^>]*>(.*?)<\/(p|div)>/g)
+        .filter((s) => s && !s.match(/^(p|div)$/))
+        .forEach((item) => {
+          if (item.charCodeAt() === 10) return;
+          let str = item
+            // 去除所有的 font-size 样式
+            .replace(/font-size:\s*\d+(\.\d+)?px;/gi, '')
+            // 匹配 class 名为 rich-fill 的 span 标签和三个以上的_,并将它们组成数组
+            .replace(/<span class="rich-fill".*?>(.*?)<\/span>|([_]{3,})/gi, '###$1$2###');
+          this.data.model_essay.push(this.splitRichText(str));
+        });
+    },
+    // 分割富文本
+    splitRichText(str) {
+      let _str = str;
+      let start = 0;
+      let index = 0;
+      let arr = [];
+      let matchNum = 0;
+      while (index !== -1) {
+        index = _str.indexOf('###', start);
+        if (index === -1) break;
+        matchNum += 1;
+        arr.push({ content: _str.slice(start, index), type: 'text' });
+        if (matchNum % 2 === 0 && arr.length > 0) {
+          arr[arr.length - 1].type = 'input';
+          let mark = getRandomNumber();
+          arr[arr.length - 1].mark = mark;
+          let content = arr[arr.length - 1].content;
+          // 设置答案数组
+          let isUnderline = /^_{3,}$/.test(content);
+          this.data.answer.answer_list.push({
+            value: isUnderline ? '' : content,
+            mark,
+            type: isUnderline ? 'any_one' : 'only_one',
+          });
+
+          // 将 content 设置为空,为预览准备
+          arr[arr.length - 1].content = '';
+        }
+        start = index + 3;
+      }
+      let last = _str.slice(start);
+      if (last) {
+        arr.push({ content: last, type: 'text' });
+      }
+      return arr;
+    },
+    handleTone(value, i) {
+      if (!/^[a-zA-Z0-9\s]+$/.test(value)) return;
+      this.data.answer.answer_list[i].value = value
+        .trim()
+        .split(/\s+/)
+        .map((item) => {
+          return handleToneValue(item);
+        })
+        .map((item) =>
+          item.map(({ number, con }) => (number && con ? addTone(Number(number), con) : number || con || '')),
+        )
+        .filter((item) => item.length > 0)
+        .join(' ');
+    },
+    uploads(file_id) {
+      this.data.audio_file_id = file_id;
+    },
+    deleteFiles() {
+      this.data.audio_file_id = '';
+    },
+    // 自动生成音频
+    handleMatic() {
+      GetStaticResources('tool-TextToVoiceFile', {
+        text: this.data.content.replace(/<[^>]+>/g, ''),
+      })
+        .then(({ status, file_id }) => {
+          if (status === 1) {
+            this.data.audio_file_id = file_id;
+          }
+        })
+        .catch(() => {});
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.fill-wrapper {
+  display: flex;
+  flex-direction: column;
+  row-gap: 16px;
+  align-items: flex-start;
+
+  :deep .rich-wrapper {
+    width: 100%;
+  }
+
+  .tips {
+    font-size: 12px;
+    color: #999;
+  }
+
+  .auto-matic,
+  .upload-audio-play {
+    :deep .upload-wrapper {
+      margin-top: 0;
+    }
+
+    .audio-wrapper {
+      :deep .audio-play {
+        width: 16px;
+        height: 16px;
+        color: #000;
+        background-color: initial;
+      }
+
+      :deep .audio-play.not-url {
+        color: #a1a1a1;
+      }
+
+      :deep .voice-play {
+        width: 16px;
+        height: 16px;
+      }
+    }
+  }
+
+  .auto-matic {
+    display: flex;
+    flex-shrink: 0;
+    column-gap: 12px;
+    align-items: center;
+    width: 200px;
+    padding: 5px 12px;
+    background-color: $fill-color;
+    border-radius: 2px;
+
+    .auto-btn {
+      font-size: 16px;
+      font-weight: 400;
+      line-height: 22px;
+      color: #1d2129;
+      cursor: pointer;
+    }
+  }
+
+  .correct-answer {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+
+    .el-input {
+      width: 180px;
+
+      :deep &__prefix {
+        display: flex;
+        align-items: center;
+        color: $text-color;
+      }
+    }
+  }
+}
+</style>

+ 94 - 0
src/views/book/courseware/create/components/question/fill/FillSetting.vue

@@ -0,0 +1,94 @@
+<template>
+  <div>
+    <el-form :model="property" label-width="72px" label-position="left">
+      <el-form-item label="序号" class="serial-number">
+        <el-input v-model="property.serial_number" />
+        <SvgIcon icon-class="switch" size="14" @click="switchSerialNumber(property)" />
+      </el-form-item>
+      <el-form-item>
+        <el-radio
+          v-for="{ value, label } in snGenerationMethodList"
+          :key="value"
+          v-model="property.sn_generation_method"
+          :label="value"
+        >
+          {{ label }}
+        </el-radio>
+      </el-form-item>
+      <el-form-item label="序号位置">
+        <SerialNumberPosition :position="property.sn_position" @changeNumberPosition="changeNumberPosition" />
+      </el-form-item>
+      <el-divider />
+      <el-form-item label="排列">
+        <el-radio-group v-model="property.arrange_type">
+          <el-radio v-for="{ value, label } in arrangeTypeList" :key="value" :label="value">
+            {{ label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="音频位置">
+        <el-radio-group v-model="property.audio_position">
+          <el-radio v-for="{ value, label } in audioPositionList" :key="value" :label="value">
+            {{ label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-divider />
+      <el-form-item label="读音">
+        <el-select v-model="property.audio_generation_method" placeholder="请选择">
+          <el-option v-for="{ value, label } in audioGenerationMethodList" :key="value" :label="label" :value="value" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="填空字体">
+        <el-select v-model="property.fill_font" placeholder="请选择">
+          <el-option v-for="{ value, label } in fillFontList" :key="value" :label="label" :value="value" />
+        </el-select>
+      </el-form-item>
+      <el-divider />
+      <el-form-item label="语音作答">
+        <el-radio-group v-model="property.is_enable_voice_answer">
+          <el-radio v-for="{ value, label } in switchOption" :key="value" :label="value" :value="value">
+            {{ label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import SettingMixin from '@/views/book/courseware/create/components/common/SettingMixin';
+
+import {
+  getFillProperty,
+  arrangeTypeList,
+  audioPositionList,
+  audioGenerationMethodList,
+  fillFontList,
+  switchOption,
+} from '@/views/book/courseware/data/fill';
+
+export default {
+  name: 'FillSetting',
+  mixins: [SettingMixin],
+  data() {
+    return {
+      property: getFillProperty(),
+      arrangeTypeList,
+      audioPositionList,
+      audioGenerationMethodList,
+      fillFontList,
+      switchOption,
+    };
+  },
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.el-form {
+  @include setting-base;
+}
+</style>

+ 232 - 0
src/views/book/courseware/create/components/question/fill/components/SoundRecord.vue

@@ -0,0 +1,232 @@
+<template>
+  <div class="sound-record-wrapper">
+    <template v-if="wavBlob">
+      <SvgIcon
+        v-if="audio.paused"
+        icon-class="audio"
+        :class="['audio-play-btn', wavBlob ? '' : 'not-url']"
+        @click="playMicrophone"
+      />
+      <img v-else :src="require('@/assets/fill/voice-play-gray.png')" class="voice-play" @click="playMicrophone" />
+      <span :class="['record-time', microphoneStatus ? 'record-ing' : '']"
+        >{{ audio.paused ? '' : '-' }}{{ secondFormatConversion(recordTimes) }}</span
+      >
+      <SvgIcon icon-class="delete-back-line" :class="['delete-btn', wavBlob ? '' : 'not-url']" @click="deleteWav" />
+    </template>
+    <div v-else class="sound-microphone" @click="microphone">
+      <img v-if="microphoneStatus" :src="require('@/assets/fill/record-ing.png')" class="voice-play" />
+      <SvgIcon v-else icon-class="mic-line" class="record" />
+      <span class="auto-btn">录制音频</span>
+    </div>
+
+    <audio ref="audio" :src="file_url" preload="metadata"></audio>
+  </div>
+</template>
+
+<script>
+import Recorder from 'js-audio-recorder'; // 录音插件
+import { secondFormatConversion } from '@/utils/transform';
+import { GetStaticResources, GetFileStoreInfo } from '@/api/app';
+export default {
+  name: 'SoundRecord',
+  props: {
+    wavBlob: {
+      type: String,
+      default: '',
+    },
+  },
+  data() {
+    return {
+      secondFormatConversion,
+      recorder: new Recorder({
+        sampleBits: 16, // 采样位数,支持 8 或 16,默认是16
+        sampleRate: 16000, // 采样率,支持 11025、16000、22050、24000、44100、48000,根据浏览器默认值,我的chrome是48000
+        numChannels: 1, // 声道,支持 1 或 2, 默认是1
+      }),
+      timer: null, // 计时器
+      microphoneStatus: false, // 是否录音
+      hasMicro: '', // 录音后的样式class
+      audio: {
+        paused: true,
+      },
+      playtime: 0, // 播放时间
+      recordTimes: 0,
+      file_url: '',
+      recordTime: 0,
+    };
+  },
+  computed: {},
+  watch: {
+    wavBlob: {
+      handler(val) {
+        if (!val) return;
+        GetFileStoreInfo({ file_id: val }).then(({ file_url, media_duration }) => {
+          this.file_url = file_url;
+          this.recordTime = media_duration;
+          this.recordTimes = JSON.parse(JSON.parse(media_duration));
+        });
+      },
+      immediate: true,
+    },
+  },
+  mounted() {
+    this.$refs.audio.addEventListener('ended', () => {
+      this.audio.paused = true;
+    });
+    this.$refs.audio.addEventListener('pause', () => {
+      this.audio.paused = true;
+    });
+    this.$refs.audio.addEventListener('play', () => {
+      this.audio.paused = false;
+    });
+  },
+  methods: {
+    playMicrophone() {
+      if (this.wavBlob) {
+        let totalTime = JSON.parse(JSON.stringify(this.recordTime));
+        if (this.audio.paused) {
+          this.hasMicro = 'active';
+          // this.$refs.audio.pause();
+          // this.$refs.audio.load();
+          this.$refs.audio.play();
+          if (this.recordTimes === 0) {
+            this.recordTimes = JSON.parse(JSON.stringify(this.recordTime));
+            this.playtime = 0;
+          }
+          clearInterval(this.timer);
+          this.timer = setInterval(() => {
+            if (this.playtime < totalTime) {
+              this.playtime += 1;
+              this.recordTimes = totalTime - this.playtime;
+            } else {
+              this.playtime = 0;
+              this.recordTimes = JSON.parse(JSON.stringify(this.recordTime));
+              clearInterval(this.timer);
+            }
+          }, 1000);
+        } else {
+          this.$refs.audio.pause();
+          this.hasMicro = 'normal';
+          clearInterval(this.timer);
+        }
+      }
+    },
+
+    // 开始录音
+    microphone() {
+      if (this.microphoneStatus) {
+        this.hasMicro = 'normal';
+        this.recorder.stop();
+        clearInterval(this.timer);
+        // 录音结束,获取取录音数据
+        let wav = this.recorder.getWAVBlob(); // 获取 WAV 数据
+        this.microphoneStatus = false;
+        let reader = new window.FileReader();
+        reader.readAsDataURL(wav);
+        reader.onloadend = () => {
+          let MethodName = 'file_store_manager-SaveFileByteBase64Text';
+          let data = {
+            base64_text: reader.result.replace('data:audio/wav;base64,', ''),
+            file_suffix_name: 'mp3',
+          };
+          GetStaticResources(MethodName, data).then((res) => {
+            if (res.status === 1) {
+              this.$emit('update:wavBlob', res.file_id);
+            }
+          });
+        };
+      } else {
+        this.hasMicro = '';
+        this.$emit('update:wavBlob', '');
+        // 开始录音
+        this.recorder.start();
+        this.microphoneStatus = true;
+        this.recordTimes = 0;
+        clearInterval(this.timer);
+        this.timer = setInterval(() => {
+          this.recordTimes += 1;
+        }, 1000);
+      }
+    },
+    // 删除录音
+    deleteWav() {
+      this.$refs.audio.pause();
+      this.hasMicro = '';
+      this.microphoneStatus = false;
+      this.playtime = 0;
+      this.recordTimes = 0;
+      clearInterval(this.timer);
+      this.$emit('update:wavBlob', '');
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.sound-record-wrapper {
+  display: flex;
+  column-gap: 12px;
+  align-items: center;
+  width: 200px;
+  padding: 5px 12px;
+  background: $fill-color;
+  border-radius: 2px;
+
+  .audio-play-btn {
+    cursor: pointer;
+
+    &.not-url {
+      color: #a1a1a1;
+      cursor: not-allowed;
+    }
+  }
+
+  .record-time {
+    flex: 1;
+    min-width: 52px;
+    font-size: 14px;
+    font-weight: 400;
+    line-height: 22px;
+    color: #a1a1a1;
+
+    &.record-ing {
+      color: #000;
+    }
+  }
+
+  .record {
+    cursor: pointer;
+  }
+
+  .voice-play {
+    width: 16px;
+    height: 16px;
+  }
+
+  .delete-btn {
+    margin-left: 12px;
+    color: #4e4e4e;
+    cursor: pointer;
+
+    &.not-url {
+      color: #a1a1a1;
+      cursor: not-allowed;
+    }
+  }
+
+  .auto-btn {
+    font-size: 16px;
+    font-weight: 400;
+    line-height: 22px;
+    color: #1d2129;
+    cursor: pointer;
+  }
+
+  .sound-microphone {
+    display: flex;
+    column-gap: 12px;
+    align-items: center;
+    width: 100%;
+  }
+}
+</style>

+ 141 - 0
src/views/book/courseware/create/components/question/fill/components/UploadAudio.vue

@@ -0,0 +1,141 @@
+<template>
+  <div class="upload-wrapper">
+    <el-upload
+      v-if="showUpload"
+      ref="upload"
+      :limit="1"
+      action="no"
+      accept="audio/*"
+      :show-file-list="false"
+      :before-upload="beforeUpload"
+      :http-request="upload"
+    >
+      <div class="upload-audio">
+        <SvgIcon icon-class="upload" />
+        <span class="upload-text">上传音频</span>
+        <span v-show="progress > 0">{{ progress }}%</span>
+      </div>
+    </el-upload>
+    <div v-show="file_url.length > 0" class="file-wrapper">
+      <div class="file-name">{{ file_name }}</div>
+      <SvgIcon icon-class="delete" class-name="delete pointer" @click="deleteFile" />
+    </div>
+  </div>
+</template>
+
+<script>
+import { fileUpload, GetFileStoreInfo } from '@/api/app';
+
+export default {
+  name: 'UploadAudio',
+  props: {
+    fileId: {
+      type: String,
+      default: '',
+    },
+    showUpload: {
+      type: Boolean,
+      default: true,
+    },
+  },
+  data() {
+    return {
+      progress: 0,
+      file_id: '',
+      file_url: '',
+      file_name: '',
+    };
+  },
+  watch: {
+    fileId: {
+      handler(val) {
+        if (!val) return;
+        GetFileStoreInfo({ file_id: val }).then(({ file_id, file_url, file_name }) => {
+          this.file_id = file_id;
+          this.file_url = file_url;
+          this.file_name = file_name;
+        });
+      },
+      immediate: true,
+    },
+  },
+  methods: {
+    beforeUpload(file) {
+      if (this.file_id.length > 0) {
+        this.$message.warning('只能上传一个音频文件');
+        return false;
+      }
+      const fileName = file.name;
+      const suffix = fileName.slice(fileName.lastIndexOf('.') + 1, fileName.length).toLowerCase();
+      if (!['mp3', 'wav', 'aac', 'm4a'].includes(suffix)) {
+        this.$message.warning('音频格式不正确');
+        return false;
+      }
+    },
+    upload(file) {
+      fileUpload('Mid', file, { handleUploadProgress: this.handleUploadProgress }).then(({ file_info_list }) => {
+        if (file_info_list.length > 0) {
+          const { file_id, file_name, file_url } = file_info_list[0];
+          this.file_id = file_id;
+          this.file_url = file_url;
+          this.file_name = file_name;
+          this.$emit('upload', file_id);
+        }
+      });
+    },
+    handleUploadProgress(progressEvent) {
+      this.progress = ((progressEvent.loaded / progressEvent.total) * 100).toFixed(2) || 0;
+    },
+    deleteFile() {
+      this.$confirm('是否删除当前音频文件?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning',
+      })
+        .then(() => {
+          this.$emit('deleteFile', this.file_id);
+          this.file_id = '';
+          this.file_url = '';
+          this.file_name = '';
+          this.progress = 0;
+          this.$refs.upload.clearFiles();
+        })
+        .catch(() => {});
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.upload-wrapper {
+  display: flex;
+  column-gap: 12px;
+  align-items: center;
+  margin-top: 8px;
+
+  .upload-audio {
+    display: flex;
+    column-gap: 12px;
+    align-items: center;
+    width: 233px;
+    padding: 4px 12px;
+    background-color: $fill-color;
+
+    .upload-text {
+      flex: 1;
+      text-align: left;
+    }
+  }
+
+  .file-wrapper {
+    display: flex;
+    column-gap: 12px;
+    align-items: center;
+
+    .file-name {
+      padding: 4px 12px;
+      background-color: $fill-color;
+    }
+  }
+}
+</style>

+ 25 - 7
src/views/book/courseware/create/components/question/matching/MatchingSetting.vue

@@ -1,13 +1,31 @@
 <template>
   <div>
     <el-form :model="property" label-width="42px">
-      <el-form-item class="serial-number" label-width="0">
-        <div>序号</div>
-        <div class="serial-number-list">
+      <el-form-item label="序号" class="serial-number">
+        <el-input v-model="property.serial_number" />
+        <SvgIcon icon-class="switch" size="14" @click="switchSerialNumber(property)" />
+      </el-form-item>
+      <el-form-item>
+        <el-radio
+          v-for="{ value, label } in snGenerationMethodList"
+          :key="value"
+          v-model="property.sn_generation_method"
+          :label="value"
+        >
+          {{ label }}
+        </el-radio>
+      </el-form-item>
+      <el-form-item label="序号位置">
+        <SerialNumberPosition :position="property.sn_position" @changeNumberPosition="changeNumberPosition" />
+      </el-form-item>
+      <el-divider />
+      <el-form-item class="option-serial-number" label-width="0">
+        <div>选项序号</div>
+        <div class="option-serial-number-list">
           <div
             v-for="(item, i) in property.serial_number_type_list"
             :key="i"
-            class="serial-number-item"
+            class="option-serial-number-item"
             @dblclick="changeSerialNumberType(i, item)"
           >
             {{ computeOptionMethods[item](0) }}
@@ -67,19 +85,19 @@ export default {
 .el-form {
   @include setting-base;
 
-  .serial-number {
+  .option-serial-number {
     :deep .el-form-item__content {
       display: flex;
       flex-direction: column;
       row-gap: 8px;
       align-items: flex-start;
 
-      .serial-number-list {
+      .option-serial-number-list {
         display: flex;
         flex-wrap: wrap;
         column-gap: 8px;
 
-        .serial-number-item {
+        .option-serial-number-item {
           min-width: 80px;
           height: 32px;
           padding: 4px 12px;

+ 11 - 0
src/views/book/courseware/data/bookType.js

@@ -22,6 +22,8 @@ import SortPage from '../create/components/question/sort/Sort.vue';
 import SortSetting from '../create/components/question/sort/SortSetting.vue';
 import VoiceMatrix from '../create/components/question/voice_matrix/VoiceMatrix.vue';
 import VoiceMatrixSetting from '../create/components/question/voice_matrix/VoiceMatrixSetting.vue';
+import FillPage from '../create/components/question/fill/Fill.vue';
+import FillSetting from '../create/components/question/fill/FillSetting.vue';
 
 import AudioPreview from '@/views/book/courseware/preview/components/audio/AudioPreview.vue';
 import DividerPreview from '@/views/book/courseware/preview/components/divider/DividerPreview.vue';
@@ -35,6 +37,7 @@ import SelectPreview from '@/views/book/courseware/preview/components/select/Sel
 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';
+import FillPreview from '@/views/book/courseware/preview/components/fill/FillPreview.vue';
 
 export const bookTypeOption = [
   {
@@ -144,6 +147,14 @@ export const bookTypeOption = [
         set: VoiceMatrixSetting,
         preview: VoiceMatrixPreview,
       },
+      {
+        value: 'fill',
+        label: '填空组件',
+        icon: '',
+        component: FillPage,
+        set: FillSetting,
+        preview: FillPreview,
+      },
     ],
   },
 ];

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

@@ -66,6 +66,78 @@ export function isEnable(value) {
   return value === switchOption[0].value;
 }
 
+export const tone_data = [
+  ['ā', 'á', 'ǎ', 'à', 'a'],
+  ['ō', 'ó', 'ǒ', 'ò', 'o'],
+  ['ē', 'é', 'ě', 'è', 'e'],
+  ['ī', 'í', 'ǐ', 'ì', 'i'],
+  ['ū', 'ú', 'ǔ', 'ù', 'u'],
+  ['ǖ', 'ǘ', 'ǚ', 'ǜ', 'ü'],
+  ['ǖ', 'ǘ', 'ǚ', 'ǜ', 'ü'],
+  ['Ā', 'Á', 'Â', 'À', 'A'],
+  ['Ō', 'Ó', 'Ô', 'Ò', 'O'],
+  ['Ē', 'É', 'Ê', 'È', 'E'],
+  ['Ī', 'Í', 'Î', 'Ì', 'I'],
+  ['Ū', 'Ú', 'Û', 'Ù', 'U'],
+];
+
+/**
+ * 添加声调
+ * @param {Number} number
+ * @param {String} con
+ * @returns String
+ */
+export function addTone(number, con) {
+  const zmList = ['a', 'o', 'e', 'i', 'u', 'v', 'ü', 'A', 'O', 'E', 'I', 'U'];
+  let cons = con;
+  if (number) {
+    for (let i = 0; i < zmList.length; i++) {
+      let zm = zmList[i];
+      if (con.includes(zm)) {
+        let zm2 = tone_data[i][number - 1];
+        if (con.includes('iu')) {
+          zm2 = tone_data[4][number - 1];
+          cons = con.replace('u', zm2);
+        } else if (con.includes('ui')) {
+          zm2 = tone_data[3][number - 1];
+          cons = con.replace('i', zm2);
+        } else if (/yv|jv|qv|xv/.test(con)) {
+          zm2 = tone_data[4][number - 1];
+          cons = con.replace('v', zm2);
+        } else if (/yü|jü|qü|xü/.test(con)) {
+          zm2 = tone_data[4][number - 1];
+          cons = con.replace('ü', zm2);
+        } else {
+          cons = con.replace(zm, zm2);
+        }
+
+        break;
+      }
+    }
+  }
+  return cons;
+}
+
+export function handleToneValue(valItem) {
+  let numList = [];
+  if (/[A-Za-zü]+\d/g.test(valItem)) {
+    valItem.split('').forEach((item, i) => {
+      if (/\d/.test(item)) {
+        let numIndex = numList.length === 0 ? 0 : numList[numList.length - 1].index;
+        let con = valItem.substring(numIndex, i).replace(/\d/g, '');
+        numList.push({
+          number: item,
+          con,
+        });
+      }
+    });
+  } else {
+    numList = [];
+  }
+
+  return numList.length === 0 ? [{ con: valItem }] : numList;
+}
+
 /**
  * 判断序号类型
  * @param {string} str

+ 66 - 0
src/views/book/courseware/data/fill.js

@@ -0,0 +1,66 @@
+import {
+  snGenerationMethodList,
+  serialNumberTypeList,
+  serialNumberPositionList,
+  arrangeTypeList,
+  switchOption,
+} from '@/views/book/courseware/data/common';
+
+export { arrangeTypeList, switchOption };
+
+// 音频位置
+export const audioPositionList = [
+  { value: 'front', label: '前' },
+  { value: 'back', label: '后' },
+];
+
+// 读音生成方式
+export const audioGenerationMethodList = [
+  {
+    value: 'upload',
+    label: '上传',
+  },
+  {
+    value: 'auto',
+    label: '自动生成',
+  },
+  {
+    value: 'record',
+    label: '录音',
+  },
+];
+
+// 填空字体
+export const fillFontList = [
+  { value: 'chinese', label: '中文', font: 'arial' },
+  { value: 'english', label: '英文', font: 'arial' },
+  { value: 'pinyin', label: '拼音', font: 'PINYIN-B' },
+];
+
+export function getFillProperty() {
+  return {
+    serial_number: 1,
+    sn_type: serialNumberTypeList[0].value,
+    sn_position: serialNumberPositionList[0].value,
+    sn_generation_method: snGenerationMethodList[0].value,
+    arrange_type: arrangeTypeList[0].value,
+    audio_position: audioPositionList[0].value,
+    audio_generation_method: audioGenerationMethodList[0].value,
+    fill_font: fillFontList[0].value,
+    is_enable_voice_answer: switchOption[0].value,
+  };
+}
+
+export function getFillData() {
+  return {
+    type: 'fill',
+    title: '填空',
+    property: getFillProperty(),
+    content: '',
+    audio_file_id: '',
+    model_essay: [],
+    answer: {
+      answer_list: [],
+    },
+  };
+}

+ 9 - 1
src/views/book/courseware/data/matching.js

@@ -1,4 +1,8 @@
-import { serialNumberTypeList } from '@/views/book/courseware/data/common';
+import {
+  serialNumberTypeList,
+  snGenerationMethodList,
+  serialNumberPositionList,
+} from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
 
 /**
@@ -11,6 +15,10 @@ export function getMatchingProperty(column_num = 2) {
   }
 
   return {
+    serial_number: 1,
+    sn_type: serialNumberTypeList[0].value,
+    sn_position: serialNumberPositionList[0].value,
+    sn_generation_method: snGenerationMethodList[0].value,
     serial_number_type_list: serialNumberTypeList.slice(0, column_num).map((item) => item.value),
     row_num: 2, // 行数
     column_num, // 列数 2 ~ 3

+ 50 - 28
src/views/book/courseware/preview/components/voice_matrix/components/SoundRecord.vue → src/views/book/courseware/preview/common/SoundRecord.vue

@@ -1,12 +1,7 @@
 <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>
+  <div class="record">
+    <template v-if="type === 'normal' || type === 'mini'">
+      <div :class="['record', microphoneStatus ? 'active' : '']" @click="microphone"></div>
       <span
         v-if="type && type == 'normal'"
         :class="[
@@ -17,19 +12,13 @@
         ]"
         >{{ 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>
+    <template v-else-if="type === 'pro'">
+      <div :class="['record', microphoneStatus ? 'active' : '']" @click="microphone"></div>
       <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>
@@ -40,12 +29,7 @@
       <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>
+      <div :class="['record', microphoneStatus ? 'active' : '']" @click="microphone"></div>
       <span
         :class="[
           'record-time',
@@ -70,8 +54,41 @@
 import Recorder from 'js-audio-recorder'; // 录音插件
 
 export default {
-  components: {},
-  props: ['wavData', 'type', 'fileName', 'selectData', 'answerRecordList', 'index', 'TaskModel', 'tmIndex'],
+  props: {
+    wavData: {
+      type: Object,
+      default: () => {},
+    },
+    // 类型 normal pro mini promax
+    type: {
+      type: String,
+      default: '',
+    },
+    fileName: {
+      type: String,
+      default: '',
+    },
+    selectData: {
+      type: Object,
+      default: () => {},
+    },
+    answerRecordList: {
+      type: Array,
+      default: () => [],
+    },
+    index: {
+      type: Number,
+      default: 0,
+    },
+    taskModel: {
+      type: String,
+      default: '',
+    },
+    tmIndex: {
+      type: Number,
+      default: 0,
+    },
+  },
   data() {
     return {
       recorder: new Recorder({
@@ -93,6 +110,11 @@ export default {
       isPlaying: false,
     };
   },
+  computed: {
+    modelNotAnswer() {
+      return !this.taskModel || this.taskModel !== 'ANSWER';
+    },
+  },
   watch: {
     answerRecordList(newVal) {
       this.recordList = JSON.parse(JSON.stringify(newVal));
@@ -134,7 +156,7 @@ export default {
   methods: {
     // 开始录音
     microphone() {
-      if (!this.TaskModel || this.TaskModel !== 'ANSWER') {
+      if (!this.taskModel || this.taskModel !== 'ANSWER') {
         if (this.microphoneStatus) {
           this.hasMicro = 'normal';
           this.recorder.stop();
@@ -263,7 +285,7 @@ export default {
       this.$emit('sentPause', false);
     },
     handleDelete() {
-      if (this.hasMicro && (!this.TaskModel || this.TaskModel !== 'ANSWER')) {
+      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);
@@ -289,7 +311,7 @@ export default {
 </script>
 
 <style lang="scss" scoped>
-.NNPE-Book-record {
+.record {
   display: flex;
   align-items: center;
   justify-content: center;
@@ -383,7 +405,7 @@ export default {
 </style>
 
 <style lang="scss">
-.NNPE-Book-record {
+.record {
   .el-select {
     flex: 1;
     height: 24px;

+ 131 - 0
src/views/book/courseware/preview/components/fill/FillPreview.vue

@@ -0,0 +1,131 @@
+<!-- eslint-disable vue/no-v-html -->
+<template>
+  <div class="select-preview" :style="getAreaStyle()">
+    <SerialNumberPosition :property="data.property" />
+
+    <div class="main" :style="getMainStyle()">
+      <AudioFill :file-id="data.audio_file_id" />
+      <div class="fill-wrapper">
+        <p v-for="(item, i) in data.model_essay" :key="i">
+          <template v-for="(li, j) in item">
+            <span v-if="li.type === 'text'" :key="j" v-html="sanitizeHTML(li.content)"></span>
+            <template v-if="li.type === 'input'">
+              <el-input
+                :key="j"
+                v-model="li.content"
+                :style="[{ width: Math.max(80, li.content.length * 21.3) + 'px' }, { 'font-family': fontFamily }]"
+              />
+            </template>
+          </template>
+        </p>
+      </div>
+      <SoundRecord ref="record" type="normal" class="record-box" />
+    </div>
+  </div>
+</template>
+
+<script>
+import { getFillData, fillFontList, arrangeTypeList } from '@/views/book/courseware/data/fill';
+
+import PreviewMixin from '../common/PreviewMixin';
+import AudioFill from './components/AudioFillPlay.vue';
+import SoundRecord from '../../common/SoundRecord.vue';
+
+export default {
+  name: 'FillPreview',
+  components: {
+    AudioFill,
+    SoundRecord,
+  },
+  mixins: [PreviewMixin],
+  data() {
+    return {
+      data: getFillData(),
+    };
+  },
+  computed: {
+    fontFamily() {
+      return fillFontList.find(({ value }) => this.data.property.fill_font === value).font;
+    },
+  },
+  created() {
+    this.answer.answer_list = this.data.model_essay
+      .map((item) => {
+        return item
+          .map(({ type, content, mark }) => {
+            if (type === 'input') {
+              return {
+                value: content,
+                mark,
+              };
+            }
+          })
+          .filter((item) => item);
+      })
+      .flat();
+  },
+  methods: {
+    getMainStyle() {
+      const isRow = this.data.property.arrange_type === arrangeTypeList[0].value;
+      let style = {
+        'flex-direction': isRow ? 'row' : 'column',
+      };
+      if (isRow) {
+        style['column-gap'] = '16px';
+      } else {
+        style['row-gap'] = '8px';
+      }
+      return style;
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.select-preview {
+  @include preview-base;
+
+  .main {
+    display: flex;
+    align-items: flex-start;
+  }
+
+  .fill-wrapper {
+    flex: 1;
+    font-size: 16pt;
+
+    p {
+      margin: 0;
+    }
+
+    .el-input {
+      display: inline-flex;
+      align-items: center;
+      width: 120px;
+      margin: 0 2px;
+
+      :deep input.el-input__inner {
+        padding: 0;
+        font-size: 16pt;
+        color: $font-color;
+        text-align: center;
+        background-color: #fff;
+        border-width: 0;
+        border-bottom: 1px solid $font-color;
+        border-radius: 0;
+      }
+    }
+  }
+
+  .record-box {
+    padding: 6px 12px;
+    background-color: $fill-color;
+
+    :deep .record-time {
+      width: 100px;
+    }
+  }
+}
+</style>

+ 134 - 0
src/views/book/courseware/preview/components/fill/components/AudioFillPlay.vue

@@ -0,0 +1,134 @@
+<template>
+  <div class="audio-fill">
+    <img
+      :src="audio.paused ? require('@/assets/fill/voice-pause-red.png') : require('@/assets/fill/voice-play-red.png')"
+      class="audio-play"
+      @click="playAudio"
+    />
+    <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: 'AudioFillPlay',
+  props: {
+    fileId: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      secondFormatConversion,
+      url: '',
+      audio: {
+        paused: true,
+        playing: false,
+        // 音频当前播放时长
+        current_time: 0,
+        // 音频最大播放时长
+        max_time: 0,
+        loading: false,
+      },
+      play_value: 0,
+      audio_allTime: null, // 展示总时间
+    };
+  },
+  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();
+    },
+    // 音频加载完之后
+    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-fill {
+  .audio-play {
+    width: 24px;
+    height: 24px;
+    cursor: pointer;
+  }
+}
+</style>

+ 33 - 20
src/views/book/courseware/preview/components/matching/MatchingPreview.vue

@@ -1,21 +1,25 @@
 <!-- eslint-disable vue/no-v-html -->
 <template>
-  <div class="matching-preview">
-    <ul ref="list" class="option-list">
-      <li v-for="(item, i) in data.option_list" :key="i" class="list-item">
-        <div
-          v-for="({ content, mark }, j) in item"
-          :key="mark"
-          :class="['item-wrapper', `item-${mark}`]"
-          :style="{ cursor: disabled ? 'default' : 'pointer' }"
-          @mousedown="mousedown($event, i, j, mark)"
-          @mouseup="mouseup($event, i, j, mark)"
-          @click="handleClickConnection($event, i, j, mark)"
-        >
-          <span class="content rich-text" v-html="sanitizeHTML(content)"></span>
-        </div>
-      </li>
-    </ul>
+  <div class="matching-preview" :style="getAreaStyle()">
+    <SerialNumberPosition :property="data.property" />
+
+    <div class="main">
+      <ul ref="list" class="option-list">
+        <li v-for="(item, i) in data.option_list" :key="i" class="list-item">
+          <div
+            v-for="({ content, mark }, j) in item"
+            :key="mark"
+            :class="['item-wrapper', `item-${mark}`]"
+            :style="{ cursor: disabled ? 'default' : 'pointer' }"
+            @mousedown="mousedown($event, i, j, mark)"
+            @mouseup="mouseup($event, i, j, mark)"
+            @click="handleClickConnection($event, i, j, mark)"
+          >
+            <span class="content rich-text" v-html="sanitizeHTML(content)"></span>
+          </div>
+        </li>
+      </ul>
+    </div>
   </div>
 </template>
 
@@ -204,15 +208,24 @@ export default {
      */
     createLine(mark, isDrag = false) {
       let { offsetWidth, offsetLeft, offsetTop, offsetHeight } = document.getElementsByClassName(`item-${mark}`)[0];
-      const { curOffsetWidth, curOffsetLeft, curOffsetTop, curMark } = this.computedCurConnectionPoint(isDrag);
-      let top = Math.min(offsetTop + offsetHeight / 2, curOffsetTop + offsetHeight / 2);
+      const { curOffsetWidth, curOffsetLeft, curOffsetTop, curOffsetHeight, curMark } =
+        this.computedCurConnectionPoint(isDrag);
+      let top = Math.min(offsetTop + offsetHeight / 2, curOffsetTop + curOffsetHeight / 2);
+      // 判断是否是同一行
+      const isSameRow = Math.abs(offsetTop + offsetHeight / 2 - (curOffsetTop + curOffsetHeight / 2)) <= 2;
       let left = Math.min(offsetLeft + offsetWidth, curOffsetLeft + curOffsetWidth);
       let width = Math.abs(
         offsetLeft > curOffsetLeft
           ? curOffsetLeft - offsetLeft + offsetWidth
           : offsetLeft - curOffsetLeft + curOffsetWidth,
       );
-      let height = Math.abs(offsetTop - curOffsetTop);
+      let height = 0;
+      if (!isSameRow) {
+        height =
+          curOffsetTop > offsetTop
+            ? Math.abs(offsetTop + offsetHeight / 2 - (curOffsetTop + curOffsetHeight / 2))
+            : Math.abs(offsetTop - curOffsetTop + offsetHeight / 2 - curOffsetHeight / 2);
+      }
       let size = offsetLeft > curOffsetLeft ? offsetTop > curOffsetTop : offsetTop < curOffsetTop; // 判断是左上还是右下
       // 创建一个空的SVG元素
       let svg = document.createElementNS(svgNS, 'svg');
@@ -249,7 +262,7 @@ export default {
         curOffsetWidth: dom.offsetWidth,
         curOffsetLeft: dom.offsetLeft,
         curOffsetTop: dom.offsetTop,
-        curOffsetHeight: dom.offsetHeight / 2,
+        curOffsetHeight: dom.offsetHeight,
         curMark: mark,
       };
     },

+ 1 - 7
src/views/book/courseware/preview/components/voice_matrix/VoiceMatrixPreview.vue

@@ -165,13 +165,9 @@
       <!-- 录音 -->
       <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"
@@ -203,7 +199,7 @@ import { getRandomNumber } from '@/utils/index.js';
 
 import PreviewMixin from '../common/PreviewMixin';
 import Bus from './components/Bus.js';
-import SoundRecord from './components/SoundRecord.vue';
+import SoundRecord from '../../common/SoundRecord.vue';
 import AudioCompare from './components/AudioCompareMatrix.vue';
 import AudioLine from './components/AudioLine.vue';
 
@@ -220,7 +216,6 @@ export default {
       data: getVoiceMatrixData(),
       cid: getRandomNumber(),
       themeColor: 'red',
-      TaskModel: '',
       curTime: 0,
       playing: false,
       stopAudio: true,
@@ -244,7 +239,6 @@ export default {
       // 跟读所需属性
       wavblob: null,
       isRecord: false,
-      refresh: true,
       matrixSelectLrc: null,
     };
   },

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

@@ -26,6 +26,7 @@
       />
       <span class="search-name">创建者</span>
       <el-select v-model="creator_id" placeholder="全部">
+        <el-option label="-全部- " value="" />
         <el-option
           v-for="{ user_id, user_real_name } in user_list"
           :key="user_id"