Parcourir la source

复制出听力题

dusenyao il y a 1 an
Parent
commit
431fa6df4e

+ 9 - 0
src/views/exercise_questions/create/components/create.vue

@@ -55,6 +55,9 @@ import ChooseToneQuestion from './exercises/ChooseToneQuestion.vue';
 import RepeatQuestion from './exercises/RepeatQuestion.vue';
 import ReadQuestion from './exercises/ReadQuestion.vue';
 import SortQuestion from './exercises/SortQuestion.vue';
+import ListenSelectQuestion from './exercises/ListenSelectQuestion.vue';
+import ListenJudgeQuestion from './exercises/ListenJudgeQuestion.vue';
+import ListenFillQuestion from './exercises/ListenFillQuestion.vue';
 
 export default {
   name: 'CreateMain',
@@ -73,6 +76,9 @@ export default {
     RepeatQuestion,
     ReadQuestion,
     SortQuestion,
+    ListenSelectQuestion,
+    ListenJudgeQuestion,
+    ListenFillQuestion,
   },
   provide() {
     return {
@@ -109,6 +115,9 @@ export default {
         repeat: RepeatQuestion,
         read: ReadQuestion,
         sort: SortQuestion,
+        listen_select: ListenSelectQuestion,
+        listen_judge: ListenJudgeQuestion,
+        listen_fill: ListenFillQuestion,
       },
     };
   },

+ 300 - 0
src/views/exercise_questions/create/components/exercises/ListenFillQuestion.vue

@@ -0,0 +1,300 @@
+<template>
+  <QuestionBase>
+    <template #content>
+      <div class="stem">
+        <el-input
+          v-if="data.property.stem_type === stemTypeList[0].value"
+          v-model="data.stem"
+          rows="3"
+          resize="none"
+          type="textarea"
+          placeholder="输入题干"
+        />
+
+        <RichText v-if="data.property.stem_type === stemTypeList[1].value" v-model="data.stem" placeholder="输入题干" />
+
+        <UploadAudio
+          v-show="isEnable(data.property.is_enable_listening)"
+          :file-id="data.file_id_list?.[0]"
+          @upload="upload"
+          @deleteFile="deleteFile"
+        />
+
+        <el-input
+          v-show="isEnable(data.property.is_enable_description)"
+          v-model="data.description"
+          rows="3"
+          resize="none"
+          type="textarea"
+          placeholder="输入描述"
+        />
+      </div>
+
+      <div class="content">
+        <RichText
+          ref="modelEssay"
+          v-model="data.article"
+          :is-fill="true"
+          :toolbar="false"
+          :wordlimit-num="false"
+          placeholder="输入文段"
+          @showContentmenu="showContentmenu"
+          @hideContentmenu="hideContentmenu"
+        />
+        <div v-show="isShow" ref="contentmenu" :style="contentmenu" class="contentmenu">
+          <SvgIcon icon-class="slice" size="16" @click="setFill" />
+          <span class="button" @click="setFill">设为填空</span>
+          <span class="line"></span>
+          <SvgIcon icon-class="close-circle" size="16" @click="deleteFill" />
+          <span class="button" @click="deleteFill">删除填空</span>
+        </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.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>
+
+    <template #property>
+      <el-form :model="data.property">
+        <el-form-item label="题干">
+          <el-radio
+            v-for="{ value, label } in stemTypeList"
+            :key="value"
+            v-model="data.property.stem_type"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="题号">
+          <el-input v-model="data.property.question_number" />
+        </el-form-item>
+        <el-form-item label-width="45px">
+          <el-radio
+            v-for="{ value, label } in questionNumberTypeList"
+            :key="value"
+            v-model="data.other.question_number_type"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-radio
+            v-for="{ value, label } in switchOption"
+            :key="value"
+            v-model="data.property.is_enable_description"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="听力">
+          <el-radio
+            v-for="{ value, label } in switchOption"
+            :key="value"
+            v-model="data.property.is_enable_listening"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="分值">
+          <el-radio
+            v-for="{ value, label } in scoreTypeList"
+            :key="value"
+            v-model="data.property.score_type"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label-width="45px">
+          <el-input v-model="data.property.score" type="number" />
+        </el-form-item>
+      </el-form>
+    </template>
+  </QuestionBase>
+</template>
+
+<script>
+import UploadAudio from '../common/UploadAudio.vue';
+import QuestionMixin from '../common/QuestionMixin.js';
+
+import { getRandomNumber } from '@/utils';
+import { addTone } from '@/views/exercise_questions/data/common';
+import { fillData, handleToneValue } from '@/views/exercise_questions/data/listenFill';
+
+export default {
+  name: 'ListenFillQuestion',
+  components: {
+    UploadAudio,
+  },
+  mixins: [QuestionMixin],
+  data() {
+    return {
+      isShow: false,
+      contentmenu: {
+        top: 0,
+        left: 0,
+      },
+      data: JSON.parse(JSON.stringify(fillData)),
+    };
+  },
+  created() {
+    window.addEventListener('click', this.hideContentmenu);
+  },
+  beforeDestroy() {
+    window.removeEventListener('click', this.hideContentmenu);
+  },
+  methods: {
+    // 识别文本
+    identifyText() {
+      this.data.model_essay = [];
+      this.data.answer.answer_list = [];
+      this.data.article
+        .split(/<p>(.*?)<\/p>/gi)
+        .filter((item) => item)
+        .forEach((item) => {
+          if (item.charCodeAt() === 10) return;
+          // 匹配 span 标签和三个以上的_,并将它们组成数组
+          let str = item.replace(/<span.*?>(.*?)<\/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;
+    },
+    // 设置填空
+    setFill() {
+      this.$refs.modelEssay.setContent();
+      this.hideContentmenu();
+    },
+    // 删除填空
+    deleteFill() {
+      this.$refs.modelEssay.deleteContent();
+      this.hideContentmenu();
+    },
+    hideContentmenu() {
+      this.isShow = false;
+    },
+    showContentmenu({ pixelsFromLeft, pixelsFromTop }) {
+      this.isShow = true;
+      this.contentmenu = {
+        left: `${pixelsFromLeft + 14}px`,
+        top: `${pixelsFromTop - 18}px`,
+      };
+    },
+    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(' ');
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.content {
+  position: relative;
+
+  .el-button {
+    margin-top: 8px;
+  }
+
+  .correct-answer {
+    .subtitle {
+      margin: 8px 0;
+      font-size: 14px;
+      color: #4e5969;
+    }
+
+    .el-input {
+      width: 180px;
+
+      :deep &__prefix {
+        display: flex;
+        align-items: center;
+        color: $text-color;
+      }
+
+      + .el-input {
+        margin-left: 8px;
+      }
+    }
+  }
+
+  .contentmenu {
+    position: absolute;
+    z-index: 999;
+    display: flex;
+    column-gap: 4px;
+    align-items: center;
+    padding: 4px 8px;
+    font-size: 14px;
+    background-color: #fff;
+    border-radius: 2px;
+    box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 10%);
+
+    .svg-icon,
+    .button {
+      cursor: pointer;
+    }
+
+    .line {
+      min-height: 16px;
+      margin: 0 4px;
+    }
+  }
+}
+</style>

+ 240 - 0
src/views/exercise_questions/create/components/exercises/ListenJudgeQuestion.vue

@@ -0,0 +1,240 @@
+<template>
+  <QuestionBase>
+    <template #content>
+      <div class="stem">
+        <el-input
+          v-if="data.property.stem_type === stemTypeList[0].value"
+          v-model="data.stem"
+          rows="3"
+          resize="none"
+          type="textarea"
+          placeholder="输入题干"
+        />
+
+        <RichText v-if="data.property.stem_type === stemTypeList[1].value" v-model="data.stem" placeholder="输入题干" />
+
+        <UploadAudio
+          v-show="isEnable(data.property.is_enable_listening)"
+          :file-id="data.file_id_list?.[0]"
+          @upload="upload"
+          @deleteFile="deleteFile"
+        />
+      </div>
+
+      <div class="content">
+        <ul>
+          <li v-for="(item, i) in data.option_list" :key="i" class="content-item">
+            <span class="question-number" title="双击切换序号类型" @dblclick="changeOptionType(data)">
+              {{ computedQuestionNumber(i, data.option_number_show_mode) }}
+            </span>
+            <div class="option-content">
+              <RichText v-model="item.content" placeholder="输入内容" :inline="true" />
+            </div>
+            <div class="option-type">
+              <div
+                v-for="option_type in data.property.option_type_list"
+                :key="option_type"
+                :class="[
+                  'option-type-item',
+                  {
+                    active: data.answer.answer_list.find(
+                      (li) => li.mark === item.mark && li.option_type === option_type,
+                    ),
+                  },
+                ]"
+                @click="selectOptionAnswer(option_type, item.mark)"
+              >
+                <SvgIcon
+                  v-if="option_type === option_type_list[0].value"
+                  icon-class="check-mark"
+                  width="10"
+                  height="7"
+                />
+                <SvgIcon v-if="option_type === option_type_list[1].value" icon-class="cross" size="8" />
+                <SvgIcon v-if="option_type === option_type_list[2].value" icon-class="circle" size="10" />
+              </div>
+            </div>
+            <SvgIcon icon-class="delete" class="delete pointer" @click="deleteOption(i)" />
+          </li>
+        </ul>
+      </div>
+
+      <div class="footer">
+        <span class="add-option" @click="addOption">
+          <SvgIcon icon-class="add-circle" size="14" /> <span>增加选项</span>
+        </span>
+      </div>
+    </template>
+
+    <template #property>
+      <el-form :model="data.property">
+        <el-form-item label="题干">
+          <el-radio
+            v-for="{ value, label } in stemTypeList"
+            :key="value"
+            v-model="data.property.stem_type"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="题号">
+          <el-input v-model="data.property.question_number" />
+        </el-form-item>
+        <el-form-item label-width="45px">
+          <el-radio
+            v-for="{ value, label } in questionNumberTypeList"
+            :key="value"
+            v-model="data.other.question_number_type"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="听力">
+          <el-radio
+            v-for="{ value, label } in switchOption"
+            :key="value"
+            v-model="data.property.is_enable_listening"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="选项">
+          <el-checkbox-group v-model="optionTypeList">
+            <el-checkbox v-for="{ label, value } in option_type_list" :key="value" :label="label" />
+          </el-checkbox-group>
+        </el-form-item>
+        <el-form-item label="分值">
+          <el-radio
+            v-for="{ value, label } in scoreTypeList"
+            :key="value"
+            v-model="data.property.score_type"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label-width="45px">
+          <el-input v-model="data.property.score" type="number" />
+        </el-form-item>
+      </el-form>
+    </template>
+  </QuestionBase>
+</template>
+
+<script>
+import UploadAudio from '../common/UploadAudio.vue';
+import QuestionMixin from '../common/QuestionMixin.js';
+
+import { changeOptionType } from '@/views/exercise_questions/data/common';
+import {
+  getJudgeData,
+  option_type_list,
+  option_type_value_list,
+  getOption,
+} from '@/views/exercise_questions/data/listenJudge';
+
+export default {
+  name: 'ListenJudgeQuestion',
+  components: {
+    UploadAudio,
+  },
+  mixins: [QuestionMixin],
+  data() {
+    return {
+      option_type_list,
+      changeOptionType,
+      data: getJudgeData(),
+    };
+  },
+  computed: {
+    optionTypeList: {
+      get() {
+        return this.data.property.option_type_list
+          .map((item) => {
+            let type = this.option_type_list.find(({ value }) => value === item);
+            return type ? type.label : '';
+          })
+          .filter((word) => word && word.length > 0);
+      },
+      set(val) {
+        this.data.property.option_type_list = val
+          .map((item) => {
+            let type = this.option_type_list.find(({ label }) => label === item);
+            return type ? type.value : '';
+          })
+          .filter((word) => word && word.length > 0)
+          .sort((a, b) => option_type_value_list.indexOf(a) - option_type_value_list.indexOf(b));
+      },
+    },
+  },
+  methods: {
+    addOption() {
+      this.data.option_list.push(getOption());
+    },
+    /**
+     * 智能识别
+     * @param {String} text 识别数据
+     */
+    recognition(text) {
+      let arr = text
+        .split(/[\r\n]/)
+        .map((item) => item.trim())
+        .filter((item) => item);
+
+      if (arr.length > 0) {
+        this.data.stem = arr[0];
+        this.data.option_list = arr.slice(1).map((content) => getOption(content));
+        this.data.answer.answer_list = [];
+      }
+    },
+    /**
+     * 选择选项答案
+     * @param {String} option_type 选项类型
+     * @param {String} mark 选项标记
+     */
+    selectOptionAnswer(option_type, mark) {
+      const index = this.data.answer.answer_list.findIndex((item) => item.mark === mark);
+      if (index === -1) {
+        this.data.answer.answer_list.push({ option_type, mark });
+      } else {
+        this.data.answer.answer_list[index].option_type = option_type;
+      }
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.content {
+  &-item {
+    .option-type {
+      display: flex;
+      column-gap: 8px;
+      align-items: center;
+      height: 32px;
+      padding: 8px 16px;
+      background-color: $fill-color;
+
+      &-item {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 16px;
+        height: 16px;
+        color: $font-light-color;
+        cursor: pointer;
+        background-color: #d7d7d7;
+        border-radius: 2px;
+
+        &.active {
+          color: #fff;
+          background-color: $main-color;
+        }
+      }
+    }
+  }
+}
+</style>

+ 226 - 0
src/views/exercise_questions/create/components/exercises/ListenSelectQuestion.vue

@@ -0,0 +1,226 @@
+<!-- 选择题 -->
+<template>
+  <QuestionBase>
+    <template #content>
+      <div class="stem">
+        <el-input
+          v-if="data.property.stem_type === stemTypeList[0].value"
+          v-model="data.stem"
+          rows="3"
+          resize="none"
+          type="textarea"
+          placeholder="输入题干"
+        />
+
+        <RichText v-if="data.property.stem_type === stemTypeList[1].value" v-model="data.stem" placeholder="输入题干" />
+
+        <el-input
+          v-show="isEnable(data.property.is_enable_description)"
+          v-model="data.description"
+          rows="3"
+          resize="none"
+          type="textarea"
+          placeholder="输入描述"
+        />
+
+        <UploadAudio
+          v-show="isEnable(data.property.is_enable_listening)"
+          :file-id="data.file_id_list?.[0]"
+          @upload="upload"
+          @deleteFile="deleteFile"
+        />
+      </div>
+
+      <div class="content">
+        <ul>
+          <li v-for="(item, i) in data.option_list" :key="i" class="content-item">
+            <span class="question-number" title="双击切换序号类型" @dblclick="changeOptionType(data)">
+              {{ computedQuestionNumber(i, data.option_number_show_mode) }}
+            </span>
+            <div class="option-content">
+              <span :class="['checkbox', { active: isAnswer(item.mark) }]" @click="selectAnswer(item.mark)"></span>
+              <RichText v-model="item.content" placeholder="输入内容" :inline="true" />
+            </div>
+            <SvgIcon icon-class="delete" class="delete pointer" @click="deleteOption(i)" />
+          </li>
+        </ul>
+      </div>
+
+      <div class="footer">
+        <span class="add-option" @click="addOption">
+          <SvgIcon icon-class="add-circle" size="14" /> <span>增加选项</span>
+        </span>
+      </div>
+    </template>
+
+    <template #property>
+      <el-form :model="data.property">
+        <el-form-item label="题干">
+          <el-radio
+            v-for="{ value, label } in stemTypeList"
+            :key="value"
+            v-model="data.property.stem_type"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="题号">
+          <el-input v-model="data.property.question_number" />
+        </el-form-item>
+        <el-form-item label-width="45px">
+          <el-radio
+            v-for="{ value, label } in questionNumberTypeList"
+            :key="value"
+            v-model="data.other.question_number_type"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-radio
+            v-for="{ value, label } in switchOption"
+            :key="value"
+            v-model="data.property.is_enable_description"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="选项">
+          <el-radio
+            v-for="{ value, label } in selectTypeList"
+            :key="value"
+            v-model="data.property.select_type"
+            :label="value"
+            @input="changeSelectType"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="听力">
+          <el-radio
+            v-for="{ value, label } in switchOption"
+            :key="value"
+            v-model="data.property.is_enable_listening"
+            :label="value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label="分值">
+          <el-radio
+            v-for="{ value, label } in scoreTypeList"
+            :key="value"
+            v-model="data.property.score_type"
+            :label="value"
+            :disabled="scoreTypeList[1].value === value && data.property.select_type === selectTypeList[0].value"
+          >
+            {{ label }}
+          </el-radio>
+        </el-form-item>
+        <el-form-item label-width="45px">
+          <el-input v-model="data.property.score" type="number" />
+        </el-form-item>
+      </el-form>
+    </template>
+  </QuestionBase>
+</template>
+
+<script>
+import UploadAudio from '../common/UploadAudio.vue';
+import QuestionMixin from '../common/QuestionMixin.js';
+
+import { selectTypeList, scoreTypeList, changeOptionType } from '@/views/exercise_questions/data/common';
+import { getSelectData, getOption } from '@/views/exercise_questions/data/listenSelect';
+
+export default {
+  name: 'ListenSelectQuestion',
+  components: {
+    UploadAudio,
+  },
+  mixins: [QuestionMixin],
+  data() {
+    return {
+      selectTypeList,
+      changeOptionType,
+      data: getSelectData(),
+    };
+  },
+  methods: {
+    /**
+     * 智能识别
+     * @param {String} text 识别数据
+     */
+    recognition(text) {
+      let arr = text
+        .split(/[\r\n]/)
+        .map((item) => item.trim())
+        .filter((item) => item);
+
+      if (arr.length > 0) {
+        this.data.stem = arr[0];
+        this.data.option_list = arr.slice(1).map((content) => getOption(content));
+      }
+    },
+    changeSelectType(val) {
+      if (val === selectTypeList[0].value && this.data.answer.answer_list.length > 1) {
+        this.data.answer.answer_list = [this.data.answer.answer_list[0]];
+      }
+      // 当多选题切换到单选题时,分值类型切换为总分
+      if (val === selectTypeList[0].value && this.data.property.score_type === scoreTypeList[1].value) {
+        this.data.property.score_type = scoreTypeList[0].value;
+      }
+    },
+    isAnswer(mark) {
+      return this.data.answer.answer_list.includes(mark);
+    },
+    selectAnswer(mark) {
+      let index = this.data.answer.answer_list.indexOf(mark);
+      if (this.data.property.select_type === selectTypeList[0].value) {
+        this.data.answer.answer_list = [mark];
+      }
+      if (this.data.property.select_type === selectTypeList[1].value) {
+        if (index === -1) {
+          this.data.answer.answer_list.push(mark);
+        } else {
+          this.data.answer.answer_list.splice(index, 1);
+        }
+      }
+    },
+    addOption() {
+      this.data.option_list.push(getOption());
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.content {
+  .option-content {
+    .checkbox {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      width: 16px;
+      height: 16px;
+      margin-right: 8px;
+      cursor: pointer;
+      border: 1px solid #333;
+      border-radius: 50%;
+
+      &.active {
+        &::before {
+          display: inline-block;
+          width: 6px;
+          height: 6px;
+          content: '';
+          background-color: #333;
+          border-radius: 50%;
+        }
+      }
+    }
+  }
+}
+</style>

+ 9 - 0
src/views/exercise_questions/create/index.vue

@@ -72,6 +72,9 @@ import ChooseTonePreview from '../preview/ChooseTonePreview.vue';
 import RepeatPreview from '../preview/RepeatPreview.vue';
 import ReadPreview from '../preview/ReadPreview.vue';
 import SortPreview from '../preview/SortPreview.vue';
+import ListenSelectPreview from '../preview/ListenSelectPreview.vue';
+import ListenFillPreview from '../preview/ListenFillPreview.vue';
+import ListenJudgePreview from '../preview/ListenJudgePreview.vue';
 
 export default {
   name: 'CreateExercise',
@@ -90,6 +93,9 @@ export default {
     RepeatPreview,
     ReadPreview,
     SortPreview,
+    ListenSelectPreview,
+    ListenFillPreview,
+    ListenJudgePreview,
   },
   provide() {
     return {
@@ -124,6 +130,9 @@ export default {
         repeat: RepeatPreview,
         read: ReadPreview,
         sort: SortPreview,
+        listen_select: ListenSelectPreview,
+        listen_fill: ListenFillPreview,
+        listen_judge: ListenJudgePreview,
       },
     };
   },

+ 9 - 0
src/views/exercise_questions/data/common.js

@@ -25,6 +25,15 @@ export const questionTypeOption = [
     ],
   },
   {
+    value: 'hear',
+    label: '听力题',
+    children: [
+      { label: '听后选择', value: 'listen_select' },
+      { label: '听后填空', value: 'listen_fill' },
+      { label: '听后判断', value: 'listen_judge' },
+    ],
+  },
+  {
     value: 'read',
     label: '阅读题',
   },

+ 45 - 0
src/views/exercise_questions/data/listenFill.js

@@ -0,0 +1,45 @@
+import { stemTypeList, questionNumberTypeList, scoreTypeList, switchOption } from './common';
+
+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;
+}
+
+// 听后填空题数据模板
+export const fillData = {
+  type: 'listen_fill', // 题型
+  stem: '', // 题干
+  file_id_list: [], // 文件 id 列表
+  description: '', // 描述
+  article: '', // 文章
+  model_essay: [], // 文章解析后的数据
+  answer: { score: 0, score_type: scoreTypeList[0].value, answer_list: [] }, // 答案
+  // 题型属性
+  property: {
+    stem_type: stemTypeList[0].value, // 题干类型
+    question_number: '1', // 题号
+    is_enable_listening: switchOption[0].value, // 是否听力
+    is_enable_description: switchOption[1].value, // 描述
+    score: 1, // 分值
+    score_type: scoreTypeList[0].value, // 分值类型
+  },
+  // 其他属性
+  other: {
+    question_number_type: questionNumberTypeList[0].value, // 题号类型
+  },
+};

+ 43 - 0
src/views/exercise_questions/data/listenJudge.js

@@ -0,0 +1,43 @@
+import { optionTypeList, stemTypeList, questionNumberTypeList, scoreTypeList, switchOption } from './common';
+import { getRandomNumber } from '@/utils/index';
+
+// 选项类型列表
+export const option_type_list = [
+  { value: 'right', label: '是' },
+  { value: 'error', label: '非' },
+  { value: 'incertitude', label: '不确定' },
+];
+
+export const option_type_value_list = option_type_list.map(({ value }) => value);
+
+export function getOption(content = '') {
+  return { content, mark: getRandomNumber() };
+}
+
+/**
+ * 获取听后判断题数据模板(防止 mark 重复)
+ * @returns {object} 判断题数据模板
+ */
+export function getJudgeData() {
+  return {
+    type: 'judge', // 题型
+    stem: '', // 题干
+    option_number_show_mode: optionTypeList[0].value, // 选项类型
+    option_list: [getOption(), getOption(), getOption()], // 选项
+    file_id_list: [], // 文件 id 列表
+    answer: { answer_list: [], score: 0, score_type: scoreTypeList[0].value }, // 答案
+    // 题型属性
+    property: {
+      stem_type: stemTypeList[0].value, // 题干类型
+      question_number: '1', // 题号
+      option_type_list: [option_type_list[0].value, option_type_list[1].value], // 选项类型列表
+      is_enable_listening: switchOption[0].value, // 是否听力
+      score: 1, // 分值
+      score_type: scoreTypeList[0].value, // 分值类型
+    },
+    // 其他属性
+    other: {
+      question_number_type: questionNumberTypeList[0].value, // 题号类型
+    },
+  };
+}

+ 46 - 0
src/views/exercise_questions/data/listenSelect.js

@@ -0,0 +1,46 @@
+import {
+  optionTypeList,
+  stemTypeList,
+  selectTypeList,
+  scoreTypeList,
+  questionNumberTypeList,
+  switchOption,
+} from './common';
+import { getRandomNumber } from '@/utils/index';
+
+export function getOption(content = '') {
+  return { content, mark: getRandomNumber() };
+}
+
+/**
+ * 获取听后选择题数据模板(防止 mark 重复)
+ */
+export function getSelectData() {
+  return {
+    type: 'listen_select', // 题型
+    stem: '', // 题干
+    option_number_show_mode: optionTypeList[0].value, // 选项类型
+    description: '', // 描述
+    option_list: [
+      { content: '', mark: getRandomNumber() },
+      { content: '', mark: getRandomNumber() },
+      { content: '', mark: getRandomNumber() },
+    ], // 选项
+    file_id_list: [], // 文件 id 列表
+    answer: { answer_list: [], score: 0, score_type: scoreTypeList[0].value }, // 答案
+    // 题型属性
+    property: {
+      stem_type: stemTypeList[0].value, // 题干类型
+      question_number: '1', // 题号
+      is_enable_description: switchOption[1].value, // 描述
+      select_type: selectTypeList[0].value, // 选择类型
+      is_enable_listening: switchOption[0].value, // 是否听力
+      score: 1, // 分值
+      score_type: scoreTypeList[0].value, // 分值类型
+    },
+    // 其他属性
+    other: {
+      question_number_type: questionNumberTypeList[0].value, // 题号类型
+    },
+  };
+}

+ 18 - 20
src/views/exercise_questions/preview/ChinesePreview.vue

@@ -25,7 +25,7 @@
             <p class="words-right">{{ item.definition }}</p>
             <p class="words-right">{{ item.collocation }}</p>
           </div>
-          <div class="card-box" v-if="data.property.learn_type === 'paint'">
+          <div v-if="data.property.learn_type === 'paint'" class="card-box">
             <!-- 描红 -->
             <Strockplayredline
               :play-storkes="true"
@@ -62,7 +62,7 @@
               />
             </div> -->
           </div>
-          <div class="card-box" v-else-if="data.property.learn_type === 'write'">
+          <div v-else-if="data.property.learn_type === 'write'" class="card-box">
             <!-- 书写 -->
             <Strockplayredline
               :play-storkes="true"
@@ -87,14 +87,14 @@
             </div>
           </div>
         </template>
-        <div class="card-box" v-if="data.property.learn_type === 'dictation'">
+        <div v-if="data.property.learn_type === 'dictation'" class="card-box">
           <div class="words-info">
             <span>{{ computeOptionMethods[data.option_number_show_mode](index) }}. </span>
             <span class="pinyin">{{ item.pinyin }}</span>
             <AudioPlay :file-id="item.audio_file_id" theme-color="white" />
           </div>
           <div class="words-dic-box">
-            <div class="words-dic-item" v-for="(itemc, indexc) in item.imgArr" :key="indexc">
+            <div v-for="(itemc, indexc) in item.imgArr" :key="indexc" class="words-dic-item">
               <span class="pinyin">{{ item.pinyin_arr[indexc].pinyin_item }}</span>
               <div :class="['strockplay-newWord']" @click="freeWrite(itemc, index, indexc)">
                 <SvgIcon icon-class="hanzi-writer-bg" class="character-target-bg" />
@@ -233,7 +233,7 @@ export default {
       this.writer_number_yuan = Math.floor(
         (document.getElementsByClassName('preview-content')[0].clientWidth - 128) / 64,
       );
-      this.writer_number = this.data.property.tian_number ? this.data.property.tian_number * 1 : 8;
+      this.writer_number = this.data.property.tian_number ? Number(this.data.property.tian_number) : 8;
       this.data.option_list.forEach((item, index) => {
         let arr = [];
         if (this.data.property.learn_type === 'dictation') {
@@ -248,22 +248,20 @@ export default {
           });
           item.imgArr = arr;
           this.answer_list.write_model[this.hz_data[index]] = arr;
-        } else {
-          if (item.content.trim()) {
-            let MethodName = 'hz_resource_manager-GetHZStrokesContent';
-            let data = {
-              hz: item.content.trim(),
-            };
-            GetStaticResources(MethodName, data).then((res) => {
-              this.$set(item, 'strokes', res);
-            });
-
-            for (let i = 0; i < this.writer_number; i++) {
-              arr.push(null);
-            }
-            item.imgArr = arr;
-            this.answer_list.write_model[item.content] = arr;
+        } else if (item.content.trim()) {
+          let MethodName = 'hz_resource_manager-GetHZStrokesContent';
+          let data = {
+            hz: item.content.trim(),
+          };
+          GetStaticResources(MethodName, data).then((res) => {
+            this.$set(item, 'strokes', res);
+          });
+
+          for (let i = 0; i < this.writer_number; i++) {
+            arr.push(null);
           }
+          item.imgArr = arr;
+          this.answer_list.write_model[item.content] = arr;
         }
       });
     },

+ 104 - 0
src/views/exercise_questions/preview/ListenFillPreview.vue

@@ -0,0 +1,104 @@
+<!-- eslint-disable vue/no-v-html -->
+<template>
+  <div class="fill-preview">
+    <div class="stem">
+      <span class="question-number">{{ data.property.question_number }}.</span>
+      <span v-html="sanitizeHTML(data.stem)"></span>
+    </div>
+    <div v-if="isEnable(data.property.is_enable_description)" class="description">{{ data.description }}</div>
+
+    <AudioPlay
+      v-if="isEnable(data.property.is_enable_listening) && data.file_id_list.length > 0"
+      :file-id="data.file_id_list[0]"
+    />
+
+    <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>
+          <el-input v-if="li.type === 'input'" :key="j" v-model="li.content" @blur="handleTone(li.content, i, j)" />
+        </template>
+      </p>
+    </div>
+  </div>
+</template>
+
+<script>
+import PreviewMixin from './components/PreviewMixin';
+
+import { addTone } from '@/views/exercise_questions/data/common';
+import { handleToneValue } from '@/views/exercise_questions/data/listenFill';
+
+export default {
+  name: 'ListenFillPreview',
+  mixins: [PreviewMixin],
+  data() {
+    return {};
+  },
+  watch: {
+    'data.model_essay': {
+      handler(val) {
+        if (!val) return;
+        this.answer.answer_list = val
+          .map((item) => {
+            return item
+              .map(({ type, content, mark }) => {
+                if (type === 'input') {
+                  return {
+                    value: content,
+                    mark,
+                  };
+                }
+              })
+              .filter((item) => item);
+          })
+          .flat();
+      },
+      deep: true,
+      immediate: true,
+    },
+  },
+  methods: {
+    handleTone(value, i, j) {
+      if (!/^[a-zA-Z0-9\s]+$/.test(value)) return;
+      this.data.model_essay[i][j].content = 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(' ');
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.fill-preview {
+  @include preview;
+
+  .fill-wrapper {
+    .el-input {
+      width: 120px;
+      margin: 0 2px;
+
+      :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;
+      }
+    }
+  }
+}
+</style>

+ 131 - 0
src/views/exercise_questions/preview/ListenJudgePreview.vue

@@ -0,0 +1,131 @@
+<!-- eslint-disable vue/no-v-html -->
+<template>
+  <div class="judge-preview">
+    <div class="stem">
+      <span class="question-number">{{ data.property.question_number }}.</span>
+      <span v-html="sanitizeHTML(data.stem)"></span>
+    </div>
+    <AudioPlay
+      v-if="isEnable(data.property.is_enable_listening) && data.file_id_list.length > 0"
+      :file-id="data.file_id_list[0]"
+    />
+
+    <ul class="option-list">
+      <li
+        v-for="({ content, mark }, i) in data.option_list"
+        :key="mark"
+        :class="['option-item', { active: isAnswer(mark) }]"
+      >
+        <div class="option-content">
+          <span class="serial-number">{{ computedQuestionNumber(i, data.option_number_show_mode) }}</span>
+          <div v-html="sanitizeHTML(content)"></div>
+        </div>
+        <div class="option-type">
+          <div
+            v-for="option_type in data.property.option_type_list"
+            :key="option_type"
+            :class="[
+              'option-type-item',
+              {
+                active: isAnswer(mark, option_type),
+              },
+            ]"
+            @click="selectAnswer(mark, option_type)"
+          >
+            <SvgIcon v-if="option_type === option_type_list[0].value" icon-class="check-mark" width="17" height="12" />
+            <SvgIcon v-if="option_type === option_type_list[1].value" icon-class="cross" size="12" />
+            <SvgIcon v-if="option_type === option_type_list[2].value" icon-class="circle" size="20" />
+          </div>
+        </div>
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script>
+import { option_type_list } from '@/views/exercise_questions/data/listenJudge';
+import { computedQuestionNumber } from '@/views/exercise_questions/data/common';
+
+import PreviewMixin from './components/PreviewMixin';
+
+export default {
+  name: 'ListenJudgePreview',
+  mixins: [PreviewMixin],
+  data() {
+    return {
+      computedQuestionNumber,
+      option_type_list,
+    };
+  },
+  methods: {
+    isAnswer(mark, option_type) {
+      return this.answer.answer_list.some((li) => li.mark === mark && li.option_type === option_type);
+    },
+
+    selectAnswer(mark, option_type) {
+      const index = this.answer.answer_list.findIndex((li) => li.mark === mark);
+      if (index === -1) {
+        this.answer.answer_list.push({ mark, option_type });
+      } else {
+        this.answer.answer_list[index].option_type = option_type;
+      }
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.judge-preview {
+  @include preview;
+
+  .option-list {
+    display: flex;
+    flex-direction: column;
+    row-gap: 16px;
+
+    .option-item {
+      display: flex;
+      column-gap: 16px;
+
+      .option-content {
+        display: flex;
+        flex: 1;
+        column-gap: 24px;
+        align-items: center;
+        padding: 12px 24px;
+        background-color: $content-color;
+        border-radius: 40px;
+
+        .serial-number {
+          color: #000;
+        }
+      }
+
+      .option-type {
+        display: flex;
+        column-gap: 8px;
+        align-items: center;
+
+        &-item {
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          width: 48px;
+          height: 48px;
+          color: #000;
+          cursor: pointer;
+          background-color: $content-color;
+          border-radius: 50%;
+
+          &.active {
+            color: #fff;
+            background-color: $light-main-color;
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 103 - 0
src/views/exercise_questions/preview/ListenSelectPreview.vue

@@ -0,0 +1,103 @@
+<!-- eslint-disable vue/no-v-html -->
+<template>
+  <div class="select-preview">
+    <div class="stem">
+      <span class="question-number">{{ data.property.question_number }}.</span>
+      <span v-html="sanitizeHTML(data.stem)"></span>
+    </div>
+    <div v-if="isEnable(data.property.is_enable_description)" class="description">{{ data.description }}</div>
+    <AudioPlay
+      v-if="isEnable(data.property.is_enable_listening) && data.file_id_list.length > 0"
+      :file-id="data.file_id_list[0]"
+    />
+    <ul class="option-list">
+      <li
+        v-for="({ content, mark }, i) in data.option_list"
+        :key="mark"
+        :class="['option-item', { active: isAnswer(mark) }]"
+        @click="selectAnswer(mark)"
+      >
+        <span class="selectionbox"></span>
+        <span>{{ computeOptionMethods[data.option_number_show_mode](i) }} </span>
+        <span v-html="sanitizeHTML(content)"></span>
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script>
+import { computeOptionMethods, selectTypeList } from '@/views/exercise_questions/data/common';
+
+import PreviewMixin from './components/PreviewMixin';
+
+export default {
+  name: 'ListenSelectPreview',
+  mixins: [PreviewMixin],
+  data() {
+    return {
+      computeOptionMethods,
+    };
+  },
+  methods: {
+    isAnswer(mark) {
+      return this.answer.answer_list.indexOf(mark) !== -1;
+    },
+    selectAnswer(mark) {
+      const index = this.answer.answer_list.indexOf(mark);
+      if (this.data.property.select_type === selectTypeList[0].value) {
+        this.answer.answer_list = [mark];
+      }
+      if (this.data.property.select_type === selectTypeList[1].value) {
+        if (index === -1) {
+          this.answer.answer_list.push(mark);
+        } else {
+          this.answer.answer_list.splice(index, 1);
+        }
+      }
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.select-preview {
+  @include preview;
+
+  .option-list {
+    display: flex;
+    flex-direction: column;
+    row-gap: 16px;
+
+    .option-item {
+      display: flex;
+      column-gap: 8px;
+      align-items: center;
+      padding: 12px 24px;
+      color: #706f78;
+      cursor: pointer;
+      background-color: $content-color;
+      border-radius: 40px;
+
+      .selectionbox {
+        width: 14px;
+        height: 14px;
+        margin-right: 8px;
+        border: 2px solid #dcdbdd;
+        border-radius: 50%;
+      }
+
+      &.active {
+        color: #34343a;
+        background-color: #e7eeff;
+
+        .selectionbox {
+          border-color: $light-main-color;
+          border-width: 4px;
+        }
+      }
+    }
+  }
+}
+</style>