ソースを参照

Merge branch 'master' of http://60.205.254.193:3000/GCLS/GCLS_Page_Exercise

dusenyao 1 年間 前
コミット
5e6f84c089

+ 48 - 41
package-lock.json

@@ -2406,6 +2406,49 @@
         "webpack-merge": "^5.7.3",
         "webpack-virtual-modules": "^0.4.2",
         "whatwg-fetch": "^3.6.2"
+      },
+      "dependencies": {
+        "@vue/vue-loader-v15": {
+          "version": "npm:vue-loader@15.11.1",
+          "resolved": "https://registry.npmmirror.com/vue-loader/-/vue-loader-15.11.1.tgz",
+          "integrity": "sha512-0iw4VchYLePqJfJu9s62ACWUXeSqM30SQqlIftbYWM3C+jpPcEHKSPUZBLjSF9au4HTHQ/naF6OGnO3Q/qGR3Q==",
+          "dev": true,
+          "requires": {
+            "@vue/component-compiler-utils": "^3.1.0",
+            "hash-sum": "^1.0.2",
+            "loader-utils": "^1.1.0",
+            "vue-hot-reload-api": "^2.3.0",
+            "vue-style-loader": "^4.1.0"
+          },
+          "dependencies": {
+            "hash-sum": {
+              "version": "1.0.2",
+              "resolved": "https://registry.npmmirror.com/hash-sum/-/hash-sum-1.0.2.tgz",
+              "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==",
+              "dev": true
+            }
+          }
+        },
+        "json5": {
+          "version": "1.0.2",
+          "resolved": "https://registry.npmmirror.com/json5/-/json5-1.0.2.tgz",
+          "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
+          "dev": true,
+          "requires": {
+            "minimist": "^1.2.0"
+          }
+        },
+        "loader-utils": {
+          "version": "1.4.2",
+          "resolved": "https://registry.npmmirror.com/loader-utils/-/loader-utils-1.4.2.tgz",
+          "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
+          "dev": true,
+          "requires": {
+            "big.js": "^5.2.2",
+            "emojis-list": "^3.0.0",
+            "json5": "^1.0.1"
+          }
+        }
       }
     },
     "@vue/cli-shared-utils": {
@@ -2593,47 +2636,6 @@
       "integrity": "sha512-RoorRB50WehYbsiWu497q8egZBYlrvOo9KBUG41uth4O023Cbs+7POLm9uw2CAiViBAIhvpw1Y4w4i+MZxOfXw==",
       "dev": true
     },
-    "@vue/vue-loader-v15": {
-      "version": "npm:vue-loader@15.10.2",
-      "resolved": "https://registry.npmmirror.com/vue-loader/-/vue-loader-15.10.2.tgz",
-      "integrity": "sha512-ndeSe/8KQc/nlA7TJ+OBhv2qalmj1s+uBs7yHDRFaAXscFTApBzY9F1jES3bautmgWjDlDct0fw8rPuySDLwxw==",
-      "dev": true,
-      "requires": {
-        "@vue/component-compiler-utils": "^3.1.0",
-        "hash-sum": "^1.0.2",
-        "loader-utils": "^1.1.0",
-        "vue-hot-reload-api": "^2.3.0",
-        "vue-style-loader": "^4.1.0"
-      },
-      "dependencies": {
-        "hash-sum": {
-          "version": "1.0.2",
-          "resolved": "https://registry.npmmirror.com/hash-sum/-/hash-sum-1.0.2.tgz",
-          "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==",
-          "dev": true
-        },
-        "json5": {
-          "version": "1.0.2",
-          "resolved": "https://registry.npmmirror.com/json5/-/json5-1.0.2.tgz",
-          "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
-          "dev": true,
-          "requires": {
-            "minimist": "^1.2.0"
-          }
-        },
-        "loader-utils": {
-          "version": "1.4.2",
-          "resolved": "https://registry.npmmirror.com/loader-utils/-/loader-utils-1.4.2.tgz",
-          "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
-          "dev": true,
-          "requires": {
-            "big.js": "^5.2.2",
-            "emojis-list": "^3.0.0",
-            "json5": "^1.0.1"
-          }
-        }
-      }
-    },
     "@vue/web-component-wrapper": {
       "version": "1.3.0",
       "resolved": "https://registry.npmmirror.com/@vue/web-component-wrapper/-/web-component-wrapper-1.3.0.tgz",
@@ -6602,6 +6604,11 @@
         "@sideway/pinpoint": "^2.0.0"
       }
     },
+    "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=="
+    },
     "js-base64": {
       "version": "2.6.4",
       "resolved": "https://registry.npmmirror.com/js-base64/-/js-base64-2.6.4.tgz",

+ 1 - 0
package.json

@@ -14,6 +14,7 @@
     "core-js": "^3.33.2",
     "dompurify": "^3.0.6",
     "element-ui": "^2.15.14",
+    "js-audio-recorder": "^1.0.7",
     "js-cookie": "^3.0.5",
     "md5": "^2.3.0",
     "nprogress": "^0.2.0",

BIN
src/assets/record-ing.png


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


BIN
src/assets/voice-play-white.png


+ 2 - 2
src/icons/svg/audio.svg

@@ -1,10 +1,10 @@
 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
 <g clip-path="url(#clip0_955_16891)">
-<path d="M5.50251 8.33366L8.3335 6.01738V13.9832L5.50251 11.667H2.50016V8.33366H5.50251ZM1.66683 13.3337H4.90757L9.31966 16.9435C9.39408 17.0044 9.48733 17.0377 9.5835 17.0377C9.81358 17.0377 10.0002 16.8512 10.0002 16.621V3.37957C10.0002 3.28339 9.96691 3.19016 9.906 3.11572C9.76025 2.93762 9.49775 2.91137 9.31966 3.05709L4.90757 6.66697H1.66683C1.2066 6.66697 0.833496 7.04006 0.833496 7.5003V12.5003C0.833496 12.9606 1.2066 13.3337 1.66683 13.3337ZM19.1668 10.0002C19.1668 12.7436 17.9617 15.2055 16.052 16.8854L14.8706 15.7039C16.48 14.3283 17.5002 12.2834 17.5002 10.0002C17.5002 7.71704 16.48 5.67215 14.8706 4.29655L16.052 3.11507C17.9617 4.79498 19.1668 7.25687 19.1668 10.0002ZM15.0002 10.0002C15.0002 8.40716 14.2552 6.98814 13.0946 6.07252L11.9037 7.26344C12.7679 7.8657 13.3335 8.86691 13.3335 10.0002C13.3335 11.1336 12.7679 12.1347 11.9037 12.737L13.0946 13.9279C14.2552 13.0123 15.0002 11.5932 15.0002 10.0002Z" fill="white"/>
+<path d="M5.50251 8.33366L8.3335 6.01738V13.9832L5.50251 11.667H2.50016V8.33366H5.50251ZM1.66683 13.3337H4.90757L9.31966 16.9435C9.39408 17.0044 9.48733 17.0377 9.5835 17.0377C9.81358 17.0377 10.0002 16.8512 10.0002 16.621V3.37957C10.0002 3.28339 9.96691 3.19016 9.906 3.11572C9.76025 2.93762 9.49775 2.91137 9.31966 3.05709L4.90757 6.66697H1.66683C1.2066 6.66697 0.833496 7.04006 0.833496 7.5003V12.5003C0.833496 12.9606 1.2066 13.3337 1.66683 13.3337ZM19.1668 10.0002C19.1668 12.7436 17.9617 15.2055 16.052 16.8854L14.8706 15.7039C16.48 14.3283 17.5002 12.2834 17.5002 10.0002C17.5002 7.71704 16.48 5.67215 14.8706 4.29655L16.052 3.11507C17.9617 4.79498 19.1668 7.25687 19.1668 10.0002ZM15.0002 10.0002C15.0002 8.40716 14.2552 6.98814 13.0946 6.07252L11.9037 7.26344C12.7679 7.8657 13.3335 8.86691 13.3335 10.0002C13.3335 11.1336 12.7679 12.1347 11.9037 12.737L13.0946 13.9279C14.2552 13.0123 15.0002 11.5932 15.0002 10.0002Z" fill="currentColor"/>
 </g>
 <defs>
 <clipPath id="clip0_955_16891">
-<rect width="20" height="20" fill="white"/>
+<rect width="20" height="20" fill="currentColor"/>
 </clipPath>
 </defs>
 </svg>

+ 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>

+ 1 - 1
src/icons/svg/first-tone.svg

@@ -1,3 +1,3 @@
 <svg width="14" height="4" viewBox="0 0 14 4" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M2 2H12" stroke="#4E5969" stroke-width="2.5" stroke-linecap="round"/>
+<path d="M2 2H12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
 </svg>

+ 2 - 2
src/icons/svg/fourth-tone.svg

@@ -1,3 +1,3 @@
-<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M2 2L6.2948 5.86532C6.69013 6.22111 7.29479 6.20521 7.67087 5.82913L12 1.5" stroke="#4E5969" stroke-width="2.5" stroke-linecap="round"/>
+<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.75977 1.35059L10.2402 6.64978" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
 </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>

+ 1 - 1
src/icons/svg/neutral-tone.svg

@@ -1,3 +1,3 @@
 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
-<circle cx="6" cy="6" r="4.25" stroke="#4E5969" stroke-width="2.5"/>
+<circle cx="6" cy="6" r="4.25" stroke="currentColor" stroke-width="2.5"/>
 </svg>

+ 1 - 1
src/icons/svg/second-tone.svg

@@ -1,3 +1,3 @@
 <svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M1.75977 6.64941L10.2402 1.35022" stroke="#4E5969" stroke-width="2.5" stroke-linecap="round"/>
+<path d="M1.75977 6.64941L10.2402 1.35022" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
 </svg>

+ 2 - 2
src/icons/svg/third-tone.svg

@@ -1,3 +1,3 @@
-<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M1.75977 1.35059L10.2402 6.64978" stroke="#4E5969" stroke-width="2.5" stroke-linecap="round"/>
+<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2 2L6.2948 5.86532C6.69013 6.22111 7.29479 6.20521 7.67087 5.82913L12 1.5" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
 </svg>

+ 33 - 2
src/views/exercise_questions/create/components/common/AudioPlay.vue

@@ -1,7 +1,16 @@
 <template>
   <div class="audio-wrapper">
-    <span class="audio-play" @click="playAudio">
-      <SvgIcon :size="audio.paused ? 20 : 14" :icon-class="iconClass" />
+    <span :class="[url ? 'audio-play' : 'audio-play not-url']" @click="playAudio">
+      <SvgIcon v-if="audio.paused" :size="audio.paused ? 20 : 14" :icon-class="iconClass" />
+      <img
+        v-else
+        :src="
+          themeColor === 'gray'
+            ? require('../../../../../assets/voice-play-gray.png')
+            : require('../../../../../assets/voice-play-white.png')
+        "
+        class="voice-play"
+      />
     </span>
     <audio ref="audio" :src="url" preload="metadata"></audio>
   </div>
@@ -17,6 +26,10 @@ export default {
       type: String,
       required: true,
     },
+    themeColor: {
+      type: String,
+      default: '',
+    },
   },
   data() {
     return {
@@ -55,7 +68,14 @@ export default {
   },
   methods: {
     playAudio() {
+      if (!this.url) return;
       const audio = this.$refs.audio;
+      let audioArr = document.getElementsByTagName('audio');
+      if (audioArr && audioArr.length > 0) {
+        for (let i = 0; i < audioArr.length; i++) {
+          audioArr[i].pause();
+        }
+      }
       audio.paused ? audio.play() : audio.pause();
     },
   },
@@ -70,9 +90,20 @@ export default {
     justify-content: center;
     width: 40px;
     height: 40px;
+    color: #fff;
     cursor: pointer;
     background-color: $main-color;
     border-radius: 50%;
+
+    &.not-url {
+      color: #a1a1a1;
+      cursor: not-allowed;
+    }
+
+    .voice-play {
+      width: 20px;
+      height: 20px;
+    }
   }
 }
 </style>

+ 2 - 0
src/views/exercise_questions/create/components/common/QuestionMixin.js

@@ -1,6 +1,7 @@
 // 题目混入
 import QuestionBase from './QuestionBase.vue';
 import RichText from '@/components/common/RichText.vue';
+import AudioPlay from '@/views/exercise_questions/create/components/common/AudioPlay.vue';
 
 import {
   stemTypeList,
@@ -23,6 +24,7 @@ const mixin = {
   components: {
     QuestionBase,
     RichText,
+    AudioPlay,
   },
   methods: {
     upload(file_id) {

+ 214 - 0
src/views/exercise_questions/create/components/common/SoundRecord.vue

@@ -0,0 +1,214 @@
+<template>
+  <div class="sound-record-wrapper">
+    <SvgIcon
+      v-if="audio.paused"
+      icon-class="audio"
+      :class="['audio-play-btn', wavBlob ? '' : 'not-url']"
+      @click="playMicrophone"
+    />
+    <img
+      v-else
+      :src="require('../../../../../assets/voice-play-gray.png')"
+      class="voice-play"
+      @click="playMicrophone"
+    />
+    <span :class="['record-time', microphoneStatus ? 'record-ing' : '']"
+      >{{ audio.paused ? '' : '-' }}{{ handleDateTime(recordTimes) }}</span
+    >
+    <img
+      v-if="microphoneStatus"
+      :src="require('../../../../../assets/record-ing.png')"
+      class="voice-play"
+      @click="microphone"
+    />
+    <SvgIcon v-else icon-class="mic-line" class="record" @click="microphone" />
+    <SvgIcon icon-class="delete-back-line" :class="['delete-btn', wavBlob ? '' : 'not-url']" @click="delectWav" />
+
+    <audio ref="audio" :src="wavBlob" preload="metadata"></audio>
+  </div>
+</template>
+
+<script>
+import Recorder from 'js-audio-recorder'; // 录音插件
+export default {
+  name: 'SoundRecord',
+  props: {
+    wavBlob: {
+      type: String,
+      default: '',
+    },
+    recordTime: {
+      type: Number,
+      default: 0,
+    },
+    itemIndex: {
+      type: Number,
+      default: null,
+    },
+  },
+  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
+      }),
+      timer: null, // 计时器
+      microphoneStatus: false, // 是否录音
+      hasMicro: '', // 录音后的样式class
+      audio: {
+        paused: true,
+      },
+      playtime: 0, // 播放时间
+      recordTimes: JSON.parse(JSON.stringify(this.recordTime)),
+    };
+  },
+  computed: {},
+  watch: {},
+  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);
+        }
+      }
+    },
+    // 格式化录音时长
+    handleDateTime(time) {
+      let times = 0;
+      if (parseInt(time / 60) < 10) {
+        times = `${`0${parseInt(time / 60)}`.substring(`0${parseInt(time / 60)}`.length - 2)}:${`0${
+          time % 60
+        }`.substring(`0${time % 60}`.length - 2)}`;
+      } else {
+        times = `${parseInt(time / 60)}:${`0${time % 60}`.substring(`0${time % 60}`.length - 2)}`;
+      }
+      return times;
+    },
+    // 开始录音
+    microphone() {
+      if (this.microphoneStatus) {
+        this.hasMicro = 'normal';
+        this.recorder.stop();
+        clearInterval(this.timer);
+        let tolTime = this.recorder.duration; // 录音总时长
+        // 录音结束,获取取录音数据
+        let wav = this.recorder.getWAVBlob(); // 获取 WAV 数据
+        this.microphoneStatus = false;
+        let reader = new window.FileReader();
+        reader.readAsDataURL(wav);
+        reader.onloadend = () => {
+          this.$emit('updataWav', this.itemIndex, reader.result, Math.floor(tolTime));
+        };
+      } else {
+        this.hasMicro = '';
+        this.$emit('updataWav', this.itemIndex, '', 0);
+        // 开始录音
+        this.recorder.start();
+        this.microphoneStatus = true;
+        this.recordTimes = 0;
+        clearInterval(this.timer);
+        this.timer = setInterval(() => {
+          this.recordTimes += 1;
+        }, 1000);
+      }
+    },
+    // 删除录音
+    delectWav() {
+      this.$refs.audio.pause();
+      this.hasMicro = '';
+      this.microphoneStatus = false;
+      this.playtime = 0;
+      this.recordTimes = 0;
+      clearInterval(this.timer);
+      this.$emit('deleteWav', this.itemIndex);
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.sound-record-wrapper {
+  display: flex;
+  align-items: center;
+  padding: 5px 12px;
+  background: #f2f3f5;
+  border-radius: 2px;
+
+  .audio-play-btn {
+    cursor: pointer;
+
+    &.not-url {
+      color: #a1a1a1;
+      cursor: not-allowed;
+    }
+  }
+
+  .record-time {
+    min-width: 52px;
+    margin: 0 12px;
+    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;
+    }
+  }
+}
+</style>

+ 148 - 15
src/views/exercise_questions/create/components/exercises/ChooseToneQuestion.vue

@@ -30,14 +30,28 @@
             <span class="question-number" @dblclick="changeOptionType(data)">
               {{ computedQuestionNumber(i, data.option_number_show_mode) }}.
             </span>
-            <div class="option-content">
-              <RichText v-model="item.content" placeholder="输入内容" :inline="true" />
+            <el-input v-model="item.content" placeholder="输入内容" @blur="handleItemAnswer(item)" />
+            <UploadAudio
+              v-if="data.other.audio_generation_method === 'upload'"
+              :key="item.audio_file_id || i"
+              :file-id="item.audio_file_id"
+              :item-index="i"
+              @upload="uploads"
+              @deleteFile="deleteFiles"
+            />
+            <div v-else-if="data.other.audio_generation_method === 'auto'" class="auto-matically">
+              <AudioPlay :file-id="item.audio_file_id" theme-color="gray" />
+              <span class="auto-btn" @click="handleMatically">自动生成</span>
             </div>
-            <UploadAudio :file-id="item.audio_file_id" />
-            <span v-for="({ img }, j) in toneList" :key="j" class="tone">
-              <SvgIcon :icon-class="img" />
-            </span>
-            <SvgIcon icon-class="delete" class="delete pointer" @click="deleteOption(i)" />
+            <SoundRecord
+              v-else
+              :wav-blob="item.audio_wav"
+              :record-time="item.audio_wav_time"
+              :item-index="i"
+              @deleteWav="deleteWav"
+              @updataWav="updataWav"
+            />
+            <SvgIcon icon-class="delete" class="delete pointer" @click="deleteOption(i, item.audio_file_id)" />
           </li>
         </ul>
       </div>
@@ -101,8 +115,9 @@
           <el-radio
             v-for="{ value, label } in toneTypeList"
             :key="value"
-            v-model="data.property.tone_type"
+            v-model="data.property.answer_mode"
             :label="value"
+            @change="handleChangeType"
           >
             {{ label }}
           </el-radio>
@@ -111,7 +126,7 @@
           <el-radio
             v-for="{ value, label } in audioGenerationMethodList"
             :key="value"
-            v-model="data.property.audio_generation_method"
+            v-model="data.other.audio_generation_method"
             :label="value"
           >
             {{ label }}
@@ -125,6 +140,7 @@
 <script>
 import QuestionMixin from '@/views/exercise_questions/create/components/common/QuestionMixin';
 import UploadAudio from '../common/UploadAudio.vue';
+import SoundRecord from '../common/SoundRecord.vue';
 
 import { changeOptionType } from '@/views/exercise_questions/data/common';
 import {
@@ -139,6 +155,7 @@ export default {
   name: 'ChooseToneQuestion',
   components: {
     UploadAudio,
+    SoundRecord,
   },
   mixins: [QuestionMixin],
   data() {
@@ -154,6 +171,82 @@ export default {
     addOption() {
       this.data.option_list.push(getOption());
     },
+    uploads(file_id, index) {
+      this.data.option_list[index].audio_file_id = file_id;
+      this.data.file_id_list.push(file_id);
+    },
+    deleteFiles(file_id, itemIndex) {
+      this.data.option_list[itemIndex].audio_file_id = '';
+      this.data.file_id_list.splice(this.data.file_id_list.indexOf(file_id), 1);
+    },
+    // 删除小题
+    deleteOption(i, file_id) {
+      this.data.option_list.splice(i, 1);
+      this.data.file_id_list.splice(this.data.file_id_list.indexOf(file_id), 1);
+    },
+    // 自动生成音频
+    handleMatically() {},
+    // 清除录音
+    deleteWav(index) {
+      this.data.option_list[index].audio_wav = '';
+      this.data.option_list[index].audio_wav_time = 0;
+    },
+    // 更新录音内容和时间
+    updataWav(index, wav, time) {
+      this.data.option_list[index].audio_wav = wav;
+      this.data.option_list[index].audio_wav_time = time;
+    },
+    // 答案
+    handleItemAnswer(item) {
+      const index = this.data.answer.answer_list.findIndex((items) => items.mark === item.mark);
+      let content = item.content.trim();
+      let content_arr = content.split(' ');
+      let select_item = '';
+      let content_preview = '';
+      content_arr.forEach((items) => {
+        let items_trim = items.trim();
+        if (items_trim) {
+          let items_yuan = JSON.parse(JSON.stringify(items_trim)).replace(/0|1|2|3|4/, '');
+          let indexs = -1;
+          if (items.indexOf('0') > -1) {
+            indexs = items.indexOf('0');
+          } else if (items.indexOf('1') > -1) {
+            indexs = items.indexOf('1');
+          } else if (items.indexOf('2') > -1) {
+            indexs = items.indexOf('2');
+          } else if (items.indexOf('3') > -1) {
+            indexs = items.indexOf('3');
+          } else if (items.indexOf('4') > -1) {
+            indexs = items.indexOf('4');
+          }
+          if (this.data.property.answer_mode === 'select') {
+            // 如果是选择声调 把声调放在拼音后面
+            // select_item += `${items_yuan + items_trim.substring(indexs, indexs + 1)} `;
+            select_item += `${items_trim.substring(indexs, indexs + 1)} `;
+          } else if (this.data.property.answer_mode === 'label') {
+            // 如果是标注声调 把声调放在对应字母后面
+            select_item += `${items_trim} `;
+          }
+          content_preview += `${items_yuan} `;
+        }
+      });
+      if (index === -1) {
+        let obj = {
+          mark: item.mark,
+          value: select_item.trim().split(' '),
+        };
+        this.data.answer.answer_list.push(obj);
+      } else {
+        this.data.answer.answer_list[index].value = select_item.trim().split(' ');
+      }
+      item.content_view = content_preview.trim().split(' ');
+    },
+    // 改变类型
+    handleChangeType() {
+      this.data.option_list.forEach((item) => {
+        this.handleItemAnswer(item);
+      });
+    },
   },
 };
 </script>
@@ -174,14 +267,54 @@ export default {
       margin-top: 0;
     }
 
-    .tone {
+    :deep .file-name {
+      width: 140px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    .auto-matically {
       display: flex;
       align-items: center;
-      justify-content: center;
-      width: 32px;
-      height: 32px;
-      cursor: pointer;
-      background-color: $fill-color;
+      width: 233px;
+      padding: 5px 12px;
+      background: #f2f3f5;
+      border-radius: 2px;
+
+      .audio-wrapper {
+        margin-right: 12px;
+
+        :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-btn {
+        font-size: 14px;
+        font-weight: 400;
+        line-height: 22px;
+        color: #1d2129;
+        cursor: pointer;
+      }
+    }
+
+    .delete {
+      flex-shrink: 0;
+      width: 16px;
+      height: 16px;
     }
   }
 }

+ 1 - 1
src/views/exercise_questions/create/components/exercises/RepeatQuestion.vue

@@ -35,7 +35,7 @@
               <RichText v-model="item.content" placeholder="输入内容" :inline="true" />
             </div>
             <UploadAudio
-              :key="i + Math.random().toString(36).slice(-6)"
+              :key="item.audio_file_id || i"
               :file-id="item.file_id_list?.[0]"
               :item-index="i"
               @upload="uploads"

+ 1 - 1
src/views/exercise_questions/create/components/exercises/WriteQuestion.vue

@@ -29,7 +29,7 @@
         <template v-if="data.property.is_enable_sample_text">
           <el-divider class="write-divider" />
           <label class="title-little">范文:</label>
-          <RichText v-model="data.sample_text" placeholder="输入范文" />
+          <RichText v-model="data.sample_text" placeholder="输入范文" :word-limit="5000" />
           <p class="tips">多篇范文之间使用分割线(---)</p>
         </template>
       </div>

+ 13 - 5
src/views/exercise_questions/data/chooseTone.js

@@ -2,7 +2,14 @@ import { stemTypeList, questionNumberTypeList, scoreTypeList, optionTypeList } f
 import { getRandomNumber } from '@/utils/index';
 
 export function getOption(content = '') {
-  return { content, mark: getRandomNumber(), audio_file_id: '', tone: '' };
+  return {
+    content,
+    mark: getRandomNumber(),
+    content_view: [],
+    audio_file_id: '',
+    audio_wav: '',
+    audio_wav_time: 0,
+  };
 }
 
 export const toneList = [
@@ -15,7 +22,7 @@ export const toneList = [
 
 export const toneTypeList = [
   { value: 'select', label: '选择声调' },
-  { value: 'dimension', label: '标注声调' },
+  { value: 'label', label: '标注声调' },
 ];
 
 export const audioGenerationMethodList = [
@@ -40,19 +47,20 @@ export const ChooseToneData = {
   description: '', // 描述
   option_number_show_mode: optionTypeList[0].value, // 选项类型
   option_list: [getOption(), getOption(), getOption()], // 选项
-  answer: { score: 0, score_type: scoreTypeList[0].value, select_list: '' }, // 答案
+  file_id_list: [],
+  answer: { score: 0, score_type: scoreTypeList[0].value, answer_list: [] }, // 答案
   // 题型属性
   property: {
     stem_type: stemTypeList[0].value, // 题干类型
     question_number: 1, // 题号
     is_enable_description: false, // 描述
-    tone_type: toneTypeList[0].value, // 音调类型
-    audio_generation_method: audioGenerationMethodList[0].value, // 音频生成方式
+    answer_mode: toneTypeList[0].value, // 音调类型
     score: 1, // 分值
     score_type: scoreTypeList[0].value, // 分值类型
   },
   // 其他属性
   other: {
     question_number_type: questionNumberTypeList[0].value, // 题号类型
+    audio_generation_method: audioGenerationMethodList[0].value, // 音频生成方式
   },
 };

+ 1 - 1
src/views/exercise_questions/data/write.js

@@ -7,7 +7,7 @@ export const writeData = {
   description: '', // 描述
   article: '', // 文章
   sample_text: '', // 范文
-  answer: { select_list: [], score: 0, score_type: scoreTypeList[0].value }, // 答案
+  answer: { score: 0, score_type: scoreTypeList[0].value }, // 答案
   // 题型属性
   property: {
     stem_type: stemTypeList[0].value, // 题干类型

+ 285 - 3
src/views/exercise_questions/preview/ChooseTonePreview.vue

@@ -7,20 +7,250 @@
     </div>
     <div v-if="data.property.is_enable_description" class="description">{{ data.description }}</div>
 
-    <div class="option-list"></div>
+    <div class="option-list">
+      <li v-for="(item, i) in data.option_list" :key="i" :class="['option-item']">
+        <span>{{ computeOptionMethods[data.option_number_show_mode](i) }}. </span>
+        <AudioPlay v-if="item.audio_file_id" :file-id="item.audio_file_id" />
+        <div class="option-content">
+          <template v-if="data.property.answer_mode === 'select'">
+            <span
+              v-for="(itemc, indexc) in con_preview[i].item_con"
+              :key="indexc"
+              :class="['item-con', active_index_str === i + '-' + indexc ? 'active' : '']"
+              @click="
+                con_preview[i].item_active_index = indexc;
+                active_index_str = i + '-' + indexc;
+              "
+            >
+              {{ itemc }}
+            </span>
+          </template>
+          <template v-else>
+            <span v-for="(itemc, indexc) in con_preview[i].item_con" :key="indexc" class="items-box">
+              <span
+                v-for="(itemi, indexi) in itemc"
+                :key="indexi"
+                :class="['items-con', active_index_str === i + '-' + indexc + '-' + indexi ? 'active' : '']"
+                @click="handleSelectItemTone(i, indexc, indexi, con_preview[i].item_con_yuan[indexc][indexi])"
+                >{{ itemi }}</span
+              >
+            </span>
+          </template>
+        </div>
+        <span
+          v-for="({ img, value }, j) in toneList"
+          :key="j"
+          :class="[
+            'tone',
+            data.property.answer_mode === 'select' &&
+            con_preview[i].user_answer[con_preview[i].item_active_index].select_tone === value
+              ? 'active'
+              : data.property.answer_mode === 'label' &&
+                con_preview[i].user_answer[con_preview[i].item_active_index].select_tone === value &&
+                con_preview[i].user_answer[con_preview[i].item_active_index].select_letter === active_letter &&
+                select_item_index === i
+              ? 'active'
+              : '',
+          ]"
+          @click="chooseTone(con_preview[i], value, i)"
+        >
+          <SvgIcon :icon-class="img" />
+        </span>
+      </li>
+    </div>
   </div>
 </template>
 
 <script>
+import { computeOptionMethods } from '@/views/exercise_questions/data/common';
 import PreviewMixin from './components/PreviewMixin';
 
 export default {
   name: 'ChooseTonePreview',
   mixins: [PreviewMixin],
   data() {
-    return {};
+    return {
+      computeOptionMethods,
+      toneList: [
+        { value: '1', label: '一声', img: 'first-tone' },
+        { value: '2', label: '二声', img: 'second-tone' },
+        { value: '3', label: '三声', img: 'third-tone' },
+        { value: '4', label: '四声', img: 'fourth-tone' },
+        { value: '0', label: '轻声', img: 'neutral-tone' },
+      ],
+      con_preview: [],
+      tone_data: [
+        ['ā', 'á', 'ǎ', 'à', 'a'],
+        ['ō', 'ó', 'ǒ', 'ò', 'o'],
+        ['ē', 'é', 'ě', 'è', 'e'],
+        ['ī', 'í', 'ǐ', 'ì', 'i'],
+        ['ū', 'ú', 'ǔ', 'ù', 'u'],
+        ['ǖ', 'ǘ', 'ǚ', 'ǜ', 'ü'],
+        ['Ā', 'Á', 'Â', 'À', 'A'],
+        ['Ō', 'Ó', 'Ô', 'Ò', 'O'],
+        ['Ē', 'É', 'Ê', 'È', 'E'],
+        ['Ī', 'Í', 'Î', 'Ì', 'I'],
+        ['Ū', 'Ú', 'Û', 'Ù', 'U'],
+      ],
+      final_con: '',
+      active_index_str: '', // 高亮索引的字符串
+      active_letter: '', // 选中字母的值
+      active_letter_index: 0, // 选择字母索引
+      select_item_index: 0, // 小题索引
+    };
+  },
+  created() {
+    this.handleData();
+  },
+  methods: {
+    chooseTone(item, value, i) {
+      if (!this.active_letter && this.data.property.answer_mode === 'label') return;
+      item.user_answer[item.item_active_index].select_tone = value;
+      if (this.data.property.answer_mode === 'label') {
+        item.user_answer[item.item_active_index].select_letter = this.active_letter;
+        this.active_index_str = `${i}-${item.item_active_index}-${this.active_letter_index}`;
+        this.handleReplaceTone(this.active_letter + value);
+        setTimeout(() => {
+          // item.item_con[item.item_active_index][this.active_letter_index] = this.final_con;
+          let new_con = item.item_con_yuan[item.item_active_index].split(this.active_letter);
+          item.item_con[item.item_active_index] = new_con[0] + this.final_con + new_con[1];
+          // this.active_letter = this.final_con;
+          this.$forceUpdate();
+        }, 100);
+      } else {
+        this.active_index_str = `${i}-${item.item_active_index}`;
+        this.handleReplaceTone(item.item_con_yuan[item.item_active_index] + value);
+        setTimeout(() => {
+          item.item_con[item.item_active_index] = this.final_con;
+          this.$forceUpdate();
+        }, 100);
+      }
+    },
+    // 处理数据
+    handleData() {
+      this.con_preview = [];
+      this.data.option_list.forEach((item) => {
+        let con_arr = item.content_view;
+        let user_answer = [];
+        con_arr.forEach(() => {
+          user_answer.push({
+            select_tone: null,
+            select_letter: '',
+          });
+        });
+        let obj = {
+          item_con: con_arr,
+          item_con_yuan: JSON.parse(JSON.stringify(con_arr)),
+          mark: item.mark,
+          user_answer,
+          item_active_index: 0,
+          active_letter: '',
+        };
+        this.con_preview.push(obj);
+      });
+    },
+    handleReplaceTone(e) {
+      this.$nextTick(() => {
+        let value = e;
+        this.resArr = [];
+        if (value) {
+          let reg = /\s+/g;
+          let valueArr = value.split(reg);
+          valueArr.forEach((item) => {
+            this.handleValue(item);
+          });
+          let str = '';
+          setTimeout(() => {
+            this.resArr.forEach((item) => {
+              str += ' ';
+              item.forEach((sItem) => {
+                if (sItem.number && sItem.con) {
+                  let number = Number(sItem.number);
+                  let con = sItem.con;
+                  let word = this.addTone(number, con);
+                  str += word;
+                } else if (sItem.number) {
+                  str += sItem.number;
+                } else if (sItem.con) {
+                  str += ` ${sItem.con} `;
+                }
+              });
+            });
+            this.final_con = str.trim();
+          }, 10);
+        }
+      });
+    },
+    handleValue(valItem) {
+      let reg = /\d/;
+      let reg2 = /[A-Za-z]+\d/g;
+      let numList = [];
+      let valArr = valItem.split('');
+      if (reg2.test(valItem)) {
+        for (let i = 0; i < valArr.length; i++) {
+          let item = valItem[i];
+          if (reg.test(item)) {
+            let numIndex = numList.length === 0 ? 0 : numList[numList.length - 1].index;
+            let con = valItem.substring(numIndex, i);
+            con = con.replace(/\d/g, '');
+            let obj = {
+              index: i,
+              number: item,
+              con,
+              isTran: true,
+            };
+            numList.push(obj);
+          }
+        }
+      } else {
+        numList = [];
+      }
+      if (numList.length === 0) {
+        this.resArr.push([{ con: valItem }]);
+      } else {
+        this.resArr.push(numList);
+      }
+    },
+    addTone(number, con) {
+      let 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.indexOf(zm) > -1) {
+            let zm2 = this.tone_data[i][number - 1];
+            if (con.indexOf('iu') > -1) {
+              zm2 = this.tone_data[4][number - 1];
+              cons = con.replace('u', zm2);
+            } else if (con.indexOf('ui') > -1) {
+              zm2 = this.tone_data[3][number - 1];
+              cons = con.replace('i', zm2);
+            } else if (
+              con.indexOf('yv') > -1 ||
+              con.indexOf('jv') > -1 ||
+              con.indexOf('qv') > -1 ||
+              con.indexOf('xv') > -1
+            ) {
+              zm2 = this.tone_data[4][number - 1];
+              cons = con.replace('v', zm2);
+            } else {
+              cons = con.replace(zm, zm2);
+            }
+
+            break;
+          }
+        }
+      }
+      return cons;
+    },
+    handleSelectItemTone(i, indexc, indexi, itemi) {
+      this.con_preview[i].item_active_index = indexc;
+      this.active_index_str = `${i}-${indexc}-${indexi}`;
+      this.active_letter = itemi;
+      this.active_letter_index = indexi;
+      this.select_item_index = i;
+    },
   },
-  methods: {},
 };
 </script>
 
@@ -29,5 +259,57 @@ export default {
 
 .choosetone-preview {
   @include preview;
+
+  .option-list {
+    display: flex;
+    flex-direction: column;
+    flex-flow: wrap;
+    row-gap: 16px;
+
+    .option-item {
+      display: flex;
+      column-gap: 16px;
+      align-items: center;
+      width: 45%;
+      margin-right: 5%;
+
+      .option-content {
+        padding: 12px 24px;
+        color: #706f78;
+        background-color: #f9f8f9;
+        border-radius: 40px;
+      }
+
+      .item-con,
+      .items-con {
+        color: #000;
+        cursor: pointer;
+
+        &.active {
+          color: #2f6fec;
+        }
+      }
+
+      .items-box {
+        margin-right: 3px;
+      }
+
+      .tone {
+        width: 32px;
+        height: 32px;
+        padding: 8px;
+        font-size: 0;
+        color: #9f9f9f;
+        text-align: center;
+        cursor: pointer;
+
+        &.active {
+          color: #2f6fec;
+          background: #dfe9fd;
+          border-radius: 16px;
+        }
+      }
+    }
+  }
 }
 </style>

+ 33 - 2
src/views/exercise_questions/preview/WritePreview.vue

@@ -5,7 +5,18 @@
       <span class="question-number">{{ data.property.question_number }}.</span>
       <span v-html="sanitizeHTML(data.stem)"></span>
     </div>
+    <div v-html="sanitizeHTML(data.article)"></div>
     <div v-if="data.property.is_enable_description" class="description">{{ data.description }}</div>
+    <template v-if="data.property.is_enable_model_essay">
+      <el-divider content-position="center"
+        ><span
+          :class="['sample-text', show_sample_text ? 'sample-show' : 'sample-hide']"
+          @click="show_sample_text = !show_sample_text"
+          >{{ show_sample_text ? '隐藏范文' : '查看范文' }}</span
+        ></el-divider
+      >
+      <div v-if="show_sample_text" v-html="sanitizeHTML(data.sample_text)"></div>
+    </template>
   </div>
 </template>
 
@@ -16,9 +27,13 @@ export default {
   name: 'WritePreview',
   mixins: [PreviewMixin],
   data() {
-    return {};
+    return {
+      show_sample_text: false,
+    };
+  },
+  created() {
+    console.log(this.data);
   },
-  created() {},
   methods: {},
 };
 </script>
@@ -28,5 +43,21 @@ export default {
 
 .write-preview {
   @include preview;
+
+  .el-divider--horizontal {
+    margin: 12px 0;
+  }
+
+  .sample-text {
+    font-size: 14px;
+    font-weight: 400;
+    line-height: 30px;
+    color: #000;
+    cursor: pointer;
+
+    &.sample-show {
+      color: #306eff;
+    }
+  }
 }
 </style>