Przeglądaj źródła

试题解析富文本分块

zhangjie 2 lat temu
rodzic
commit
106059a36e

+ 9 - 10
src/assets/styles/pages.scss

@@ -1188,8 +1188,8 @@
   }
 }
 
-// question-export-edit-dialog
-.question-export-edit-dialog {
+// question-import-edit-dialog
+.question-import-edit-dialog {
   .el-dialog__body {
     height: 100%;
     overflow: auto;
@@ -1209,14 +1209,6 @@
 
     &-edit {
       padding-right: 52px;
-
-      .v-editor {
-        height: 100%;
-      }
-      .v-editor-container {
-        max-height: 100%;
-        height: 100%;
-      }
     }
     &-view {
       padding-left: 52px;
@@ -1261,6 +1253,13 @@
       overflow-x: hidden;
       overflow-y: auto;
     }
+    &-richtext {
+      margin: 10px;
+      .tips-info {
+        line-height: 20px;
+        min-height: auto;
+      }
+    }
   }
   .qe-middle {
     position: absolute;

+ 14 - 2
src/components/vEditor/VEditor.vue

@@ -1,8 +1,11 @@
 <template>
   <div class="v-editor">
-    <VMenu class="v-editor-head" />
+    <VMenu v-if="showMenu" class="v-editor-head" />
     <div
-      :class="['v-editor-container', { 'is-focus': isFocus }]"
+      :class="[
+        'v-editor-container',
+        { 'is-focus': isFocus, 'is-no-menu': !showMenu },
+      ]"
       @click="containerClick"
     >
       <div ref="editorMain" class="v-editor-main">
@@ -75,6 +78,10 @@ export default {
       type: String,
       default: "json",
     },
+    showMenu: {
+      type: Boolean,
+      default: true,
+    },
     customEmitInput: {
       type: Boolean,
       default: false,
@@ -338,6 +345,11 @@ export default {
   overflow: auto;
   white-space: pre-wrap;
 }
+.v-editor-container.is-no-menu {
+  padding: 0;
+  min-height: auto;
+  max-height: none;
+}
 .v-editor-container.is-focus {
   border-color: #1886fe;
 }

+ 122 - 59
src/modules/question/components/QuestionImportEdit.vue

@@ -1,7 +1,7 @@
 <template>
-  <div class="question-export-edit">
+  <div class="question-import-edit">
     <el-dialog
-      custom-class="question-export-edit-dialog"
+      custom-class="question-import-edit-dialog"
       :visible.sync="modalIsShow"
       :close-on-click-modal="false"
       :close-on-press-escape="false"
@@ -48,15 +48,34 @@
               </div>
             </div>
             <div class="qe-part-body">
-              <v-editor
-                ref="RichTextEditor"
-                v-model="paperRichJson"
-                :enable-formula="false"
-                :enable-audio="false"
-                custom-emit-input
-                :custom-render-action="renderRichText"
-                :custom-tojson-action="richTextToJSON"
-              ></v-editor>
+              <div id="qe-part-richtext-list">
+                <div
+                  class="qe-part-richtext"
+                  v-for="(richJsonItem, rindex) in paperRichJsonGroup"
+                  :key="rindex"
+                >
+                  <v-editor
+                    ref="RichTextEditor"
+                    :value="richJsonItem"
+                    :show-menu="false"
+                    custom-emit-input
+                    :custom-render-action="renderRichText"
+                    :custom-tojson-action="richTextToJSON"
+                  ></v-editor>
+                  <div
+                    v-if="richJsonItem.exceptions.length"
+                    class="qe-part-richtext-error"
+                  >
+                    <p
+                      class="tips-info tips-error"
+                      v-for="(cont, index) in richJsonItem.exceptions"
+                      :key="index"
+                    >
+                      {{ cont }}
+                    </p>
+                  </div>
+                </div>
+              </div>
             </div>
           </div>
         </div>
@@ -151,7 +170,7 @@ const questionInfoField = [
 ];
 
 export default {
-  name: "QuestionExportEdit",
+  name: "QuestionImportEdit",
   components: { QuestionImportPaperEdit, ImportFileDialog, UploadButton },
   props: {
     data: {
@@ -179,6 +198,7 @@ export default {
       questionKey: "",
       paperData: [],
       paperRichJson: { sections: [] },
+      paperRichJsonGroup: [],
       richTextToJSON,
       renderRichText,
       lastPaperScrollTop: 0,
@@ -204,15 +224,17 @@ export default {
       // });
       this.resetData(this.data);
 
-      this.$nextTick(() => {
-        this.registScrollEvent();
-      });
+      // this.$nextTick(() => {
+      //   this.registScrollEvent();
+      // });
     },
     resetData({ richText, detailInfo }) {
       this.paperData = deepCopy(detailInfo);
-      this.paperRichJson = this.transformRichText(deepCopy(richText));
-      this.uploadData = { courseId: this.data.importData.courseId };
+      this.paperRichJson = this.buildRichText(deepCopy(richText));
       this.transformDataInfo();
+      this.paperRichJsonGroup = this.getRichTextGroup();
+
+      this.uploadData = { courseId: this.data.importData.courseId };
       this.questionKey = randomCode();
 
       this.$nextTick(() => {
@@ -220,46 +242,28 @@ export default {
       });
     },
     getRichTextIndexList() {
-      const richTextBodyDom =
-        this.$refs.RichTextEditor.$el.querySelector(".v-editor-body");
+      const richTextListDom = document.getElementById("qe-part-richtext-list");
+      const elPos = richTextListDom.getBoundingClientRect();
       let richTextIndexList = [];
-      richTextBodyDom.childNodes.forEach((sectionNode) => {
-        const id = sectionNode.getAttribute("id");
-        if (!id) return;
-        if (
-          sectionNode.className &&
-          sectionNode.className.includes("section-error")
-        )
-          return;
+      const richTextBodyDoms =
+        richTextListDom.querySelectorAll(".v-editor-body");
+      richTextBodyDoms.forEach((richTextBodyDom) => {
+        richTextBodyDom.childNodes.forEach((sectionNode) => {
+          const id = sectionNode.getAttribute("id");
+          if (!id) return;
+          if (
+            sectionNode.className &&
+            sectionNode.className.includes("section-error")
+          )
+            return;
 
-        const index = id.replace("section-", "") * 1;
-        richTextIndexList.push([index, sectionNode.offsetTop]);
-      });
-      this.richTextIndexList = richTextIndexList;
-    },
-    transformRichText(richText) {
-      let nsections = [];
-      richText.sections.forEach((section) => {
-        nsections.push({
-          blocks: section.blocks,
-          attributes: { id: `section-${section.remark.index}` },
+          const index = id.replace("section-", "") * 1;
+          const sectionPos = sectionNode.getBoundingClientRect();
+
+          richTextIndexList.push([index, sectionPos.y - elPos.y]);
         });
-        if (section.remark && !section.remark.status) {
-          nsections.push({
-            blocks: [
-              {
-                type: "text",
-                value: section.remark.cause,
-              },
-            ],
-            attributes: {
-              id: `section-error-${section.remark.index}`,
-              class: "section-error",
-            },
-          });
-        }
       });
-      return { sections: nsections };
+      this.richTextIndexList = richTextIndexList;
     },
     async getCourseProperty() {
       const res = await propertyNameQueryApi(this.data.importData.courseId, "");
@@ -269,6 +273,16 @@ export default {
         JSON.stringify({ optionList, courseId: this.data.importData.courseId })
       );
     },
+    buildRichText(richText) {
+      let nsections = [];
+      richText.sections.forEach((section) => {
+        nsections.push({
+          ...section,
+          attributes: { id: `section-${section.remark.index}` },
+        });
+      });
+      return { sections: nsections };
+    },
     transformDataInfo() {
       this.transformRichImg(this.paperRichJson);
       this.paperData.forEach((detail) => {
@@ -318,6 +332,55 @@ export default {
       });
       return JSON.stringify(qAnswer);
     },
+    getRichTextGroup() {
+      let groupSetList = [];
+      this.paperData.forEach((detail) => {
+        detail.questions.forEach((question) => {
+          groupSetList.push({
+            id: randomCode(),
+            indexs: question.integralIndex,
+          });
+          if (question.subQuestions && question.subQuestions.length) {
+            question.subQuestions.forEach((subq) => {
+              groupSetList.push({
+                id: randomCode(),
+                indexs: subq.integralIndex,
+              });
+            });
+          }
+        });
+      });
+
+      let groups = [];
+      let curGroupId = 0;
+      let curGroup = [];
+      const findGroupId = (ind) => {
+        let data = groupSetList.find((item) => item.indexs.includes(ind));
+        return data ? data.id : null;
+      };
+      this.paperRichJson.sections.forEach((section) => {
+        const sectionGroupId = findGroupId(section.remark.index);
+        if (sectionGroupId !== curGroupId) {
+          if (curGroup.length) {
+            groups.push({ sections: curGroup, exceptions: [] });
+            curGroup = [];
+          }
+        }
+        curGroupId = sectionGroupId;
+        curGroup.push(section);
+      });
+      if (curGroup.length) {
+        groups.push({ sections: curGroup, exceptions: [] });
+        curGroup = [];
+      }
+
+      groups.forEach((group) => {
+        group.exceptions = group.sections
+          .filter((section) => !section.remark.status)
+          .map((section) => section.remark.cause);
+      });
+      return groups;
+    },
     initData() {
       this.paperData = [];
       this.paperRichJson = { sections: [] };
@@ -362,8 +425,8 @@ export default {
         res.data.detailInfo,
         cacheData
       );
-      this.paperRichJson = this.transformRichText(deepCopy(res.data.richText));
-      this.transformDataInfo();
+      this.paperRichJson = this.buildRichText(deepCopy(res.data.richText));
+      this.paperRichJsonGroup = this.getRichTextGroup();
       this.questionKey = randomCode();
 
       this.$nextTick(() => {
@@ -777,10 +840,10 @@ export default {
     // word upload
     uploaded(res) {
       this.$message.success("上传成功!");
-      this.paperRichJson = deepCopy(res.data.richText);
-      this.paperData = deepCopy(res.data.detailInfo);
-      this.transformDataInfo();
-      this.questionKey = randomCode();
+      this.resetData({
+        richText: res.data.richText,
+        detailInfo: res.data.detailInfo,
+      });
     },
     uploadError(error) {
       this.$message.error(error.message);

+ 970 - 0
src/modules/question/components/QuestionImportEdit1.vue

@@ -0,0 +1,970 @@
+<template>
+  <div class="question-import-edit">
+    <el-dialog
+      custom-class="question-import-edit-dialog"
+      :visible.sync="modalIsShow"
+      :close-on-click-modal="false"
+      :close-on-press-escape="false"
+      append-to-body
+      fullscreen
+      destroy-on-close
+      :show-close="false"
+      @opened="visibleChange"
+      @closed="initData"
+    >
+      <div slot="title" class="box-justify">
+        <div>
+          <h2>文件上传</h2>
+        </div>
+        <div>
+          <upload-button
+            btn-content="重新上传文件"
+            btn-icon="icon icon-import"
+            :disabled="loading"
+            :upload-data="uploadData"
+            :upload-url="uploadUrl"
+            :format="importFileTypes"
+            @valid-error="uploadError"
+            @upload-error="uploadError"
+            @upload-success="uploaded"
+          ></upload-button>
+          <el-button
+            size="small"
+            type="danger"
+            icon="icon icon-back-white"
+            @click="cancel"
+            >返回</el-button
+          >
+        </div>
+      </div>
+      <div class="qe-body">
+        <div class="qe-part qe-part-edit">
+          <div class="qe-part-main">
+            <div class="qe-part-head">
+              <h3>题目编辑</h3>
+              <div>
+                <i class="icon icon-tips"></i>
+                提示:若识别有误,可点击左侧题目按格式进行修改后重新识别
+              </div>
+            </div>
+            <div class="qe-part-body">
+              <v-editor
+                ref="RichTextEditor"
+                v-model="paperRichJson"
+                :enable-formula="false"
+                :enable-audio="false"
+                custom-emit-input
+                :custom-render-action="renderRichText"
+                :custom-tojson-action="richTextToJSON"
+              ></v-editor>
+            </div>
+          </div>
+        </div>
+        <div class="qe-part qe-part-view">
+          <div class="qe-part-main">
+            <div class="qe-part-head">
+              <h3>题目阅览</h3>
+              <div>
+                <el-button
+                  size="small"
+                  type="primary"
+                  plain
+                  icon="icon icon-export-answer"
+                  @click="toImportAnswer"
+                  >导入答案属性</el-button
+                >
+                <el-button
+                  size="small"
+                  type="primary"
+                  icon="icon icon-save-white"
+                  :loading="loading"
+                  @click="confirm"
+                  >识别无误,加入题库</el-button
+                >
+              </div>
+            </div>
+            <div id="qe-part-paper" class="qe-part-body">
+              <question-import-paper-edit
+                v-if="paperData.length"
+                ref="QuestionImportPaperEdit"
+                :key="questionKey"
+                :paper="paperData"
+                :course-id="data.importData.courseId"
+              ></question-import-paper-edit>
+            </div>
+          </div>
+        </div>
+        <div class="qe-middle">
+          <div class="qe-middle-arrow"></div>
+          <el-button
+            size="small"
+            type="primary"
+            :loading="loading"
+            @click="toParse"
+            >识别</el-button
+          >
+        </div>
+      </div>
+    </el-dialog>
+
+    <!-- 上传答案文件 -->
+    <import-file-dialog
+      ref="ImportAnswerDialog"
+      dialog-title="导入答案"
+      :template-download-handle="answerTemplateDownload"
+      :upload-url="uploadAnswerUrl"
+      :upload-data="uploadAnswerData"
+      add-file-param="dataFile"
+      @uploaded="answerUploaded"
+    ></import-file-dialog>
+  </div>
+</template>
+
+<script>
+// import paperRichTextJson from "../datas/paperRichText.json";
+// import paperParseData from "../datas/paperParseData.json";
+
+import { calcSum, deepCopy, objTypeOf, randomCode } from "@/plugins/utils";
+import QuestionImportPaperEdit from "./QuestionImportPaperEdit.vue";
+import UploadButton from "@/components/UploadButton.vue";
+import { isAnEmptyRichText } from "@/utils/utils";
+import {
+  questionImportPaperSave,
+  questionImportParseRichText,
+  questionImportDownloadTemplate,
+} from "../api";
+import ImportFileDialog from "@/components/ImportFileDialog.vue";
+import { QUESTION_API } from "@/constants/constants";
+import { propertyNameQueryApi } from "@/modules/question/api";
+import { downloadByApi } from "@/plugins/download";
+import { richTextToJSON, renderRichText } from "./import-edit/richText";
+
+const questionInfoField = [
+  "courseId",
+  "difficulty",
+  "quesProperties",
+  "score",
+  "publicity",
+  "control",
+  "answerAnalysis",
+  "quesAnswer",
+];
+
+export default {
+  name: "QuestionExportEdit",
+  components: { QuestionImportPaperEdit, ImportFileDialog, UploadButton },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {
+          richText: { sections: [] },
+          detailInfo: [],
+          importData: {
+            courseId: "",
+            courseName: "",
+            name: "",
+            checkTotalScore: false,
+            useOriginalPaper: false,
+            totalScore: 0,
+          },
+        };
+      },
+    },
+  },
+  data() {
+    return {
+      modalIsShow: false,
+      loading: false,
+      questionKey: "",
+      paperData: [],
+      paperRichJson: { sections: [] },
+      richTextToJSON,
+      renderRichText,
+      lastPaperScrollTop: 0,
+      lastRichTextScrollTop: 0,
+      richTextIndexList: [],
+      scrollType: "",
+      // upload answer
+      uploadAnswerUrl: `${QUESTION_API}/word/parse/import`,
+      uploadAnswerData: {},
+      // word upload
+      uploadData: {},
+      importFileTypes: ["docx", "doc"],
+      uploadUrl: `${QUESTION_API}/word/parse/struct`,
+    };
+  },
+  methods: {
+    async visibleChange() {
+      await this.getCourseProperty();
+
+      // this.resetData({
+      //   richText: paperRichTextJson,
+      //   detailInfo: paperParseData,
+      // });
+      this.resetData(this.data);
+
+      this.$nextTick(() => {
+        this.registScrollEvent();
+      });
+    },
+    resetData({ richText, detailInfo }) {
+      this.paperData = deepCopy(detailInfo);
+      this.paperRichJson = this.transformRichText(deepCopy(richText));
+      this.uploadData = { courseId: this.data.importData.courseId };
+      this.transformDataInfo();
+      this.questionKey = randomCode();
+
+      this.$nextTick(() => {
+        this.getRichTextIndexList();
+      });
+    },
+    getRichTextIndexList() {
+      const richTextBodyDom =
+        this.$refs.RichTextEditor.$el.querySelector(".v-editor-body");
+      let richTextIndexList = [];
+      richTextBodyDom.childNodes.forEach((sectionNode) => {
+        const id = sectionNode.getAttribute("id");
+        if (!id) return;
+        if (
+          sectionNode.className &&
+          sectionNode.className.includes("section-error")
+        )
+          return;
+
+        const index = id.replace("section-", "") * 1;
+        richTextIndexList.push([index, sectionNode.offsetTop]);
+      });
+      this.richTextIndexList = richTextIndexList;
+    },
+    transformRichText(richText) {
+      let nsections = [];
+      richText.sections.forEach((section) => {
+        nsections.push({
+          blocks: section.blocks,
+          attributes: { id: `section-${section.remark.index}` },
+        });
+        if (section.remark && !section.remark.status) {
+          nsections.push({
+            blocks: [
+              {
+                type: "text",
+                value: section.remark.cause,
+              },
+            ],
+            attributes: {
+              id: `section-error-${section.remark.index}`,
+              class: "section-error",
+            },
+          });
+        }
+      });
+      return { sections: nsections };
+    },
+    async getCourseProperty() {
+      const res = await propertyNameQueryApi(this.data.importData.courseId, "");
+      const optionList = res.data || [];
+      window.sessionStorage.setItem(
+        "coursePropertys",
+        JSON.stringify({ optionList, courseId: this.data.importData.courseId })
+      );
+    },
+    transformDataInfo() {
+      this.transformRichImg(this.paperRichJson);
+      this.paperData.forEach((detail) => {
+        detail.questions.forEach((question) => {
+          this.transformQuestion(question);
+          if (question.subQuestions && question.subQuestions.length) {
+            question.subQuestions.forEach((subq) => {
+              this.transformQuestion(subq);
+            });
+          }
+        });
+      });
+    },
+    transformQuestion(question) {
+      this.transformRichImg(question.body);
+      this.transformRichImg(question.answerRichTexts);
+      if (question.options && question.options.length) {
+        question.options.forEach((item) => {
+          this.transformRichImg(item.body);
+        });
+      }
+      question.quesAnswer = this.transformQuestionAnser(question.quesAnswer);
+    },
+    transformRichImg(richText) {
+      if (isAnEmptyRichText(richText)) return;
+
+      const rate = 96 / 300;
+      richText.sections.forEach((section) => {
+        section.blocks.forEach((block) => {
+          if (block.type !== "image" || !block.param) return;
+          block.param.width = block.param.width * rate;
+          block.param.height = block.param.height * rate;
+        });
+      });
+    },
+    transformQuestionAnser(quesAnswer) {
+      let qAnswer = null;
+      try {
+        qAnswer = quesAnswer ? JSON.parse(quesAnswer) : null;
+      } catch (error) {
+        console.log(error);
+      }
+      if (!qAnswer || objTypeOf(qAnswer) !== "array") return quesAnswer;
+
+      qAnswer.forEach((item) => {
+        this.transformRichImg(item);
+      });
+      return JSON.stringify(qAnswer);
+    },
+    initData() {
+      this.paperData = [];
+      this.paperRichJson = { sections: [] };
+      window.sessionStorage.removeItem("coursePropertys");
+      this.$message.closeAll();
+      this.removeScrollEvent();
+    },
+    cancel() {
+      this.modalIsShow = false;
+    },
+    open() {
+      this.modalIsShow = true;
+    },
+    async toParse() {
+      if (isAnEmptyRichText(this.paperRichJson)) {
+        this.$message.error("请输入试卷内容!");
+        return;
+      }
+
+      if (this.loading) return;
+      this.loading = true;
+
+      let richText = this.$refs.RichTextEditor.emitJsonAction();
+      richText.sections = richText.sections.filter(
+        (item) =>
+          !item.attributes || item.attributes["class"] !== "section-error"
+      );
+
+      const res = await questionImportParseRichText({
+        richText,
+        courseId: this.data.importData.courseId,
+      }).catch(() => {});
+      this.loading = false;
+      if (!res) return;
+
+      const cacheData = this.getCachePaperInfo(
+        this.getImportPaperData(),
+        questionInfoField
+      );
+      // console.log(cacheData);
+      this.paperData = this.assignCachePaperData(
+        res.data.detailInfo,
+        cacheData
+      );
+      this.paperRichJson = this.transformRichText(deepCopy(res.data.richText));
+      this.questionKey = randomCode();
+
+      this.$nextTick(() => {
+        this.getRichTextIndexList();
+      });
+    },
+    getCachePaperInfo(paperData, cacheFields = []) {
+      let cachePaperInfo = {};
+      paperData.forEach((detail, dIndex) => {
+        detail.questionInfo.forEach((question, qIndex) => {
+          let info = {};
+          let k = `${dIndex + 1}_${qIndex + 1}`;
+          if (cacheFields.length) {
+            cacheFields.forEach((field) => {
+              info[field] = question[field];
+            });
+          } else {
+            info = { ...question };
+          }
+
+          cachePaperInfo[k] = info;
+
+          if (question.subQuestions && question.subQuestions.length) {
+            question.subQuestions.forEach((subq, subqIndex) => {
+              let info = {};
+              let k = `${dIndex + 1}_${qIndex + 1}_${subqIndex + 1}`;
+              if (cacheFields.length) {
+                cacheFields.forEach((field) => {
+                  info[field] = subq[field];
+                });
+              } else {
+                info = { ...subq };
+              }
+
+              cachePaperInfo[k] = info;
+            });
+          }
+        });
+      });
+      // console.log(cachePaperInfo);
+      return cachePaperInfo;
+    },
+    assignCachePaperData(paperData, cacheData, mergeReverse = false) {
+      return paperData.map((detail, dIndex) => {
+        detail.questions = detail.questions.map((question, qIndex) => {
+          let k = `${dIndex + 1}_${qIndex + 1}`;
+          let nq = this.mergeObjData(
+            question,
+            cacheData[k] || {},
+            mergeReverse
+          );
+          if (question.subQuestions && question.subQuestions.length) {
+            nq.subQuestions = question.subQuestions.map((subq, subqIndex) => {
+              let k = `${dIndex + 1}_${qIndex + 1}_${subqIndex + 1}`;
+              return this.mergeObjData(subq, cacheData[k] || {}, mergeReverse);
+            });
+          }
+          return nq;
+        });
+        return detail;
+      });
+    },
+    isNull(val) {
+      if (val) {
+        if (val === "[]") return true;
+        if (objTypeOf(val) === "array" && !val.length) return true;
+      }
+      return val === null || val === "" || val === undefined;
+    },
+    mergeObjData(targetObj, cacheObj, mergeReverse) {
+      let data = { ...targetObj };
+      Object.keys(cacheObj).forEach((k) => {
+        if (mergeReverse) {
+          data[k] = this.isNull(cacheObj[k]) ? targetObj[k] : cacheObj[k];
+        } else {
+          data[k] = this.isNull(targetObj[k]) ? cacheObj[k] : targetObj[k];
+        }
+      });
+      return data;
+    },
+    getImportPaperData() {
+      if (!this.$refs.QuestionImportPaperEdit) return [];
+      let paperData = deepCopy(this.$refs.QuestionImportPaperEdit.getData());
+      const transformFieldMap = { body: "quesBody", options: "quesOptions" };
+      const fields = Object.keys(transformFieldMap);
+      const course = {
+        id: this.data.importData.courseId,
+        name: this.data.importData.courseName,
+      };
+
+      const transformQuestion = (question) => {
+        question.id = null;
+        question.course = course;
+        fields.forEach((field) => {
+          question[transformFieldMap[field]] = question[field];
+          delete question[field];
+        });
+        if (question.quesOptions && question.quesOptions.length) {
+          question.quesOptions = question.quesOptions.map((option) => {
+            option.optionBody = option.body;
+            delete option.body;
+            return option;
+          });
+        }
+        return question;
+      };
+
+      const detailInfo = paperData.map((detail) => {
+        const questionInfo = detail.questions.map((question) => {
+          transformQuestion(question);
+
+          if (question.subQuestions && question.subQuestions.length) {
+            question.subQuestions = question.subQuestions.map((subq) =>
+              transformQuestion(subq)
+            );
+            question.score = calcSum(
+              question.subQuestions.map((q) => q.score || 0)
+            );
+          }
+          return question;
+        });
+
+        return {
+          name: detail.name,
+          number: detail.number,
+          questionCount: questionInfo.length,
+          questionInfo,
+          questionScore: detail.questionScore,
+          totalScore: calcSum(questionInfo.map((q) => q.score || 0)),
+        };
+      });
+      // console.log(detailInfo);
+      return detailInfo;
+    },
+    checkImportPaperData(paperData) {
+      this.$message.closeAll();
+
+      // 题目内容校验
+      const MATCHING_QUESTION = ["PARAGRAPH_MATCHING", "BANKED_CLOZE"];
+      const SELECT_QUESTION = [
+        "SINGLE_ANSWER_QUESTION",
+        "MULTIPLE_ANSWER_QUESTION",
+        ...MATCHING_QUESTION,
+      ];
+      const NESTED_QUESTION = [
+        ...MATCHING_QUESTION,
+        "READING_COMPREHENSION",
+        "CLOZE",
+        "LISTENING_QUESTION",
+      ];
+      const ALLOW_EMPTY_BODY_QUESTION = [
+        "LISTENING_QUESTION",
+        ...MATCHING_QUESTION,
+      ];
+      let errInfos = [];
+      paperData.forEach((detail) => {
+        detail.questionInfo.forEach((question) => {
+          const { questionType, quesBody } = question;
+          const questionTitle = `第${detail.number}大题第${question.number}小题`;
+          let qErrInfo = [];
+          // 题干
+          if (
+            !ALLOW_EMPTY_BODY_QUESTION.includes(questionType) &&
+            (!quesBody || isAnEmptyRichText(quesBody))
+          ) {
+            qErrInfo.push(`没有题干`);
+          }
+
+          // 选项
+          if (SELECT_QUESTION.includes(questionType)) {
+            if (!question.quesOptions.length) {
+              qErrInfo.push(`没有选项`);
+            }
+            if (
+              question.quesOptions.some((option) =>
+                isAnEmptyRichText(option.optionBody)
+              )
+            ) {
+              qErrInfo.push(`有选择内容为空`);
+            }
+          }
+
+          // 小题数
+          if (
+            NESTED_QUESTION.includes(questionType) &&
+            !question.subQuestions.length
+          ) {
+            qErrInfo.push(`没有小题`);
+          }
+
+          if (qErrInfo.length) {
+            errInfos.push(`${questionTitle}${qErrInfo.join("、")}`);
+            qErrInfo = [];
+          }
+
+          // 选词填空、段落匹配,单用模式时校验输入答案是否重复
+          if (
+            MATCHING_QUESTION.includes(questionType) &&
+            question.quesParam.matchingMode === 1
+          ) {
+            let selectedAnswer = [],
+              errorQuestionIndexs = [];
+            question.subQuestions.forEach((subq, sindex) => {
+              if (selectedAnswer.includes(subq.quesAnswer)) {
+                errorQuestionIndexs.push(`${question.number}-${sindex + 1}`);
+              } else {
+                if (subq.quesAnswer !== "[]")
+                  selectedAnswer.push(subq.quesAnswer);
+              }
+            });
+            if (errorQuestionIndexs.length) {
+              errInfos.push(
+                `第${
+                  detail.number
+                }大题${errorQuestionIndexs.join()}小题答案重复!`
+              );
+            }
+          }
+
+          if (!NESTED_QUESTION.includes(questionType)) return;
+
+          // 套题小题校验
+          question.subQuestions.forEach((subq, sindex) => {
+            const subqTitle = `第${detail.number}大题第${question.number}-${
+              sindex + 1
+            }小题`;
+            if (
+              questionType === "READING_COMPREHENSION" &&
+              (!subq.quesBody || isAnEmptyRichText(subq.quesBody))
+            ) {
+              qErrInfo.push(`没有题干`);
+            }
+            if (
+              SELECT_QUESTION.includes(subq.subqType) &&
+              !MATCHING_QUESTION.includes(questionType)
+            ) {
+              if (!subq.quesOptions.length) {
+                qErrInfo.push(`没有选项`);
+              }
+              if (
+                subq.quesOptions.some((option) =>
+                  isAnEmptyRichText(option.optionBody)
+                )
+              ) {
+                qErrInfo.push(`有选择内容为空`);
+              }
+            }
+
+            if (qErrInfo.length) {
+              errInfos.push(`${subqTitle}${qErrInfo.join("、")}`);
+              qErrInfo = [];
+            }
+          });
+        });
+      });
+      if (errInfos.length) {
+        this.$message({
+          showClose: true,
+          message: errInfos.join("。"),
+          type: "error",
+          duration: 0,
+        });
+        return;
+      }
+
+      if (!this.data.importData.useOriginalPaper) return true;
+
+      let detailNumbers = paperData.map((detail) => detail.number);
+      // 大题号重复性校验
+      let repeatDetaiNumbers = [];
+      let detailNums = [];
+      for (let i = 0; i < detailNumbers.length; i++) {
+        const num = detailNumbers[i];
+        if (detailNums.includes(num)) {
+          if (!repeatDetaiNumbers.includes(num)) repeatDetaiNumbers.push(num);
+        } else {
+          detailNums.push(num);
+        }
+      }
+      if (repeatDetaiNumbers.length) {
+        this.$message({
+          showClose: true,
+          message: `大题号${repeatDetaiNumbers.join("、")}重复`,
+          type: "error",
+          duration: 0,
+        });
+        return;
+      }
+      // 大题号连续性校验
+      for (let i = 0; i < detailNumbers.length; i++) {
+        if (detailNumbers[i] - 1 !== i) {
+          this.$message({
+            showClose: true,
+            message: "大题号不连续",
+            type: "error",
+            duration: 0,
+          });
+          return;
+        }
+      }
+
+      // 答案、分数校验
+      let totalScore = calcSum(paperData.map((d) => d.totalScore));
+      let errQuestions = [];
+      paperData.forEach((detail) => {
+        detail.questionInfo.forEach((question) => {
+          if (question.subQuestions && question.subQuestions.length) {
+            let subIndexs = [];
+            question.subQuestions.forEach((subq, sind) => {
+              if (!subq.score)
+                subIndexs.push(question.number + "-" + (sind + 1));
+            });
+            if (subIndexs.length)
+              errQuestions.push(
+                `第${detail.number}大题第${subIndexs.join()}小题`
+              );
+          } else {
+            if (!question.score) {
+              errQuestions.push(
+                `第${detail.number}大题第${question.number}小题`
+              );
+            }
+          }
+        });
+      });
+      if (errQuestions.length) {
+        this.$message({
+          showClose: true,
+          message: `请设置如下试题的分值:${errQuestions.join("、")}。`,
+          type: "error",
+          duration: 0,
+        });
+        return;
+      }
+
+      if (
+        this.data.importData.checkTotalScore &&
+        totalScore !== this.data.importData.totalScore
+      ) {
+        this.$message({
+          showClose: true,
+          message: `试卷总分与导入设置的总分不一致!`,
+          type: "error",
+          duration: 0,
+        });
+        return;
+      }
+
+      return true;
+    },
+    async confirm() {
+      const confirm = await this.$confirm("确认加入题库吗?", "提示", {
+        type: "warning",
+      }).catch(() => {});
+      if (confirm !== "confirm") return;
+
+      const detailInfo = this.getImportPaperData();
+      if (!this.checkImportPaperData(detailInfo)) return;
+
+      if (this.loading) return;
+      this.loading = true;
+
+      const res = await questionImportPaperSave({
+        ...this.data.importData,
+        detailInfo,
+      }).catch(() => {});
+
+      this.loading = false;
+      if (!res) return;
+
+      this.$message.success("提交成功!");
+      this.$emit("modified");
+      this.cancel();
+    },
+    // 导入答案属性
+    toImportAnswer() {
+      const detailInfo = this.getImportPaperData();
+      this.uploadAnswerData = {
+        detailInfo: JSON.stringify(detailInfo),
+        ...this.data.importData,
+      };
+      this.$refs.ImportAnswerDialog.open();
+    },
+    async answerTemplateDownload() {
+      const detailInfo = this.getImportPaperData();
+
+      const res = await downloadByApi(() => {
+        return questionImportDownloadTemplate({
+          detailInfo,
+          ...this.data.importData,
+        });
+      }).catch((e) => {
+        this.$message.error(e || "下载失败,请重新尝试!");
+      });
+
+      if (!res) return;
+      this.$message.success("下载成功!");
+    },
+    answerUploaded(res) {
+      const cacheData = this.getCachePaperInfo(
+        res.data.detailInfo,
+        questionInfoField
+      );
+      this.paperData = this.assignCachePaperData(
+        this.paperData,
+        cacheData,
+        true
+      );
+      this.questionKey = randomCode();
+    },
+    // word upload
+    uploaded(res) {
+      this.$message.success("上传成功!");
+      this.paperRichJson = deepCopy(res.data.richText);
+      this.paperData = deepCopy(res.data.detailInfo);
+      this.transformDataInfo();
+      this.questionKey = randomCode();
+    },
+    uploadError(error) {
+      this.$message.error(error.message);
+    },
+    // scroll
+    registScrollEvent() {
+      document
+        .getElementById("qe-part-paper")
+        .addEventListener("scroll", this.paperScrollEvent);
+      this.$refs.RichTextEditor.$el
+        .querySelector(".v-editor-container")
+        .addEventListener("scroll", this.richTextScrollEvent);
+    },
+    removeScrollEvent() {
+      document
+        .getElementById("qe-part-paper")
+        .removeEventListener("scroll", this.paperScrollEvent);
+      this.$refs.RichTextEditor.$el
+        .querySelector(".v-editor-container")
+        .removeEventListener("scroll", this.richTextScrollEvent);
+    },
+    paperScrollEvent(e) {
+      // e.preventDefault();
+      // e.stopPropagation();
+      if (this.scrollType === "rich-text") {
+        this.lastPaperScrollTop =
+          document.getElementById("qe-part-paper").scrollTop;
+        return;
+      }
+      this.scrollType = "paper";
+      setTimeout(() => {
+        this.scrollType = "";
+      }, 100);
+      const questionContIndexList =
+        this.$refs.QuestionImportPaperEdit.questionContIndexList;
+
+      const scrollTop = e.target.scrollTop;
+      const isScrollDown = scrollTop > this.lastPaperScrollTop;
+      this.lastPaperScrollTop = scrollTop;
+      const targeContIndex = questionContIndexList.findIndex(
+        (item) => scrollTop < item[3]
+      );
+      let targeContPercent = 0;
+      let targeCont = null;
+      let nextTargetCont = null;
+      if (targeContIndex !== -1) {
+        targeCont = questionContIndexList[targeContIndex - 1];
+        nextTargetCont = questionContIndexList[targeContIndex];
+        targeContPercent =
+          (scrollTop - targeCont[3]) / (nextTargetCont[3] - targeCont[3]);
+      } else {
+        targeCont = questionContIndexList.slice(-1)[0];
+        const textHeight = this.$refs.QuestionImportPaperEdit.$el.offsetHeight;
+        targeContPercent =
+          (scrollTop - targeCont[3]) / (textHeight - targeCont[3]);
+      }
+
+      const richTextSectionDom = document.getElementById(
+        `section-${targeCont[2][0]}`
+      );
+      if (!richTextSectionDom) return;
+
+      const richTextContainerDom = this.$refs.RichTextEditor.$el.querySelector(
+        ".v-editor-container"
+      );
+      const richTextMainDom =
+        this.$refs.RichTextEditor.$el.querySelector(".v-editor-main");
+      const sectionOffsetTop = richTextSectionDom.offsetTop;
+      let nextSectionOffsetTop = richTextMainDom.offsetHeight;
+
+      if (nextTargetCont) {
+        const nextRichTextSectionDom = document.getElementById(
+          `section-${nextTargetCont[2][0]}`
+        );
+        if (nextRichTextSectionDom) {
+          nextSectionOffsetTop = nextRichTextSectionDom.offsetTop;
+        } else {
+          nextSectionOffsetTop =
+            richTextSectionDom.offsetTop + richTextSectionDom.offsetHeight;
+        }
+      }
+
+      const textScrollTop =
+        sectionOffsetTop +
+        targeContPercent * (nextSectionOffsetTop - sectionOffsetTop);
+      // console.log(
+      //   targeCont[2],
+      //   textScrollTop,
+      //   targeContPercent,
+      //   nextSectionOffsetTop,
+      //   sectionOffsetTop
+      // );
+      richTextContainerDom.scrollTop = isScrollDown
+        ? Math.max(textScrollTop, richTextContainerDom.scrollTop)
+        : Math.min(textScrollTop, richTextContainerDom.scrollTop);
+    },
+    richTextScrollEvent(e) {
+      if (this.scrollType === "paper") {
+        this.lastRichTextScrollTop =
+          this.$refs.RichTextEditor.$el.querySelector(
+            ".v-editor-container"
+          ).scrollTop;
+        return;
+      }
+      this.scrollType = "rich-text";
+      setTimeout(() => {
+        this.scrollType = "";
+      }, 100);
+
+      const isScrollDown = e.target.scrollTop > this.lastRichTextScrollTop;
+      // console.log(isScrollDown, e.target.scrollTop, this.lastRichTextScrollTop);
+      this.lastRichTextScrollTop = e.target.scrollTop;
+      const offsetH = isScrollDown ? 150 : 0;
+      const scrollTop = e.target.scrollTop + offsetH;
+
+      const richTextMainDom =
+        this.$refs.RichTextEditor.$el.querySelector(".v-editor-main");
+      const questionContIndexList =
+        this.$refs.QuestionImportPaperEdit.questionContIndexList;
+
+      const findQuestionItemDom = (sectionIndex) => {
+        const questionCont = questionContIndexList.find((item) =>
+          item[2].includes(sectionIndex)
+        );
+        if (!questionCont) return;
+        const [id, type] = questionCont;
+        let itemDom = document.getElementById(id);
+        if (type === "body") {
+          itemDom = itemDom.querySelector(".ep-question-title");
+        } else if (type === "option") {
+          itemDom = itemDom.querySelector(".ep-question-body");
+        } else if (type === "answer") {
+          itemDom =
+            itemDom.querySelector(".question-info-view") ||
+            itemDom.querySelector(".ep-question-props");
+        }
+        return itemDom;
+      };
+
+      const targeContIndex = this.richTextIndexList.findIndex(
+        (item) => scrollTop < item[1]
+      );
+      if (!targeContIndex) return;
+      let targeContPercent = 0;
+      let targeCont = null;
+      let nextTargetCont = null;
+      if (targeContIndex !== -1) {
+        targeCont = this.richTextIndexList[targeContIndex - 1];
+        nextTargetCont = this.richTextIndexList[targeContIndex];
+        targeContPercent =
+          (scrollTop - targeCont[1]) / (nextTargetCont[1] - targeCont[1]);
+      } else {
+        targeCont = this.richTextIndexList.slice(-1)[0];
+        const textHeight = richTextMainDom.offsetHeight;
+        targeContPercent =
+          (scrollTop - targeCont[1]) / (textHeight - targeCont[1]);
+      }
+
+      const questionContDom = findQuestionItemDom(targeCont[0]);
+      if (!questionContDom) return;
+      const questionListDom = this.$refs.QuestionImportPaperEdit.$el;
+      const elPos = questionListDom.getBoundingClientRect();
+      const questionPos = questionContDom.getBoundingClientRect();
+      const questionContOffsetTop = questionPos.y - elPos.y;
+      let nextQuestionContOffsetTop = questionListDom.offsetHeight;
+
+      if (nextTargetCont) {
+        const nextQuestionContDom = findQuestionItemDom(nextTargetCont[0]);
+        if (nextQuestionContDom) {
+          const nextQuestionPos = nextQuestionContDom.getBoundingClientRect();
+          nextQuestionContOffsetTop = nextQuestionPos.y - elPos.y;
+        } else {
+          nextQuestionContOffsetTop =
+            questionContOffsetTop + questionContDom.offsetHeight;
+        }
+      }
+
+      const questionScrollTop =
+        questionContOffsetTop +
+        targeContPercent * (nextQuestionContOffsetTop - questionContOffsetTop);
+      const questionContainerDom = document.getElementById("qe-part-paper");
+      questionContainerDom.scrollTop = isScrollDown
+        ? Math.max(questionScrollTop, questionContainerDom.scrollTop)
+        : Math.min(questionScrollTop, questionContainerDom.scrollTop);
+    },
+  },
+};
+</script>

+ 22 - 3
src/modules/question/components/QuestionImportPaperEdit.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="question-export-paper-edit">
+  <div class="question-import-paper-edit">
     <div
       v-for="(detail, dIndex) in paperData"
       :key="dIndex"
@@ -26,7 +26,8 @@
               :is="structTypeComp(question.questionType)"
               :ref="`QuestionEditDetail-${dIndex}-${qindex}-${question.id}`"
               :question="question"
-            ></component>
+            >
+            </component>
           </div>
         </div>
       </div>
@@ -46,7 +47,7 @@ import { STRUCT_TYPE_COMP_DICT } from "./edit/questionModel";
 import { mapState } from "vuex";
 
 export default {
-  name: "QuestionExportPaperEdit",
+  name: "QuestionImportPaperEdit",
   components: {
     FillBlankQuestion,
     SelectQuestion,
@@ -93,11 +94,29 @@ export default {
       return STRUCT_TYPE_COMP_DICT[questionType];
     },
     getQuestionInfo(question) {
+      const exceptionField = ["bodyIndex", "optionIndex"];
+      let exceptionIndexs = [];
+      exceptionField.forEach((item) => {
+        if (question.questionIndex[item])
+          exceptionIndexs.push(...question.questionIndex[item]);
+      });
+      let exceptions = [];
+      if (question.questionExceptions) {
+        question.questionExceptions.forEach((item) => {
+          if (
+            item.exceptionIndex.some((key) => exceptionIndexs.includes(key))
+          ) {
+            exceptions.push(item.cause);
+          }
+        });
+      }
+
       return {
         sourceDetailId: question.customizeQuestionType.id,
         sourceDetailName: question.customizeQuestionType.name,
         questionType: question.customizeQuestionType.questionType,
         courseId: this.courseId,
+        exceptions,
         score: question.score || 0,
         id: randomCode(),
       };

+ 11 - 2
src/modules/question/components/import-edit/BankedClozeQuestion.vue

@@ -15,7 +15,15 @@
         <rich-text :text-json="option.body"></rich-text>
       </div>
     </div>
-
+    <div v-if="question.exceptions.length" class="ep-question-exception">
+      <p
+        class="tips-info tips-error"
+        v-for="(cont, index) in question.exceptions"
+        :key="index"
+      >
+        {{ cont }}
+      </p>
+    </div>
     <el-form ref="modalFormComp" label-width="72px">
       <el-form-item label="答题模式">
         <el-select v-model="matchingMode">
@@ -39,7 +47,8 @@
         ref="MatchQuestion"
         :question="subq"
         :parent-question="question"
-      ></match-question>
+      >
+      </match-question>
     </div>
   </div>
 </template>

+ 9 - 0
src/modules/question/components/import-edit/BooleanQuestion.vue

@@ -8,6 +8,15 @@
         <span class="ep-question-title-number">{{ question.number }}.</span>
         <rich-text :text-json="question.body"></rich-text>
       </div>
+      <div v-if="question.exceptions.length" class="ep-question-exception">
+        <p
+          class="tips-info tips-error"
+          v-for="(cont, index) in question.exceptions"
+          :key="index"
+        >
+          {{ cont }}
+        </p>
+      </div>
       <question-info-view
         v-if="!isActive"
         :question="getData()"

+ 9 - 0
src/modules/question/components/import-edit/FillBlankQuestion.vue

@@ -8,6 +8,15 @@
         <span class="ep-question-title-number">{{ question.number }}.</span>
         <rich-text :text-json="question.body"></rich-text>
       </div>
+      <div v-if="question.exceptions.length" class="ep-question-exception">
+        <p
+          class="tips-info tips-error"
+          v-for="(cont, index) in question.exceptions"
+          :key="index"
+        >
+          {{ cont }}
+        </p>
+      </div>
       <question-info-view
         v-if="!isActive"
         :question="getData()"

+ 9 - 0
src/modules/question/components/import-edit/MatchQuestion.vue

@@ -8,6 +8,15 @@
         <span class="ep-question-title-number">{{ question.number }}.</span>
         <rich-text :text-json="question.body"></rich-text>
       </div>
+      <div v-if="question.exceptions.length" class="ep-question-exception">
+        <p
+          class="tips-info tips-error"
+          v-for="(cont, index) in question.exceptions"
+          :key="index"
+        >
+          {{ cont }}
+        </p>
+      </div>
       <question-info-view
         v-if="!isActive"
         :question="getData()"

+ 11 - 1
src/modules/question/components/import-edit/NestedQuestion.vue

@@ -3,6 +3,15 @@
     <div class="ep-question-title">
       <rich-text :text-json="question.body"></rich-text>
     </div>
+    <div v-if="question.exceptions.length" class="ep-question-exception">
+      <p
+        class="tips-info tips-error"
+        v-for="(cont, index) in question.exceptions"
+        :key="index"
+      >
+        {{ cont }}
+      </p>
+    </div>
     <div
       v-for="subq in question.subQuestions"
       :key="subq.id"
@@ -13,7 +22,8 @@
         :is="getStructTypeComp(subq.questionType)"
         ref="QuestionEditDetail"
         :question="subq"
-      ></component>
+      >
+      </component>
     </div>
   </div>
 </template>

+ 9 - 0
src/modules/question/components/import-edit/SelectQuestion.vue

@@ -38,6 +38,15 @@
           <rich-text :text-json="option.body"></rich-text>
         </div>
       </div>
+      <div v-if="question.exceptions.length" class="ep-question-exception">
+        <p
+          class="tips-info tips-error"
+          v-for="(cont, index) in question.exceptions"
+          :key="index"
+        >
+          {{ cont }}
+        </p>
+      </div>
       <question-info-view
         v-if="!isActive"
         :question="getData()"

+ 9 - 0
src/modules/question/components/import-edit/TextAnswerQuestion.vue

@@ -8,6 +8,15 @@
         <span class="ep-question-title-number">{{ question.number }}.</span>
         <rich-text :text-json="question.body"></rich-text>
       </div>
+      <div v-if="question.exceptions.length" class="ep-question-exception">
+        <p
+          class="tips-info tips-error"
+          v-for="(cont, index) in question.exceptions"
+          :key="index"
+        >
+          {{ cont }}
+        </p>
+      </div>
       <question-info-view
         v-if="!isActive"
         :question="getData()"