Explorar el Código

对话题扩展

dusenyao hace 1 año
padre
commit
5e1e00f4af

+ 7 - 0
src/api/app.js

@@ -48,6 +48,13 @@ export function ParseAccessToken(data) {
 }
 
 /**
+ * 保存文件字节流的 Base64 编码文本信息
+ */
+export function SaveFileByteBase64Text(data) {
+  return http.post(`${process.env.VUE_APP_FileServer}?MethodName=file_store_manager-SaveFileByteBase64Text`, data);
+}
+
+/**
  * 上传文件
  * @param {String} SecurityLevel 保密级别
  * @param {object} file 文件对象

BIN
src/assets/record.png


+ 44 - 26
src/components/common/SoundRecord.vue

@@ -1,49 +1,67 @@
-<!-- 录音 -->
 <template>
-  <div class="sound-record">
-    <span @click="startRecord">开始录音</span>
+  <div class="sound-record-wrapper">
+    <img
+      :src="microphoneStatus ? require('../../assets/record-ing-hasBg.png') : require('../../assets/record.png')"
+      class="voice-play"
+      @click="microphone"
+    />
   </div>
 </template>
 
 <script>
+import Recorder from 'js-audio-recorder'; // 录音插件
+
+import { SaveFileByteBase64Text } from '@/api/app';
+
 export default {
   name: 'SoundRecord',
   data() {
     return {
-      isRecording: false,
-      recordedAudio: null,
-      mediaRecorder: null,
-      chunks: [],
+      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, // 是否录音
     };
   },
   methods: {
     // 开始录音
-    startRecord() {
-      navigator.mediaDevices
-        .getUserMedia({ audio: true })
-        .then((stream) => {
-          this.mediaRecorder = new MediaRecorder(stream);
-          this.mediaRecorder.start();
-          this.isRecording = true;
-          this.chunks = [];
-          this.mediaRecorder.addEventListener('dataavailable', (e) => {
-            this.chunks.push(e.data);
+    microphone() {
+      if (this.microphoneStatus) {
+        this.recorder.stop();
+        // 录音结束,获取取录音数据
+        let wav = this.recorder.getWAVBlob(); // 获取 WAV 数据
+        this.microphoneStatus = false;
+        let reader = new window.FileReader();
+        reader.readAsDataURL(wav);
+        reader.onloadend = () => {
+          SaveFileByteBase64Text({
+            base64_text: reader.result.replace('data:audio/wav;base64,', ''),
+            file_suffix_name: 'mp3',
+          }).then(({ file_id }) => {
+            this.$emit('saveWav', file_id);
           });
-          this.mediaRecorder.addEventListener('stop', () => {
-            this.recordedAudio = new Blob(this.chunks, { type: 'audio/mp3' });
-            this.isRecording = false;
-          });
-        })
-        .catch((err) => {
-          console.log(err);
-        });
+        };
+      } else {
+        // 开始录音
+        this.recorder.start();
+        this.microphoneStatus = true;
+      }
     },
   },
 };
 </script>
 
 <style lang="scss" scoped>
-.sound-record {
+.sound-record-wrapper {
   display: flex;
+  align-items: center;
+
+  .voice-play {
+    width: 32px;
+    height: 32px;
+    cursor: pointer;
+  }
 }
 </style>

+ 3 - 0
src/icons/svg/music.svg

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.6663 1.75V9.91667C11.6663 11.2053 10.6216 12.25 9.33301 12.25C8.04437 12.25 6.99967 11.2053 6.99967 9.91667C6.99967 8.62802 8.04437 7.58333 9.33301 7.58333C9.75803 7.58333 10.1565 7.69697 10.4997 7.89547V2.91667H5.24967V9.91667C5.24967 11.2053 4.20501 12.25 2.91634 12.25C1.62768 12.25 0.583008 11.2053 0.583008 9.91667C0.583008 8.62802 1.62768 7.58333 2.91634 7.58333C3.34134 7.58333 3.7398 7.69697 4.08301 7.89547V1.75H11.6663ZM2.91634 11.0833C3.56067 11.0833 4.08301 10.561 4.08301 9.91667C4.08301 9.27232 3.56067 8.75 2.91634 8.75C2.27201 8.75 1.74967 9.27232 1.74967 9.91667C1.74967 10.561 2.27201 11.0833 2.91634 11.0833ZM9.33301 11.0833C9.97736 11.0833 10.4997 10.561 10.4997 9.91667C10.4997 9.27232 9.97736 8.75 9.33301 8.75C8.68866 8.75 8.16634 9.27232 8.16634 9.91667C8.16634 10.561 8.68866 11.0833 9.33301 11.0833Z" fill="#175DFF"/>
+</svg>

+ 3 - 0
src/icons/svg/picture.svg

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2.91667 6.47529L4.08333 5.30862L7.29167 8.51696L9.33333 6.47529L11.0833 8.22529V2.91667H2.91667V6.47529ZM2.91667 8.12519V11.0833H4.72529L6.46672 9.34191L4.08333 6.95852L2.91667 8.12519ZM6.37519 11.0833H11.0833V9.87519L9.33333 8.12519L6.37519 11.0833ZM2.33333 1.75H11.6667C11.9888 1.75 12.25 2.01117 12.25 2.33333V11.6667C12.25 11.9888 11.9888 12.25 11.6667 12.25H2.33333C2.01117 12.25 1.75 11.9888 1.75 11.6667V2.33333C1.75 2.01117 2.01117 1.75 2.33333 1.75ZM9.04167 5.83333C8.55843 5.83333 8.16667 5.44158 8.16667 4.95833C8.16667 4.47508 8.55843 4.08333 9.04167 4.08333C9.5249 4.08333 9.91667 4.47508 9.91667 4.95833C9.91667 5.44158 9.5249 5.83333 9.04167 5.83333Z" fill="#175DFF"/>
+</svg>

+ 21 - 8
src/views/exercise_questions/create/components/common/AudioPlay.vue

@@ -5,6 +5,8 @@
       :style="{
         padding: showSlider ? '4px 16px' : '',
         width: showSlider ? '230px' : '',
+        justifyContent: showSlider && !showProgress ? 'space-between' : 'center',
+        backgroundColor: backgroundColor,
       }"
       @click="playAudio"
     >
@@ -20,13 +22,15 @@
       />
 
       <template v-if="showSlider">
-        <span class="audio-time">{{ secondFormatConversion(audio.current_time) }}</span>
-        <el-slider
-          v-model="play_value"
-          class="audio-slider"
-          :format-tooltip="formatProcessToolTip"
-          @change="changeCurrentTime"
-        />
+        <template v-if="showProgress">
+          <span class="audio-time">{{ secondFormatConversion(audio.current_time) }}</span>
+          <el-slider
+            v-model="play_value"
+            class="audio-slider"
+            :format-tooltip="formatProcessToolTip"
+            @change="changeCurrentTime"
+          />
+        </template>
         <span class="audio-time">{{ audio_allTime }}</span>
       </template>
     </span>
@@ -62,6 +66,16 @@ export default {
       type: Boolean,
       default: false,
     },
+    // 是否显示音频进度条
+    showProgress: {
+      type: Boolean,
+      default: true,
+    },
+    // 播放背景色
+    backgroundColor: {
+      type: String,
+      default: '#165dff',
+    },
   },
   data() {
     return {
@@ -186,7 +200,6 @@ export default {
     height: 40px;
     color: #fff;
     cursor: pointer;
-    background-color: $main-color;
     border-radius: 20px;
 
     &.not-url {

+ 388 - 83
src/views/exercise_questions/create/components/exercises/DialogueQuestion.vue

@@ -11,14 +11,92 @@
         />
       </div>
 
-      <div class="content">
-        <span class="subtitle">输入对话:</span>
-        <el-input v-model="data.dialogue" :autosize="{ minRows: 3 }" type="textarea" placeholder="输入对话" />
+      <div class="content-wrapper">
+        <div class="content">
+          <div v-for="({ role, type, text, file_id }, i) in data.option_list" :key="i" class="option-list">
+            <span
+              class="avatar"
+              :style="{ backgroundColor: data.property.role_list.find((item) => item.mark === role).color }"
+            >
+              {{ data.property.role_list.find((item) => item.mark === role).name }}
+            </span>
+
+            <div v-if="type === 'text'" class="text">{{ text }}</div>
+
+            <div v-else-if="type === 'image'" class="image">
+              <img :src="file_map_list[file_id]" />
+            </div>
+
+            <div v-else-if="type === 'audio'" class="audio">
+              <AudioPlay
+                :file-id="file_id"
+                :show-slider="true"
+                :show-progress="false"
+                :background-color="data.property.role_list.find((item) => item.mark === role).color"
+              />
+            </div>
+
+            <div class="content-operation">
+              <div class="up-down">
+                <span :style="{ borderBottomColor: i === 0 ? '#c2c2c4' : '#000' }" @click="moveOption('up', i)"></span>
+                <span
+                  :style="{ borderTopColor: i < data.option_list.length - 1 ? '#000' : '#c2c2c4' }"
+                  @click="moveOption('down', i)"
+                ></span>
+              </div>
+              <SvgIcon icon-class="delete" @click="deleteOption(i)" />
+            </div>
+          </div>
+        </div>
+
+        <div class="operation">
+          <div class="operation-left">
+            <el-select v-model="curRole" placeholder="请选择">
+              <el-option
+                v-for="(item, i) in data.property.role_list"
+                :key="i"
+                :label="item.name.length === 0 ? ' ' : item.name"
+                :value="item.mark"
+              />
+            </el-select>
+            <SoundRecord @saveWav="saveWav" />
+          </div>
+          <div class="operation-right">
+            <el-upload
+              action="no"
+              accept="audio/*"
+              :show-file-list="false"
+              :http-request="handleAudio"
+              :before-upload="handleBeforeAudio"
+            >
+              <SvgIcon icon-class="music" />
+              <span>上传音频</span>
+            </el-upload>
+            <el-upload
+              action="no"
+              accept="image/*"
+              :show-file-list="false"
+              :http-request="handleImage"
+              :before-upload="handleBeforeImage"
+            >
+              <SvgIcon icon-class="picture" />
+              <span>上传图片</span>
+            </el-upload>
+          </div>
+        </div>
+
+        <el-input
+          v-model="textInput"
+          placeholder="输入"
+          type="textarea"
+          resize="none"
+          :autosize="{ minRows: 3 }"
+          @keyup.enter.native="handleText"
+        />
       </div>
 
       <el-button @click="identifyText">识别</el-button>
       <div v-if="data.answer.answer_list.length > 0" class="correct-answer">
-        <div class="subtitle">正确答案</div>
         <el-input v-for="(item, i) in data.answer.answer_list" :key="item.mark" v-model="item.value">
           <span slot="prefix">{{ i + 1 }}.</span>
         </el-input>
@@ -77,104 +155,208 @@
             {{ label }}
           </el-radio>
         </el-form-item>
+        <el-form-item label="角色数">
+          <el-select v-model="data.property.role_number" placeholder="请选择">
+            <el-option v-for="item in [2, 3, 4, 5]" :key="item" :label="item" :value="item" />
+          </el-select>
+        </el-form-item>
+        <el-form-item v-for="(item, i) in data.property.role_list" :key="i" :label="`角色 ${i + 1}`" class="role">
+          <el-input v-model="item.name" />
+          <el-color-picker v-model="item.color" />
+        </el-form-item>
       </el-form>
     </template>
   </QuestionBase>
 </template>
 
 <script>
-import RichText from '@/components/common/RichText.vue';
 import QuestionMixin from '../common/QuestionMixin.js';
+import AudioPlay from '../common/AudioPlay.vue';
+import SoundRecord from '@/components/common/SoundRecord.vue';
 
 import { getRandomNumber } from '@/utils';
-import { dialogueData } from '@/views/exercise_questions/data/dialogue';
+import { fileUpload, GetFileURLMap } from '@/api/app';
+import { getDialogueData, getRole } from '@/views/exercise_questions/data/dialogue';
 
 export default {
   name: 'DialogueQuestion',
-  components: { RichText },
+  components: {
+    AudioPlay,
+    SoundRecord,
+  },
   mixins: [QuestionMixin],
   data() {
     return {
-      data: JSON.parse(JSON.stringify(dialogueData)),
+      curRole: '',
+      textInput: '',
+      file_id_list: [],
+      file_map_list: [],
+      data: getDialogueData(),
     };
   },
   watch: {
-    'data.dialogue': {
+    'data.property.role_number'(val) {
+      if (this.data.property.role_list.length > val) {
+        this.data.property.role_list = this.data.property.role_list.slice(0, val);
+      } else {
+        for (let i = this.data.property.role_list.length; i < val; i++) {
+          this.data.property.role_list.push(getRole(i));
+        }
+      }
+    },
+    'data.property.role_list': {
+      handler(val) {
+        if (val.find((item) => item.mark === this.curRole) === undefined && val.length > 0) {
+          this.curRole = val[0].mark;
+        }
+      },
+      immediate: true,
+    },
+    'data.option_list': {
       handler(val) {
-        if (!val) {
+        let file_id_list = [];
+        val.forEach(({ type, file_id }) => {
+          if (type === 'image' || type === 'audio') {
+            file_id_list.push(file_id);
+          }
+        });
+        // 判断 this.file_id_list 和 file_id_list 两个数组中的内容是否相等,位置可能不同
+        if (
+          this.file_id_list.length === file_id_list.length &&
+          this.file_id_list.every((item) => file_id_list.includes(item))
+        ) {
           return;
         }
-        let direction = 'row';
-        let preName = '';
-        this.data.option_list = val
-          // 用换行分割 data.dialogue
-          .split('\n')
-          // 过滤掉空行
-          .filter((item) => item.trim() !== '')
-          .map((item) => {
-            // 分割对话中的人物和对话内容
-            const match = item.match(/(\S+)[::](.+)/);
-            if (match) {
-              let name = match[1];
-              if (preName.length === 0 || preName !== name) {
-                direction = preName.length === 0 ? 'row' : direction === 'row' ? 'row-reverse' : 'row';
-              }
-              preName = name;
-              let content = match[2];
-              let reg = /_{3,}/;
-              let isFill = reg.test(content);
-              let fillList = [];
-              if (isFill) {
-                content = content.replace(/(_{3,})/g, '###$1###');
-                fillList = content
-                  .split('###')
-                  .filter((item) => item)
-                  .map((content) => {
-                    let isInput = reg.test(content);
-                    return {
-                      content: isInput ? '' : content,
-                      mark: getRandomNumber(),
-                      type: isInput ? 'input' : 'text',
-                    };
-                  });
-              }
-              return {
-                name,
-                content: isFill ? fillList : content,
-                direction,
-                audio_file_id: '',
-                mark: isFill ? getRandomNumber() : '',
-                type: isFill ? 'input' : 'text',
-              };
-            }
-          })
-          .filter((item) => item);
+        this.file_id_list = file_id_list;
+        GetFileURLMap({ file_id_list }).then(({ url_map }) => {
+          this.file_map_list = url_map;
+        });
       },
     },
   },
   methods: {
+    // 识别
     identifyText() {
-      this.data.answer.answer_list = this.data.option_list
-        .filter(({ type }) => type === 'input')
-        .map(({ content }) => {
-          return content
-            .filter(({ type }) => type === 'input')
-            .map(({ content, mark }) => {
-              return {
-                value: content,
-                mark,
-                type: 'only_one',
-              };
-            });
-        })
-        .flat();
+      let answer_list = [];
+      this.data.option_list.forEach(({ type, content_list }) => {
+        if (type !== 'text') return;
+        content_list.forEach(({ type, mark }) => {
+          if (type !== 'input') return;
+          answer_list.push({
+            mark,
+            value: this.data.answer.answer_list.find((item) => item.mark === mark)?.value || '',
+          });
+        });
+      });
+      this.data.answer.answer_list = answer_list;
+    },
+    handleBeforeAudio(file) {
+      if (this.curRole.length <= 0) {
+        this.$message.error('请先选择角色');
+        return false;
+      }
+      // 判断文件是否为音频
+      if (!file.type.includes('audio')) {
+        this.$message.error('请选择音频文件');
+        return false;
+      }
+    },
+    handleBeforeImage(file) {
+      if (this.curRole.length <= 0) {
+        this.$message.error('请先选择角色');
+        return false;
+      }
+      // 判断文件是否为图片
+      if (!file.type.includes('image')) {
+        this.$message.error('请选择图片文件');
+        return false;
+      }
+    },
+    handleAudio(file) {
+      this.upload('audio', file);
+    },
+    handleImage(file) {
+      this.upload('image', file);
+    },
+    upload(type, file) {
+      fileUpload('Mid', file, { isGlobalprogress: true }).then(({ file_info_list }) => {
+        if (file_info_list.length > 0) {
+          const { file_id } = file_info_list[0];
+          this.data.option_list.push({
+            role: this.curRole,
+            file_id,
+            type,
+          });
+        }
+      });
+    },
+    handleText() {
+      if (this.curRole.length <= 0) {
+        return this.$message.error('请先选择角色');
+      }
+      let text = this.textInput.replace(/\n/, '');
+      let reg = /_{3,}/;
+      let hasFill = reg.test(text); // 是否有填空
+      let content_list = [];
+      if (hasFill) {
+        text = text.replace(/(_{3,})/g, '###$1###');
+        content_list = text
+          .split('###')
+          .filter((item) => item)
+          .map((content) => {
+            let isInput = reg.test(content);
+            return {
+              content: isInput ? '' : content,
+              mark: isInput ? getRandomNumber() : '',
+              type: isInput ? 'input' : 'text',
+            };
+          });
+      } else {
+        content_list = [
+          {
+            content: text,
+            mark: '',
+            type: 'text',
+          },
+        ];
+      }
+      this.data.option_list.push({
+        role: this.curRole,
+        text: this.textInput.replace(/\n/, ''),
+        mark: getRandomNumber(),
+        file_id: '',
+        content_list,
+        type: 'text',
+      });
+      this.textInput = '';
+    },
+    moveOption(type, i) {
+      if ((type === 'up' && i === 0) || (type === 'down' && i === this.data.option_list.length - 1)) return;
+      const item = this.data.option_list[i];
+      this.data.option_list.splice(i, 1);
+      this.data.option_list.splice(type === 'up' ? i - 1 : i + 1, 0, item);
+    },
+    deleteOption(i) {
+      let type = this.data.option_list[i].type;
+      this.data.option_list.splice(i, 1);
+      // 如果删除的是文本,需要重新识别
+      if (type === 'text') {
+        this.identifyText();
+      }
+    },
+    saveWav(file_id) {
+      this.data.option_list.push({
+        role: this.curRole,
+        file_id,
+        type: 'audio',
+      });
     },
   },
 };
 </script>
 
 <style lang="scss" scoped>
-.content {
+.content-wrapper {
   display: flex;
   flex-direction: column;
   row-gap: 8px;
@@ -182,23 +364,115 @@ export default {
   margin-bottom: 8px;
   border-bottom: $border;
 
-  .subtitle {
-    font-size: 14px;
+  .content {
+    display: flex;
+    flex-direction: column;
+    row-gap: 16px;
+    padding: 16px;
+    background-color: $fill-color;
+
+    .option-list {
+      display: flex;
+      column-gap: 8px;
+
+      .avatar {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 40px;
+        min-width: 40px;
+        height: 40px;
+        min-height: 40px;
+        margin-right: 8px;
+        font-size: 12px;
+        color: #fff;
+        border-radius: 50%;
+      }
+
+      .content-operation {
+        display: flex;
+        column-gap: 8px;
+        align-items: center;
+
+        .up-down {
+          display: flex;
+          flex-direction: column;
+          row-gap: 12px;
+
+          // span 三角形
+          :first-child {
+            width: 0;
+            height: 0;
+            cursor: pointer;
+            border-color: transparent;
+            border-style: solid;
+            border-width: 4px;
+          }
+
+          :last-child {
+            width: 0;
+            height: 0;
+            cursor: pointer;
+            border-color: transparent;
+            border-style: solid;
+            border-width: 4px;
+          }
+        }
+
+        .svg-icon {
+          cursor: pointer;
+        }
+      }
+
+      .text {
+        padding: 8px 12px;
+        font-size: 14px;
+        word-break: break-all;
+        background-color: #fff;
+        border-radius: 8px;
+      }
+
+      .image img {
+        border-radius: 8px;
+      }
+    }
   }
 
-  + .content {
-    padding-top: 8px;
-    margin-top: 8px;
-    border-top: $border;
+  .operation {
+    display: flex;
+    justify-content: space-between;
+    font-size: 14px;
+
+    &-left {
+      display: flex;
+      column-gap: 8px;
+      align-items: center;
+
+      .el-select {
+        width: 130px;
+      }
+    }
+
+    &-right {
+      display: flex;
+      column-gap: 36px;
+      align-items: center;
+      color: $main-color;
+
+      :deep .el-upload {
+        display: flex;
+        column-gap: 8px;
+        align-items: center;
+      }
+    }
   }
 }
 
 .correct-answer {
-  .subtitle {
-    margin: 8px 0;
-    font-size: 14px;
-    color: #4e5969;
-  }
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px 8px;
+  margin-top: 8px;
 
   .el-input {
     width: 180px;
@@ -208,9 +482,40 @@ export default {
       align-items: center;
       color: $text-color;
     }
+  }
+}
+
+.el-form {
+  .role {
+    :deep .el-form-item__content {
+      display: flex;
+      flex: 1;
+
+      .el-input {
+        width: 100px;
+        margin-right: 16px;
+      }
+
+      .el-color-picker {
+        flex: 1;
+
+        &__trigger {
+          width: 100%;
+          background-color: $fill-color;
+        }
+
+        &__color {
+          width: 22px;
+          height: 22px;
+          border-width: 0;
+        }
 
-    + .el-input {
-      margin-left: 8px;
+        &__icon {
+          left: 88%;
+          width: 20px;
+          color: $font-color;
+        }
+      }
     }
   }
 }

+ 5 - 3
src/views/exercise_questions/create/index.vue

@@ -298,11 +298,13 @@ export default {
      * @param {object} param0 移动参数
      * @param {number} param0.newIndex 移动后的索引
      */
-    onEnd({ newIndex }) {
+    onEnd({ newIndex, oldIndex }) {
+      let order = newIndex > oldIndex;
+      // TODO dest_position 1 不起作用
       MoveQuestion({
         question_id: this.index_list[newIndex].id,
-        dest_question_id: this.index_list[newIndex + 1].id,
-        dest_position: 0,
+        dest_question_id: this.index_list[order ? newIndex - 1 : newIndex + 1].id,
+        dest_position: order ? 1 : 0,
       })
         .then(() => {
           this.$message.success('移动成功');

+ 40 - 22
src/views/exercise_questions/data/dialogue.js

@@ -1,25 +1,43 @@
+import { getRandomNumber } from '@/utils';
 import { stemTypeList, questionNumberTypeList, scoreTypeList, switchOption } from './common';
 
+export const roleDefaultColorList = ['#306EFF', '#3ABD38', '#FC8E3D', '#FC493D', '#BF3DFC']; // 角色默认颜色
+
+/**
+ * 获取角色对象
+ * @param {number} index 序号
+ */
+export function getRole(index) {
+  return {
+    name: `角色${index + 1}`,
+    mark: getRandomNumber(),
+    color: roleDefaultColorList[index],
+  };
+}
+
 // 对话题数据模板
-export const dialogueData = {
-  type: 'dialogue', // 题型
-  stem: '', // 题干
-  description: '', // 描述
-  dialogue: '', // 对话内容,用于编辑显示
-  option_list: [], // 选项
-  file_id_list: [], // 文件 id 列表
-  answer: { score: 1, score_type: scoreTypeList[0].value, answer_list: [] }, // 答案
-  // 题型属性
-  property: {
-    stem_type: stemTypeList[1].value, // 题干类型
-    question_number: '1', // 题号
-    score: 1, // 分值
-    is_enable_description: switchOption[0].value, // 描述
-    is_enable_voice_answer: switchOption[0].value, // 语音作答
-    score_type: scoreTypeList[0].value, // 分值类型
-  },
-  // 其他属性
-  other: {
-    question_number_type: questionNumberTypeList[1].value, // 题号类型
-  },
-};
+export function getDialogueData() {
+  return {
+    type: 'dialogue', // 题型
+    stem: '', // 题干
+    description: '', // 描述
+    option_list: [], // 选项列表
+    file_id_list: [], // 文件 id 列表
+    answer: { score: 1, score_type: scoreTypeList[0].value, answer_list: [] }, // 答案
+    // 题型属性
+    property: {
+      stem_type: stemTypeList[1].value, // 题干类型
+      question_number: '1', // 题号
+      score: 1, // 分值
+      is_enable_description: switchOption[0].value, // 描述
+      is_enable_voice_answer: switchOption[0].value, // 语音作答
+      score_type: scoreTypeList[0].value, // 分值类型
+      role_number: 2, // 角色数 2 - 5
+      role_list: [getRole(0), getRole(1)], // 角色列表
+    },
+    // 其他属性
+    other: {
+      question_number_type: questionNumberTypeList[1].value, // 题号类型
+    },
+  };
+}

+ 2 - 2
src/views/exercise_questions/data/questionType.js

@@ -8,7 +8,7 @@ import { essayQuestionData } from './essayQuestion';
 import { readAloudData } from './readAloud';
 import { repeatData } from './repeat';
 import { talkPictrueData } from './talkPicture';
-import { dialogueData } from './dialogue';
+import { getDialogueData } from './dialogue';
 import { answerQuestionData } from './answerQuestion';
 import { replaceAnswerData } from './replaceAnswer';
 import { getListenSelectData } from './listenSelect';
@@ -45,7 +45,7 @@ export const questionTypeDataOption = [
       { label: '朗读题', value: 'read_aloud', data: readAloudData },
       { label: '听说训练', value: 'repeat', data: repeatData },
       { label: '看图说话', value: 'talk_picture', data: talkPictrueData },
-      { label: '对话题', value: 'dialogue', data: dialogueData },
+      { label: '对话题', value: 'dialogue', data: getDialogueData() },
       { label: '口语表达', value: 'answer_question', data: answerQuestionData },
       { label: '替换练习', value: 'replace_answer', data: replaceAnswerData },
     ],

+ 214 - 86
src/views/exercise_questions/preview/DialoguePreview.vue

@@ -10,39 +10,59 @@
       class="description rich-text"
       v-html="sanitizeHTML(data.description)"
     ></div>
-    <div class="dialogue-wrapper">
-      <ul>
-        <li
-          v-for="(item, i) in data.option_list"
-          :key="i"
-          :style="{ flexDirection: item.direction }"
-          class="dialogue-item"
+
+    <div class="content">
+      <div v-for="(item, i) in optionList" :key="i" class="option-list">
+        <span
+          class="avatar"
+          :style="{ backgroundColor: data.property.role_list.find(({ mark }) => mark === item.role).color }"
         >
-          <span class="name" :style="{ backgroundColor: item.direction === 'row' ? '#4F73F4' : '#3ABD38' }">
-            {{ item.name }}
-          </span>
-          <div class="content" :style="{ backgroundColor: item.direction === 'row' ? '#fff' : '#d0f3de' }">
-            <template v-if="item.type === 'text'">
-              {{ item.content }}
-            </template>
-            <template v-else>
-              <div v-for="(li, j) in item.content" :key="j">
-                <template v-if="li.type === 'input'">
-                  <el-input v-model="li.content" :class="[item.direction === 'row' ? 'is_left' : 'is_right']" />
-                </template>
-                <template v-else>
-                  {{ li.content }}
-                </template>
-              </div>
+          {{ data.property.role_list.find(({ mark }) => mark === item.role).name }}
+        </span>
+
+        <div v-if="item.type === 'text'" class="text-wrapper">
+          <div class="text">
+            <template v-for="(li, j) in item.content_list">
+              <span v-if="li.type === 'text'" :key="j">{{ li.content }}</span>
+              <template v-else-if="li.type === 'input'">
+                <el-input
+                  :key="j"
+                  v-model="li.content"
+                  :disabled="disabled"
+                  :class="[...computedAnswerClass(li.mark, li.content)]"
+                  :style="[{ width: Math.max(80, li.content.length * 16) + 'px' }]"
+                />
+                <span
+                  v-show="computedAnswerText(li.mark, li.content).length > 0"
+                  :key="`answer-${j}`"
+                  class="right-answer"
+                >
+                  {{ computedAnswerText(li.mark, li.content) }}
+                </span>
+              </template>
             </template>
           </div>
           <SoundRecordPreview
-            v-if="item.type === 'input' && isEnable(data.property.is_enable_voice_answer)"
-            :wav-blob.sync="item.audio_file_id"
+            v-if="isEnable(data.property.is_enable_voice_answer)"
+            :wav-blob.sync="item.file_id"
+            :disabled="disabled"
             type="small"
           />
-        </li>
-      </ul>
+        </div>
+
+        <div v-else-if="item.type === 'image'" class="image">
+          <img :src="file_map_list[item.file_id]" />
+        </div>
+
+        <div v-else-if="item.type === 'audio'" class="audio">
+          <AudioPlay
+            :file-id="item.file_id"
+            :show-slider="true"
+            :show-progress="false"
+            :background-color="data.property.role_list.find(({ mark }) => mark === item.role).color"
+          />
+        </div>
+      </div>
     </div>
   </div>
 </template>
@@ -51,6 +71,8 @@
 import PreviewMixin from './components/PreviewMixin';
 import SoundRecordPreview from './components/common/SoundRecordPreview.vue';
 
+import { GetFileURLMap } from '@/api/app';
+
 export default {
   name: 'DialoguePreview',
   components: {
@@ -58,36 +80,98 @@ export default {
   },
   mixins: [PreviewMixin],
   data() {
-    return {};
+    return {
+      optionList: [],
+      file_map_list: [],
+    };
   },
   watch: {
     'data.option_list': {
       handler(val) {
-        if (!val) return;
-        this.answer.answer_list = val
-          .map(({ type, content, audio_file_id, mark }) => {
-            if (type === 'text') return;
-            let _content = content
-              .filter((item) => item.type === 'input')
-              .map(({ content, ...data }) => {
-                return {
-                  value: content,
-                  ...data,
-                };
-              });
-            return {
-              content: _content,
-              mark,
-              audio_file_id,
-            };
-          })
-          .filter((item) => item);
+        let list = JSON.parse(JSON.stringify(val));
+        let file_id_list = [];
+        list.forEach(({ type, file_id }) => {
+          if (type === 'image' || type === 'audio') {
+            file_id_list.push(file_id);
+          }
+        });
+        GetFileURLMap({ file_id_list }).then(({ url_map }) => {
+          this.file_map_list = url_map;
+        });
+        this.optionList = list;
+      },
+      deep: true,
+      immediate: true,
+    },
+    optionList: {
+      handler(val) {
+        this.answer.answer_list = [];
+        val.forEach(({ type, content_list, mark, file_id }) => {
+          if (type !== 'text') return;
+          let list = content_list
+            .map(({ type, mark, content }) => {
+              if (type !== 'input') return;
+              return {
+                mark,
+                content,
+              };
+            })
+            .filter((item) => item);
+          this.answer.answer_list.push({
+            content_list: list,
+            file_id,
+            mark,
+          });
+        });
       },
       deep: true,
       immediate: true,
     },
+    isJudgingRightWrong(val) {
+      if (!val) return;
+      this.answer.answer_list.forEach(({ mark, file_id, content_list }) => {
+        let find = this.optionList.find((item) => item.mark === mark);
+        find.file_id = file_id;
+        find.content_list.forEach((item) => {
+          if (item.type === 'text') return;
+          let find = content_list.find((li) => li.mark === item.mark);
+          item.content = find.content;
+        });
+      });
+    },
+  },
+  methods: {
+    /**
+     * 计算答题对错选项字体颜色
+     * @param {string} mark 选项标识
+     * @param {string} content 选项内容
+     */
+    computedAnswerClass(mark, content) {
+      if (!this.isJudgingRightWrong && !this.isShowRightAnswer) {
+        return '';
+      }
+      let rightValue = this.data.answer.answer_list.find((item) => item.mark === mark)?.value || '';
+      let classList = [];
+      let isRight = rightValue === content;
+
+      if (this.isJudgingRightWrong) {
+        isRight ? classList.push('right') : classList.push('wrong');
+      }
+
+      if (this.isShowRightAnswer && !isRight) {
+        classList.push('show-right-answer');
+      }
+      return classList;
+    },
+    computedAnswerText(mark, content) {
+      if (!this.isShowRightAnswer) return '';
+      if (content.length === 0) return '';
+      let find = this.data.answer.answer_list.find((item) => item.mark === mark);
+      if (find.value === content) return '';
+      if (!find) return '';
+      return `(${find.value})`;
+    },
   },
-  methods: {},
 };
 </script>
 
@@ -97,63 +181,107 @@ export default {
 .dialogue-preview {
   @include preview;
 
-  .dialogue-wrapper {
+  .content {
+    display: flex;
+    flex-direction: column;
+    row-gap: 16px;
     padding: 24px;
-    background-color: $content-color;
+    background-color: $fill-color;
     border-radius: 16px;
 
-    ul {
+    .option-list {
       display: flex;
-      flex-direction: column;
-      row-gap: 24px;
+      column-gap: 8px;
 
-      .dialogue-item {
+      .avatar {
         display: flex;
-        column-gap: 16px;
-
-        .name {
-          display: flex;
-          align-items: center;
-          justify-content: center;
-          width: 40px;
-          min-width: 40px;
-          height: 40px;
-          min-height: 40px;
-          font-size: 12px;
-          font-weight: bold;
-          color: #fff;
-          border-radius: 50%;
-        }
+        align-items: center;
+        justify-content: center;
+        width: 40px;
+        min-width: 40px;
+        height: 40px;
+        min-height: 40px;
+        margin-right: 8px;
+        font-size: 12px;
+        color: #fff;
+        border-radius: 50%;
+      }
+
+      .text-wrapper {
+        display: flex;
+        flex-direction: column;
+        row-gap: 8px;
+        align-items: flex-start;
 
-        .content {
-          display: flex;
-          flex-wrap: wrap;
-          align-items: flex-end;
-          padding: 8px 16px;
-          color: #000;
+        .text {
+          padding: 8px 12px;
+          font-size: 14px;
+          word-break: break-all;
+          background-color: #fff;
           border-radius: 8px;
 
           .el-input {
-            :deep .el-input__inner {
-              border-width: 0;
-              border-bottom: 1px solid #000;
-              border-radius: 0;
-            }
+            display: inline-flex;
+            align-items: center;
+            width: 120px;
+            margin: 0 2px;
 
-            &.is_left {
-              :deep .el-input__inner {
-                background-color: #fff;
+            &.right {
+              :deep input.el-input__inner {
+                color: $right-color;
               }
             }
 
-            &.is_right {
-              :deep .el-input__inner {
-                background-color: #d0f3de;
+            &.wrong {
+              :deep input.el-input__inner {
+                color: $error-color;
               }
             }
+
+            & + .right-answer {
+              position: relative;
+              left: -4px;
+              display: inline-block;
+              height: 32px;
+              font-size: 16px;
+              line-height: 28px;
+              vertical-align: bottom;
+              border-bottom: 1px solid $font-color;
+            }
+
+            :deep input.el-input__inner {
+              padding: 0;
+              font-size: 16px;
+              color: $font-color;
+              text-align: center;
+              background-color: #fff;
+              border-width: 0;
+              border-bottom: 1px solid $font-color;
+              border-radius: 0;
+            }
+          }
+        }
+
+        .sound-record-preview {
+          padding: 4px;
+          background-color: #fff;
+          border-radius: 40px;
+
+          :deep .sound-item .sound-item-span {
+            color: #1d1d1d;
+            background-color: #fff;
+          }
+
+          :deep .sound-item-luyin .sound-item-span {
+            color: #fff;
+            background-color: $light-main-color;
           }
         }
       }
+
+      .image img {
+        border-radius: 8px;
+      }
     }
   }
 }