Kaynağa Gözat

填空题完成

dusenyao 1 yıl önce
ebeveyn
işleme
5192f861b8

+ 1 - 1
.vscode/settings.json

@@ -1,3 +1,3 @@
 {
-  "cSpell.words": ["cascader", "GCLS"]
+  "cSpell.words": ["cascader", "GCLS", "tinymce"]
 }

+ 201 - 59
src/components/common/RichText.vue

@@ -3,16 +3,16 @@
     v-bind="$attrs"
     :id="id"
     ref="richText"
+    model-events="change keyup undo redo setContent"
     :value="value"
     :class="['rich-text', isBorder ? 'is-border' : '']"
     :init="init"
-    @input="updateValue"
     v-on="$listeners"
   />
 </template>
 
 <script>
-import 'tinymce/tinymce';
+import tinymce from 'tinymce/tinymce';
 import Editor from '@tinymce/tinymce-vue';
 
 import 'tinymce/icons/default/icons';
@@ -37,6 +37,7 @@ import 'tinymce/plugins/autoresize'; // 自动调整大小插件
 import 'tinymce/plugins/ax_wordlimit'; // 字数限制插件
 
 import { getRandomNumber } from '@/utils';
+import { isNodeType } from '@/utils/validate';
 import { fileUpload } from '@/api/app';
 
 export default {
@@ -66,6 +67,20 @@ export default {
       type: Boolean,
       default: false,
     },
+    toolbar: {
+      type: [String, Boolean],
+      /* eslint-disable max-len */
+      default:
+        'fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright | bullist numlist | image media | link blockquote hr',
+    },
+    wordlimitNum: {
+      type: [Number, Boolean],
+      default: 500,
+    },
+    isFill: {
+      type: Boolean,
+      default: false,
+    },
   },
   data() {
     return {
@@ -82,77 +97,204 @@ export default {
         width: '100%',
         autoresize_bottom_margin: 0,
         plugins: 'link lists image hr media autoresize ax_wordlimit',
-        /* eslint-disable max-len */
-        toolbar:
-          'fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright | bullist numlist | image media | link blockquote hr',
-        menubar: false,
-        branding: false,
-        statusbar: false,
+        toolbar: this.toolbar, // 工具栏
+        contextmenu: false, // 右键菜单
+        menubar: false, // 菜单栏
+        branding: false, // 品牌
+        statusbar: false, // 状态栏
         // 字数限制
-        ax_wordlimit_num: 500,
+        ax_wordlimit_num: this.wordlimitNum,
         ax_wordlimit_callback(editor) {
           editor.execCommand('undo');
         },
-        /**
-         * 图片上传自定义逻辑函数
-         * @param {object} blobInfo 文件数据
-         * @param {Function} success 成功回调函数
-         * @param {Function} fail 失败回调函数
-         */
-        images_upload_handler(blobInfo, success, fail) {
-          let file = blobInfo.blob();
+        media_filter_html: false,
+        images_upload_handler: this.imagesUploadHandler,
+        file_picker_types: 'media', // 文件上传类型
+        file_picker_callback: this.filePickerCallback,
+        init_instance_callback: this.isFill ? this.initInstanceCallback : '',
+      },
+    };
+  },
+  methods: {
+    /**
+     * 图片上传自定义逻辑函数
+     * @param {object} blobInfo 文件数据
+     * @param {Function} success 成功回调函数
+     * @param {Function} fail 失败回调函数
+     */
+    imagesUploadHandler(blobInfo, success, fail) {
+      let file = blobInfo.blob();
+      const formData = new FormData();
+      formData.append(file.name, file, file.name);
+      fileUpload('Mid', formData)
+        .then(({ file_info_list }) => {
+          if (file_info_list.length > 0) {
+            success(file_info_list[0].file_url);
+          } else {
+            fail('上传失败');
+          }
+        })
+        .catch(() => {
+          fail('上传失败');
+        });
+    },
+    /**
+     * 文件上传自定义逻辑函数
+     * @param {Function} callback
+     * @param {String} value
+     * @param {object} meta
+     */
+    filePickerCallback(callback, value, meta) {
+      if (meta.filetype === 'media') {
+        let filetype = '.mp3, .mp4';
+        let input = document.createElement('input');
+        input.setAttribute('type', 'file');
+        input.setAttribute('accept', filetype);
+        input.click();
+        input.addEventListener('change', () => {
+          let file = input.files[0];
           const formData = new FormData();
           formData.append(file.name, file, file.name);
           fileUpload('Mid', formData)
             .then(({ file_info_list }) => {
               if (file_info_list.length > 0) {
-                success(file_info_list[0].file_url);
+                callback(file_info_list[0].file_url);
               } else {
-                fail('上传失败');
+                callback('');
               }
             })
             .catch(() => {
-              fail('上传失败');
-            });
-        },
-        file_picker_types: 'media', // 文件上传类型
-        /**
-         * 文件上传自定义逻辑函数
-         * @param {Function} callback
-         * @param {String} value
-         * @param {object} meta
-         */
-        file_picker_callback(callback, value, meta) {
-          if (meta.filetype === 'media') {
-            let filetype = '.mp3, .mp4';
-            let input = document.createElement('input');
-            input.setAttribute('type', 'file');
-            input.setAttribute('accept', filetype);
-            input.click();
-            input.addEventListener('change', () => {
-              let file = input.files[0];
-              const formData = new FormData();
-              formData.append(file.name, file, file.name);
-              fileUpload('Mid', formData)
-                .then(({ file_info_list }) => {
-                  if (file_info_list.length > 0) {
-                    callback(file_info_list[0].file_url);
-                  } else {
-                    callback('');
-                  }
-                })
-                .catch(() => {
-                  callback('');
-                });
+              callback('');
             });
-          }
-        },
-      },
-    };
-  },
-  methods: {
-    updateValue(data) {
-      this.$emit('update:value', data);
+        });
+      }
+    },
+    /**
+     * 初始化编辑器实例回调函数
+     * @param {Editor} editor 编辑器实例
+     */
+    initInstanceCallback(editor) {
+      editor.on('SetContent Undo Redo ViewUpdate keyup mousedown', () => {
+        this.$emit('hideContentmenu');
+      });
+
+      editor.on('click', (e) => {
+        if (e.target.classList.contains('rich-fill')) {
+          editor.selection.select(e.target); // 选中填空
+          let { offsetLeft, offsetTop } = e.target;
+          this.$emit('showContentmenu', {
+            pixelsFromLeft: offsetLeft - 14,
+            pixelsFromTop: offsetTop,
+          });
+        }
+      });
+
+      let mouseX = 0;
+      editor.on('mousedown', (e) => {
+        mouseX = e.offsetX;
+      });
+
+      editor.on('mouseup', (e) => {
+        let start = editor.selection.getStart();
+        let end = editor.selection.getEnd();
+        let rng = editor.selection.getRng();
+        if (start !== end || rng.collapsed) {
+          this.$emit('hideContentmenu');
+          return;
+        }
+        if (e.offsetX < mouseX) {
+          mouseX = e.offsetX;
+        }
+        if (isNodeType(start, 'span')) {
+          start = start.parentNode;
+        }
+
+        // 获取文本内容和起始偏移位置
+        let text = start.textContent;
+        let startOffset = rng.startOffset;
+        let previousSibling = rng.startContainer.previousSibling;
+        // 判断是否选中的是 span 标签
+        let isSpan = isNodeType(rng.startContainer.parentNode, 'span');
+        if (isSpan) {
+          previousSibling = rng.startContainer.parentNode.previousSibling;
+        }
+        // 计算起始偏移位置
+        while (previousSibling) {
+          startOffset += previousSibling.textContent.length;
+          previousSibling = previousSibling.previousSibling;
+        }
+
+        // 获取起始偏移位置前的文本内容
+        const textBeforeOffset = text.substring(0, startOffset);
+
+        /* 使用 Canvas API测量文本宽度 */
+        // 获取字体大小和行高
+        let computedStyle = window.getComputedStyle(start);
+        const fontSize = parseFloat(computedStyle.fontSize);
+        const canvas = document.createElement('canvas');
+        const context = canvas.getContext('2d');
+        context.font = `${fontSize}px ${computedStyle.fontFamily}`;
+        // 计算文字距离左侧的像素位置
+        const width = context.measureText(textBeforeOffset).width;
+        const lineHeight = context.measureText('M').fontBoundingBoxAscent + 3.8; // 获取行高
+
+        /* 计算偏移位置 */
+        const computedWidth = computedStyle.width.replace('px', ''); // 获取编辑器宽度
+        let row = width / computedWidth; // 计算选中文本在第几行
+        row = row % 1 > 0.8 ? Math.ceil(row) : Math.floor(row);
+        const offsetTop = start.offsetTop; // 获取选中文本距离顶部的像素位置
+        let pixelsFromTop = offsetTop + lineHeight * row; // 计算选中文本距离顶部的像素位置
+        this.$emit('showContentmenu', {
+          pixelsFromLeft: mouseX,
+          pixelsFromTop,
+        });
+      });
+    },
+    // 删除填空
+    deleteContent() {
+      let editor = tinymce.get(this.id);
+      let start = editor.selection.getStart();
+      if (isNodeType(start, 'span')) {
+        let textContent = start.textContent;
+        let content = editor.selection.getContent();
+        let str = textContent.split(content);
+        start.remove();
+        editor.selection.setContent(str.join(content));
+      } else {
+        this.collapse();
+      }
+    },
+    // 设置填空
+    setContent() {
+      let editor = tinymce.get(this.id);
+      let start = editor.selection.getStart();
+      let content = editor.selection.getContent();
+      if (isNodeType(start, 'span')) {
+        let textContent = start.textContent;
+        let str = textContent.split(content);
+        start.remove();
+        editor.selection.setContent(str.join(this.getSpanString(content)));
+      } else {
+        let str = this.replaceSpanString(content);
+        editor.selection.setContent(this.getSpanString(str));
+      }
+    },
+    // 折叠选区
+    collapse() {
+      let editor = tinymce.get(this.id);
+      let rng = editor.selection.getRng();
+      if (!rng.collapsed) {
+        this.$emit('hideContentmenu');
+        editor.selection.collapse();
+      }
+    },
+    // 获取 span 标签
+    getSpanString(str) {
+      return `<span class="rich-fill" style="text-decoration: underline;cursor: pointer;">${str}</span>`;
+    },
+    // 去除 span 标签
+    replaceSpanString(str) {
+      return str.replace(/<span\b[^>]*>(.*?)<\/span>/gi, '$1');
     },
   },
 };

+ 3 - 0
src/icons/svg/close-circle.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="M6.99992 13.6663C3.31802 13.6663 0.333252 10.6815 0.333252 6.99967C0.333252 3.31777 3.31802 0.333008 6.99992 0.333008C10.6818 0.333008 13.6666 3.31777 13.6666 6.99967C13.6666 10.6815 10.6818 13.6663 6.99992 13.6663ZM6.99992 12.333C9.94545 12.333 12.3333 9.94521 12.3333 6.99967C12.3333 4.05415 9.94545 1.66634 6.99992 1.66634C4.0544 1.66634 1.66659 4.05415 1.66659 6.99967C1.66659 9.94521 4.0544 12.333 6.99992 12.333ZM6.99992 6.05687L8.88552 4.17125L9.82832 5.11405L7.94272 6.99967L9.82832 8.88527L8.88552 9.82807L6.99992 7.94247L5.1143 9.82807L4.17149 8.88527L6.05712 6.99967L4.17149 5.11405L5.1143 4.17125L6.99992 6.05687Z" fill="black"/>
+</svg>

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

@@ -0,0 +1,3 @@
+<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.4606 6.60973L10.6391 7.7882C6.63213 11.7951 3.33233 11.7951 0.503906 10.8523L10.8748 0.481445L13.2318 2.83847L9.4606 6.60973ZM7.575 6.60973L11.3462 2.83847L10.8748 2.36706L3.20967 10.0322C5.03071 10.1038 6.78147 9.42793 8.72053 7.75527L7.575 6.60973Z" fill="black"/>
+</svg>

+ 1 - 0
src/styles/element-variables.scss

@@ -11,6 +11,7 @@ $--table-header-font-color: $font-color;
 // input
 $--input-background-color: $fill-color;
 $--input-border: 1px solid $fill-color;
+$--input-small-font-size: 14px;
 
 // button
 $--button-small-font-size: 14px;

+ 9 - 0
src/utils/validate.js

@@ -37,3 +37,12 @@ export function isNumber(value) {
   }
   return false;
 }
+
+/**
+ * 判断 Node 元素类型
+ * @param {Node} node
+ * @param {String} type
+ */
+export function isNodeType(node, type) {
+  return node.nodeName.toLowerCase() === type.toLowerCase();
+}

+ 155 - 3
src/views/exercise_questions/create/components/exercises/FillQuestion.vue

@@ -31,8 +31,34 @@
       </div>
 
       <div class="content">
-        <el-input v-model="data.model_essay" type="textarea" placeholder="请输入" rows="5" />
+        <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 === 'underline')"
+            :key="item.mark"
+            v-model="item.content"
+          >
+            <span slot="prefix">{{ i + 1 }}.</span>
+          </el-input>
+        </div>
       </div>
     </template>
 
@@ -113,6 +139,7 @@
 import UploadAudio from '../common/UploadAudio.vue';
 import QuestionMixin from '../common/QuestionMixin.js';
 
+import { getRandomNumber } from '@/utils';
 import { fillData } from '@/views/exercise_questions/data/fill';
 
 export default {
@@ -123,20 +150,145 @@ export default {
   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() {},
+    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 > 1) {
+          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({
+            content: isUnderline ? '' : content,
+            mark,
+            type: isUnderline ? 'underline' : 'input',
+          });
+          // 将 content 设置为空,为预览准备
+          arr[arr.length - 1].content = '';
+        }
+        start = index + 3;
+      }
+      let last = _str.slice(start, -1);
+      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`,
+      };
+    },
   },
 };
 </script>
 
 <style lang="scss" scoped>
 .content {
-  .el-textarea + .el-button {
+  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>

+ 3 - 3
src/views/exercise_questions/data/fill.js

@@ -6,9 +6,9 @@ export const fillData = {
   stem: '', // 题干
   file_id_list: [], // 文件 id 列表
   description: '', // 描述
-  model_essay: '', // 范文
-  fill_blank: [], // 填空
-  answer: { score: 0, score_type: scoreTypeList[0].value }, // 答案
+  article: '', // 文章
+  model_essay: [], // 文章解析后的数据
+  answer: { score: 0, score_type: scoreTypeList[0].value, answer_list: [] }, // 答案
   // 题型属性
   property: {
     stem_type: stemTypeList[0].value, // 题干类型

+ 50 - 1
src/views/exercise_questions/preview/FillPreview.vue

@@ -6,11 +6,20 @@
       <span v-html="sanitizeHTML(data.stem)"></span>
     </div>
     <div v-if="data.property.is_enable_description" class="description">{{ data.description }}</div>
+
     <AudioPlay
       v-if="data.property.is_enable_listening && data.file_id_list.length > 0"
       :file-id="data.file_id_list[0]"
     />
-    <div>{{ data.model_essay }}</div>
+
+    <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" />
+        </template>
+      </p>
+    </div>
   </div>
 </template>
 
@@ -23,6 +32,28 @@ export default {
   data() {
     return {};
   },
+  watch: {
+    'data.model_essay': {
+      handler(val) {
+        this.answer.select_list = val
+          .map((item) => {
+            return item
+              .map(({ type, content, mark }) => {
+                if (type === 'input') {
+                  return {
+                    content,
+                    mark,
+                  };
+                }
+              })
+              .filter((item) => item);
+          })
+          .flat();
+      },
+      deep: true,
+      immediate: true,
+    },
+  },
   methods: {},
 };
 </script>
@@ -32,5 +63,23 @@ export default {
 
 .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>