Pārlūkot izejas kodu

将公用数据与方法放入混入js文件中

dusenyao 1 gadu atpakaļ
vecāks
revīzija
b89f0d34e9

+ 5 - 0
package-lock.json

@@ -4680,6 +4680,11 @@
         "domelementtype": "^2.2.0"
       }
     },
+    "dompurify": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.0.6.tgz",
+      "integrity": "sha512-ilkD8YEnnGh1zJ240uJsW7AzE+2qpbOUYjacomn3AvJ6J4JhKGSZ2nh4wUIXPZrEPppaCLx5jFe8T89Rk8tQ7w=="
+    },
     "domready": {
       "version": "1.0.8",
       "resolved": "https://registry.npmmirror.com/domready/-/domready-1.0.8.tgz",

+ 1 - 0
package.json

@@ -12,6 +12,7 @@
     "@tinymce/tinymce-vue": "^3.2.8",
     "axios": "^1.5.1",
     "core-js": "^3.33.1",
+    "dompurify": "^3.0.6",
     "element-ui": "^2.15.14",
     "js-cookie": "^3.0.5",
     "md5": "^2.3.0",

+ 6 - 0
src/styles/common.scss

@@ -42,6 +42,12 @@
   background-color: $border-color;
 }
 
+.horizontal-line {
+  width: 32px;
+  height: 1px;
+  background-color: $border-color;
+}
+
 .tag {
   & + & {
     margin-left: 8px;

+ 6 - 0
src/styles/mixin.scss

@@ -33,4 +33,10 @@
     background-color: #f9f8f9;
     border-radius: 16px;
   }
+
+  .option-list {
+    :deep p {
+      margin: 0;
+    }
+  }
 }

+ 24 - 21
src/utils/transform.js

@@ -4,38 +4,41 @@
  * @returns {string}
  */
 export function digitToChinese(num) {
-  let str = '';
-  let arr = num.toString().split('');
-  let len = arr.length;
-  let arr2 = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']; // 可以创建一个对应数组,然后用下标去取
-  for (let i = 0; i < len; i++) {
-    str += arr2[arr[i]];
-  }
+  const digitToChineseMap = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']; // 创建一个对应数组,然后用下标去取
+  let str = num
+    .toString()
+    .split('')
+    .map((digit) => digitToChineseMap[digit])
+    .join('');
   return str;
 }
 
 // 对小于 10 的补零
 export function zeroFill(val) {
-  if (val < 10) return `0${val}`;
-  return val;
+  return val < 10 ? `0${val}` : val;
 }
 
 /**
- * 将秒转为 时:分:秒 格式
- * @param {Number} val 秒
- * @returns {String} hh:MM:ss
+ * 将秒转为时:分:秒格式
+ * @param {Number|String} val 秒
+ * @returns {String} hh:MM:ss 小于1小时返回 MM:ss
  */
 export function secondFormatConversion(val) {
-  let second = parseInt(val); // 秒
-  if (second < 60) {
-    return `00:${second < 10 ? `0${second}` : second}`;
-  }
-  if (second < 3600) {
-    return `00:${zeroFill(parseInt(second / 60))}:${zeroFill(parseInt(second % 60))}`;
+  const seconds = parseInt(val); // 输入的秒数
+  const hours = Math.floor(seconds / 3600); // 小时部分
+  const minutes = Math.floor((seconds % 3600) / 60); // 分钟部分
+  const remainingSeconds = seconds % 60; // 剩余的秒数
+
+  // 使用零填充函数来格式化小时、分钟和秒
+  const formattedHours = zeroFill(hours);
+  const formattedMinutes = zeroFill(minutes);
+  const formattedSeconds = zeroFill(remainingSeconds);
+
+  // 根据时间范围返回不同的格式
+  if (hours > 0) {
+    return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
   }
-  return `${zeroFill(parseInt(second / 60 / 60))}:${zeroFill(parseInt((second / 60) % 60))}:${zeroFill(
-    parseInt(second % 60),
-  )}`;
+  return `${formattedMinutes}:${formattedSeconds}`;
 }
 
 /**

+ 1 - 1
src/views/exercise_questions/create/components/common/QuestionBase.vue

@@ -111,7 +111,7 @@ export default {
       .footer {
         display: flex;
         justify-content: center;
-        margin-top: 12px;
+        margin-top: 14px;
         color: $main-color;
 
         .add-option {

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

@@ -44,6 +44,7 @@ import { exerciseTypeList } from '@/views/exercise_questions/data/common';
 import SelectQuestionType from './common/SelectQuestionType.vue';
 import SelectQuestion from './exercises/SelectQuestion.vue';
 import JudgeQuestion from './exercises/JudgeQuestion.vue';
+import MatchingQuestion from './exercises/MatchingQuestion.vue';
 
 export default {
   name: 'CreateMain',
@@ -51,6 +52,7 @@ export default {
     SelectQuestion,
     JudgeQuestion,
     SelectQuestionType,
+    MatchingQuestion,
   },
   provide() {
     return {
@@ -76,6 +78,7 @@ export default {
       exerciseComponents: {
         select: SelectQuestion,
         judge: JudgeQuestion,
+        matching: MatchingQuestion,
       },
     };
   },

+ 16 - 0
src/views/exercise_questions/create/components/exercises/JudgeQuestion.vue

@@ -170,6 +170,22 @@ export default {
       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.select_list = [];
+      }
+    },
+    /**
      * 选择选项答案
      * @param {String} option_type 选项类型
      * @param {String} mark 选项标记

+ 154 - 6
src/views/exercise_questions/create/components/exercises/MatchingQuestion.vue

@@ -22,28 +22,176 @@
           placeholder="输入描述"
         />
       </div>
+
+      <div class="content">
+        <ul>
+          <li v-for="(item, i) in data.option_list" :key="i" class="content-item">
+            <div v-for="(li, j) in item" :key="li.mark" class="item-cell">
+              <span class="question-number">{{ computedQuestionNumber(i, data.option_number_show_mode) }}. </span>
+              <RichText v-model="li.content" placeholder="输入内容" :inline="true" />
+              <span v-if="data.property.column_number > j + 1" class="horizontal-line"></span>
+              <SvgIcon
+                v-if="data.property.column_number === j + 1"
+                icon-class="delete"
+                class="delete pointer"
+                @click="deleteOption(i)"
+              />
+            </div>
+          </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></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 columnNumberList"
+            :key="value"
+            v-model="data.property.column_number"
+            :label="value"
+            @input="changeColumnNumber"
+          >
+            {{ 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 QuestionMixin from '../common/QuestionMixin.js';
 
-import { matchingTypeList, matchingData } from '@/views/exercise_questions/data/matching';
+import { columnNumberList, getOption, getMatchingDataTemplate } from '@/views/exercise_questions/data/matching';
 
 export default {
   name: 'MatchingQuestion',
   mixins: [QuestionMixin],
   data() {
     return {
-      matchingTypeList,
-      data: JSON.parse(JSON.stringify(matchingData)),
+      columnNumberList,
+      data: getMatchingDataTemplate(),
     };
   },
-  methods: {},
+  methods: {
+    addOption() {
+      let newOption = [];
+      for (let i = 0; i < this.data.property.column_number; i++) {
+        newOption.push(getOption());
+      }
+      this.data.option_list.push(newOption);
+    },
+    setAnswerColumnList() {
+      this.data.answer.column_list = this.data.option_list.map((item) => {
+        return item.map(({ mark }) => mark);
+      });
+    },
+    /**
+     * 修改列数后,修改选项列表
+     * @param {Number} val 列数
+     */
+    changeColumnNumber(val) {
+      this.data.option_list = this.data.option_list.map((item) => {
+        let len = item.length;
+        if (len > val) {
+          item.splice(val);
+        } else if (len < val) {
+          for (let i = 0; i < val - len; i++) {
+            item.push(getOption());
+          }
+        }
+        return item;
+      });
+      this.setAnswerColumnList();
+    },
+  },
 };
 </script>
 
-<style lang="scss" scoped></style>
+<style lang="scss" scoped>
+.content {
+  &-item {
+    .item-cell {
+      display: flex;
+      flex: 1;
+      column-gap: 4px;
+      align-items: center;
+
+      .rich-text {
+        flex: 1;
+        min-height: 32px;
+        background-color: $fill-color;
+
+        :deep &.mce-content-body {
+          padding-top: 4px;
+          padding-left: 12px;
+        }
+
+        :deep &:not(.mce-edit-focus) {
+          p {
+            margin: 0;
+          }
+        }
+
+        :deep &.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before {
+          top: 6px;
+          left: 12px;
+        }
+      }
+    }
+  }
+}
+</style>

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

@@ -61,6 +61,7 @@ import { AddQuestionToExercise, DeleteQuestion, GetExerciseQuestionIndexList, Ge
 import CreateMain from './components/create.vue';
 import SelectPreview from '@/views/exercise_questions/preview/SelectPreview.vue';
 import JudgePreview from '@/views/exercise_questions/preview/JudgePreview.vue';
+import MatchingPreview from '@/views/exercise_questions/preview/MatchingPreview.vue';
 
 export default {
   name: 'CreateExercise',
@@ -68,6 +69,7 @@ export default {
     CreateMain,
     SelectPreview,
     JudgePreview,
+    MatchingPreview,
   },
   provide() {
     return {
@@ -86,7 +88,7 @@ export default {
       isSetUp: false, // 设置
       preview: false, // 预览显示
       previewData: {}, // 预览数据
-      previewComponents: { select: SelectPreview, judge: JudgePreview },
+      previewComponents: { select: SelectPreview, judge: JudgePreview, matching: MatchingPreview },
     };
   },
   computed: {

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

@@ -10,8 +10,8 @@ export const option_type_list = [
 
 export const option_type_value_list = option_type_list.map(({ value }) => value);
 
-export function getOption() {
-  return { content: '', mark: getRandomNumber() };
+export function getOption(content = '') {
+  return { content, mark: getRandomNumber() };
 }
 
 // 判断题数据模板

+ 37 - 23
src/views/exercise_questions/data/matching.js

@@ -2,30 +2,44 @@ import { optionTypeList, stemTypeList, scoreTypeList, questionNumberTypeList } f
 import { getRandomNumber } from '@/utils/index';
 
 // 连线类型列表
-export const matchingTypeList = [
+export const columnNumberList = [
   { value: 2, label: '2列' },
   { value: 3, label: '3列' },
 ];
 
-// 连线题数据模板
-export const matchingData = {
-  type: 'matching', // 题型
-  stem: '', // 题干
-  option_number_show_mode: optionTypeList[0].value, // 选项类型
-  description: '', // 描述
-  option_list: [{ column_list: [{ content: '', mark: getRandomNumber() }] }], // 选项
-  answer: { column_list: [], score: 0, score_type: scoreTypeList[0].value }, // 答案
-  // 题型属性
-  property: {
-    stem_type: stemTypeList[0].value, // 题干类型
-    question_number: 1, // 题号
-    column_number: matchingTypeList[0].value, // 列数
-    is_enable_description: false, // 描述
-    score: 1, // 分值
-    score_type: scoreTypeList[0].value, // 分值类型
-  },
-  // 其他属性
-  other: {
-    question_number_type: questionNumberTypeList[0].value, // 题号类型
-  },
-};
+export function getOption(content = '') {
+  return { content, mark: getRandomNumber() };
+}
+
+/**
+ * 获取连线题数据模板
+ * 因为 option_list 和 answer.column_list 中的数据是一一对应的,所以需要函数生成来保持一致
+ */
+export function getMatchingDataTemplate() {
+  let option_list = Array.from({ length: 3 }, () =>
+    Array.from({ length: columnNumberList[0].value }, () => getOption()),
+  );
+  let column_list = option_list.map((item) => item.map(({ mark }) => mark));
+
+  return {
+    type: 'matching', // 题型
+    stem: '', // 题干
+    option_number_show_mode: optionTypeList[0].value, // 选项类型
+    description: '', // 描述
+    option_list, // 选项
+    answer: { column_list, score: 0, score_type: scoreTypeList[0].value }, // 答案
+    // 题型属性
+    property: {
+      stem_type: stemTypeList[0].value, // 题干类型
+      question_number: 1, // 题号
+      column_number: columnNumberList[0].value, // 列数
+      is_enable_description: false, // 描述
+      score: 1, // 分值
+      score_type: scoreTypeList[0].value, // 分值类型
+    },
+    // 其他属性
+    other: {
+      question_number_type: questionNumberTypeList[0].value, // 题号类型
+    },
+  };
+}

+ 5 - 21
src/views/exercise_questions/preview/JudgePreview.vue

@@ -1,8 +1,9 @@
+<!-- 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="data.stem"></span>
+      <span v-html="sanitizeHTML(data.stem)"></span>
     </div>
     <AudioPlay
       v-if="data.property.is_enable_listening && data.file_id_list.length > 0"
@@ -15,7 +16,7 @@
         :key="mark"
         :class="['option-item', { active: isAnswer(mark) }]"
       >
-        <div class="option-content" v-html="content"></div>
+        <div class="option-content" v-html="sanitizeHTML(content)"></div>
         <div class="option-type">
           <div
             v-for="option_type in data.property.option_type_list"
@@ -41,30 +42,17 @@
 <script>
 import { option_type_list } from '@/views/exercise_questions/data/judge';
 
-import AudioPlay from '@/views/exercise_questions/create/components/common/AudioPlay.vue';
+import PreviewMixin from './components/PreviewMixin';
 
 export default {
   name: 'JudgePreview',
-  components: {
-    AudioPlay,
-  },
-  props: {
-    data: {
-      type: Object,
-      required: true,
-    },
-  },
+  mixins: [PreviewMixin],
   data() {
     return {
-      answer: [],
       option_type_list,
     };
   },
   methods: {
-    getAnswer() {
-      return this.answer;
-    },
-
     isAnswer(mark, option_type) {
       return this.answer.some((li) => li.mark === mark && li.option_type === option_type);
     },
@@ -126,10 +114,6 @@ export default {
           }
         }
       }
-
-      :deep p {
-        margin: 0;
-      }
     }
   }
 }

+ 220 - 0
src/views/exercise_questions/preview/MatchingPreview.vue

@@ -0,0 +1,220 @@
+<!-- eslint-disable vue/no-v-html -->
+<template>
+  <div class="matching-preview">
+    <div class="stem">
+      <span class="question-number">{{ data.property.question_number }}.</span>
+      <span v-html="sanitizeHTML(data.stem)"></span>
+    </div>
+    <div v-if="data.property.is_enable_description" class="description">{{ data.description }}</div>
+
+    <ul class="option-list">
+      <li v-for="(item, i) in optionList" :key="i" class="list-item">
+        <div v-for="({ content, mark }, j) in item" :key="mark" class="item-wrapper">
+          <span
+            v-if="j > 0 && j < item.length"
+            :class="['connection', `pre-line-${i}-${j}`]"
+            @click="handleConnection($event, i, j, 'pre', mark)"
+          ></span>
+          <span class="content" v-html="sanitizeHTML(content)"></span>
+          <span
+            v-if="j < item.length - 1"
+            :class="['connection', `next-line-${i}-${j}`]"
+            @click="handleConnection($event, i, j, 'next', mark)"
+          ></span>
+        </div>
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script>
+import PreviewMixin from './components/PreviewMixin';
+
+export default {
+  name: 'MatchingPreview',
+  mixins: [PreviewMixin],
+  data() {
+    return {
+      curConnectionPoint: { i: -1, j: -1, position: 'pre', mark: '' }, // 当前连线点
+      connectionList: [], // 连接线列表
+    };
+  },
+  computed: {
+    optionList() {
+      // 生成一个与选项列表相同的二维数组,但没有内容
+      let arr = Array.from(Array(this.data.option_list.length), () => Array(this.data.option_list[0].length).fill({}));
+      for (let i = 0; i < this.data.property.column_number; i++) {
+        let rowNumList = Array.from(Array(this.data.option_list.length).keys()); // 行数列表
+        for (let j = 0; j < this.data.option_list.length; j++) {
+          let random = Math.floor(Math.random() * rowNumList.length); // 随机行数
+          let item = this.data.option_list[j][i]; // 当前选项
+          arr[rowNumList[random]][i] = item; // 将当前选项添加到随机行数的列中
+          rowNumList.splice(random, 1); // 删除已经添加过选项的行数
+        }
+      }
+      return arr;
+    },
+  },
+  created() {
+    document.addEventListener('click', this.handleEventConnection);
+  },
+  beforeDestroy() {
+    document.removeEventListener('click', this.handleEventConnection);
+  },
+  methods: {
+    handleEventConnection(e) {
+      if (e.target.classList.contains('connection')) return;
+      Array.from(document.getElementsByClassName('connection')).forEach((item) => {
+        item.classList.remove('focus');
+      });
+      this.curConnectionPoint = { i: -1, j: -1, position: 'pre', mark: '' };
+    },
+
+    /**
+     * 处理连线
+     * @param {PointerEvent} e 事件对象
+     * @param {Number} i 选项列表索引
+     * @param {Number} j 选项索引
+     * @param {String} position 连线点位置
+     * @param {String} mark 选项标识
+     */
+    handleConnection(e, i, j, position, mark) {
+      // 如果当前连线点不存在,则设置当前连线点
+      if (this.curConnectionPoint.i === -1 && this.curConnectionPoint.j === -1) {
+        this.curConnectionPoint = { i, j, mark, position };
+        e.target.classList.add('focus');
+        return;
+      }
+      // 如果当前连线点存在,清除所有连线点的选中状态
+      Array.from(document.getElementsByClassName('connection')).forEach((item) => {
+        item.classList.remove('focus');
+      });
+      // 如果当前连线点与上一个连线点不在相邻的位置,则设置点击的连接点为当前连线点
+      if (Math.abs(this.curConnectionPoint.j - j) > 1 || position === this.curConnectionPoint.position) {
+        this.curConnectionPoint = { i, j, mark, position };
+        e.target.classList.add('focus');
+        return;
+      }
+      this.pushConnectionList(j, mark);
+      // 如果当前连线点与上一个连线点在相邻的位置,则创建连接线
+      this.createLine(e, i, j, position);
+    },
+
+    // 创建连接线
+    createLine(e, i, j, position) {
+      let offsetLeft = document.getElementsByClassName(`${position}-line-${i}-${j}`)[0].offsetLeft;
+      let offsetTop = document.getElementsByClassName(`${position}-line-${i}-${j}`)[0].offsetTop;
+      const { curOffsetLeft, curOffsetTop } = this.computedCurConnectionPoint();
+      let top = Math.min(offsetTop, curOffsetTop) + 5;
+      let left = Math.min(offsetLeft, curOffsetLeft) + 5;
+      let width = Math.abs(offsetLeft - curOffsetLeft) + 2;
+      let height = Math.abs(offsetTop - curOffsetTop) + 2;
+      let size = offsetLeft > curOffsetLeft ? offsetTop > curOffsetTop : offsetTop < curOffsetTop;
+
+      // 创建一个空的SVG元素
+      let svgNS = 'http://www.w3.org/2000/svg'; // SVG命名空间
+      let svg = document.createElementNS(svgNS, 'svg');
+      svg.setAttribute(
+        'style',
+        `position:absolute; width: 128px; height: ${Math.max(8, height) + 2}px; top: ${top}px; left: ${left}px;`,
+      );
+      svg.classList.add('connection-line');
+      // 向SVG元素添加 line 元素
+      let line = document.createElementNS(svgNS, 'line');
+      line.setAttribute('x1', size ? '2' : width);
+      line.setAttribute('y1', '2');
+      line.setAttribute('x2', size ? width : '2');
+      line.setAttribute('y2', height);
+      line.setAttribute('stroke-width', '2'); // 设置线条宽度
+      line.setAttribute('stroke-linecap', 'round'); // 设置线段的两端样式
+      line.setAttribute('stroke', '#306eff'); // 设置填充颜色
+
+      // 将SVG元素插入到文档中
+      e.target.parentElement.parentElement.parentElement.appendChild(svg); // 在文档的<body>中插入SVG元素
+      svg.appendChild(line); // 向SVG元素添加矩形
+      this.curConnectionPoint = { i: -1, j: -1, position: 'pre', mark: '' };
+    },
+
+    computedCurConnectionPoint() {
+      const { i, j, position } = this.curConnectionPoint;
+      if (i === -1 && j === -1) return {};
+      let curOffsetLeft = document.getElementsByClassName(`${position}-line-${i}-${j}`)[0].offsetLeft;
+      let curOffsetTop = document.getElementsByClassName(`${position}-line-${i}-${j}`)[0].offsetTop;
+      return { curOffsetLeft, curOffsetTop };
+    },
+
+    // 清除连线
+    clearLine() {
+      document.querySelectorAll('svg.connection-line').forEach((item) => {
+        console.log(item);
+        item.remove();
+      });
+    },
+
+    pushConnectionList(j, mark) {
+      let index = this.connectionList.findIndex((item) => {
+        return item.includes(mark) || item.includes(this.curConnectionPoint.mark);
+      });
+      if (index === -1) {
+        let arr = [];
+        arr[j] = mark;
+        arr[this.curConnectionPoint.j] = this.curConnectionPoint.mark;
+        this.connectionList.push(arr);
+      } else {
+        this.connectionList[index][j] = mark;
+        this.connectionList[index][this.curConnectionPoint.j] = this.curConnectionPoint.mark;
+      }
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.matching-preview {
+  @include preview;
+
+  .option-list {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    row-gap: 16px;
+
+    .list-item {
+      display: flex;
+      column-gap: 60px;
+
+      .item-wrapper {
+        display: flex;
+        flex: 1;
+        column-gap: 24px;
+        align-items: center;
+        min-height: 48px;
+        padding: 12px 24px;
+        background-color: $fill-color;
+        border-radius: 40px;
+
+        .content {
+          flex: 1;
+        }
+
+        .connection {
+          z-index: 1;
+          width: 14px;
+          height: 14px;
+          cursor: pointer;
+          border: 4px solid #306eff;
+          border-radius: 50%;
+
+          &.focus {
+            background-color: #306eff;
+            border-color: #fff;
+            outline: 2px solid #306eff;
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 5 - 20
src/views/exercise_questions/preview/SelectPreview.vue

@@ -1,8 +1,9 @@
+<!-- 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="data.stem"></span>
+      <span v-html="sanitizeHTML(data.stem)"></span>
     </div>
     <div v-if="data.property.is_enable_description" class="description">{{ data.description }}</div>
     <AudioPlay
@@ -18,7 +19,7 @@
       >
         <span class="selectionbox"></span>
         <span>{{ computeOptionMethods[data.option_number_show_mode](i) }}. </span>
-        <span v-html="content"></span>
+        <span v-html="sanitizeHTML(content)"></span>
       </li>
     </ul>
   </div>
@@ -27,29 +28,17 @@
 <script>
 import { computeOptionMethods, selectTypeList } from '@/views/exercise_questions/data/common';
 
-import AudioPlay from '@/views/exercise_questions/create/components/common/AudioPlay.vue';
+import PreviewMixin from './components/PreviewMixin';
 
 export default {
   name: 'SelectPreview',
-  components: {
-    AudioPlay,
-  },
-  props: {
-    data: {
-      type: Object,
-      required: true,
-    },
-  },
+  mixins: [PreviewMixin],
   data() {
     return {
-      answer: [],
       computeOptionMethods,
     };
   },
   methods: {
-    getAnswer() {
-      return this.answer;
-    },
     isAnswer(mark) {
       return this.answer.indexOf(mark) !== -1;
     },
@@ -108,10 +97,6 @@ export default {
           border-width: 4px;
         }
       }
-
-      :deep p {
-        margin: 0;
-      }
     }
   }
 }

+ 39 - 0
src/views/exercise_questions/preview/components/PreviewMixin.js

@@ -0,0 +1,39 @@
+// 预览混入
+import AudioPlay from '@/views/exercise_questions/create/components/common/AudioPlay.vue';
+import DOMPurify from 'dompurify';
+
+const PreviewMixin = {
+  props: {
+    data: {
+      type: Object,
+      required: true,
+    },
+  },
+  components: {
+    AudioPlay,
+  },
+  data() {
+    return {
+      answer: [], // 答案
+    };
+  },
+  methods: {
+    /**
+     * 获取答案
+     * @returns {Array} 答案
+     */
+    getAnswer() {
+      return this.answer;
+    },
+    /**
+     * 过滤 html,防止 xss 攻击
+     * @param {String} html 需要过滤的html
+     * @returns {String} 过滤后的html
+     */
+    sanitizeHTML(html) {
+      return DOMPurify.sanitize(html);
+    },
+  },
+};
+
+export default PreviewMixin;