QuestionImportEdit.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661
  1. <template>
  2. <div class="question-export-edit">
  3. <el-dialog
  4. custom-class="question-export-edit-dialog"
  5. :visible.sync="modalIsShow"
  6. :close-on-click-modal="false"
  7. :close-on-press-escape="false"
  8. append-to-body
  9. fullscreen
  10. destroy-on-close
  11. :show-close="false"
  12. @opened="visibleChange"
  13. @closed="initData"
  14. >
  15. <div slot="title" class="box-justify">
  16. <div>
  17. <h2>文件上传</h2>
  18. </div>
  19. <div>
  20. <el-button
  21. size="small"
  22. type="danger"
  23. plain
  24. icon="icon icon-back"
  25. @click="cancel"
  26. >返回</el-button
  27. >
  28. </div>
  29. </div>
  30. <div class="qe-body">
  31. <div class="qe-part qe-part-edit">
  32. <div class="qe-part-main">
  33. <div class="qe-part-head">
  34. <h3>题目编辑</h3>
  35. <div>
  36. <i class="icon icon-tips"></i>
  37. 提示:若识别有误,可点击左侧题目按格式进行修改后重新识别
  38. </div>
  39. </div>
  40. <div class="qe-part-body">
  41. <v-editor
  42. v-model="paperRichJson"
  43. :enable-formula="false"
  44. :enable-audio="false"
  45. ></v-editor>
  46. </div>
  47. </div>
  48. </div>
  49. <div class="qe-part qe-part-view">
  50. <div class="qe-part-main">
  51. <div class="qe-part-head">
  52. <h3>题目阅览</h3>
  53. <div>
  54. <el-button
  55. size="small"
  56. type="primary"
  57. plain
  58. icon="icon icon-export-answer"
  59. @click="toImportAnswer"
  60. >导入答案属性</el-button
  61. >
  62. <el-button
  63. size="small"
  64. type="primary"
  65. icon="icon icon-save-white"
  66. :loading="loading"
  67. @click="confirm"
  68. >识别无误,加入题库</el-button
  69. >
  70. </div>
  71. </div>
  72. <div class="qe-part-body">
  73. <question-import-paper-edit
  74. v-if="paperData.length"
  75. ref="QuestionImportPaperEdit"
  76. :key="questionKey"
  77. :paper="paperData"
  78. :course-id="data.importData.courseId"
  79. ></question-import-paper-edit>
  80. </div>
  81. </div>
  82. </div>
  83. <div class="qe-middle">
  84. <div class="qe-middle-arrow"></div>
  85. <el-button
  86. size="small"
  87. type="primary"
  88. :loading="loading"
  89. @click="toParse"
  90. >识别</el-button
  91. >
  92. </div>
  93. </div>
  94. </el-dialog>
  95. <!-- 上传答案文件 -->
  96. <import-file-dialog
  97. ref="ImportAnswerDialog"
  98. dialog-title="导入答案"
  99. :template-download-handle="answerTemplateDownload"
  100. :upload-url="uploadAnswerUrl"
  101. :upload-data="uploadAnswerData"
  102. add-file-param="dataFile"
  103. @uploaded="answerUploaded"
  104. ></import-file-dialog>
  105. </div>
  106. </template>
  107. <script>
  108. // import paperRichTextJson from "../datas/paperRichText.json";
  109. // import paperParseData from "../datas/paperParseData.json";
  110. import { calcSum, deepCopy, objTypeOf, randomCode } from "@/plugins/utils";
  111. import QuestionImportPaperEdit from "./QuestionImportPaperEdit.vue";
  112. import { isAnEmptyRichText } from "@/utils/utils";
  113. import {
  114. questionImportPaperSave,
  115. questionImportParseRichText,
  116. questionImportDownloadTemplate,
  117. } from "../api";
  118. import ImportFileDialog from "@/components/ImportFileDialog.vue";
  119. import { QUESTION_API } from "@/constants/constants";
  120. import { propertyNameQueryApi } from "@/modules/question/api";
  121. import { downloadByApi } from "@/plugins/download";
  122. const questionInfoField = [
  123. "courseId",
  124. "difficulty",
  125. "quesProperties",
  126. "score",
  127. "publicity",
  128. "control",
  129. "answerAnalysis",
  130. "quesAnswer",
  131. ];
  132. export default {
  133. name: "QuestionExportEdit",
  134. components: { QuestionImportPaperEdit, ImportFileDialog },
  135. props: {
  136. data: {
  137. type: Object,
  138. default() {
  139. return {
  140. richText: { sections: [] },
  141. detailInfo: [],
  142. importData: {
  143. courseId: "",
  144. courseName: "",
  145. name: "",
  146. checkTotalScore: false,
  147. useOriginalPaper: false,
  148. totalScore: 0,
  149. },
  150. };
  151. },
  152. },
  153. },
  154. data() {
  155. return {
  156. modalIsShow: false,
  157. loading: false,
  158. questionKey: "",
  159. paperData: [],
  160. paperRichJson: { sections: [] },
  161. // upload answer
  162. uploadAnswerUrl: `${QUESTION_API}/word/parse/import`,
  163. uploadAnswerData: {},
  164. };
  165. },
  166. methods: {
  167. async visibleChange() {
  168. await this.getCourseProperty();
  169. // this.paperData = deepCopy(paperParseData);
  170. // this.paperRichJson = deepCopy(paperRichTextJson);
  171. this.paperRichJson = deepCopy(this.data.richText);
  172. this.paperData = deepCopy(this.data.detailInfo);
  173. this.transformDataInfo();
  174. this.questionKey = randomCode();
  175. },
  176. async getCourseProperty() {
  177. const res = await propertyNameQueryApi(this.data.importData.courseId, "");
  178. const optionList = res.data || [];
  179. window.sessionStorage.setItem(
  180. "coursePropertys",
  181. JSON.stringify({ optionList, courseId: this.data.importData.courseId })
  182. );
  183. },
  184. transformDataInfo() {
  185. this.transformRichImg(this.paperRichJson);
  186. this.paperData.forEach((detail) => {
  187. detail.questions.forEach((question) => {
  188. this.transformQuestion(question);
  189. if (question.subQuestions && question.subQuestions.length) {
  190. question.subQuestions.forEach((subq) => {
  191. this.transformQuestion(subq);
  192. });
  193. }
  194. });
  195. });
  196. },
  197. transformQuestion(question) {
  198. this.transformRichImg(question.body);
  199. this.transformRichImg(question.answerRichTexts);
  200. if (question.options && question.options.length) {
  201. question.options.forEach((item) => {
  202. this.transformRichImg(item.body);
  203. });
  204. }
  205. // this.transformRichImg(question.quesAnswer);
  206. },
  207. transformRichImg(richText) {
  208. if (isAnEmptyRichText(richText)) return;
  209. const rate = 96 / 300;
  210. richText.sections.forEach((section) => {
  211. section.blocks.forEach((block) => {
  212. if (block.type !== "image" || !block.param) return;
  213. block.param.width = block.param.width * rate;
  214. block.param.height = block.param.height * rate;
  215. });
  216. });
  217. },
  218. initData() {
  219. this.paperData = [];
  220. this.paperRichJson = { sections: [] };
  221. window.sessionStorage.removeItem("coursePropertys");
  222. this.$message.closeAll();
  223. },
  224. cancel() {
  225. this.modalIsShow = false;
  226. },
  227. open() {
  228. this.modalIsShow = true;
  229. },
  230. async toParse() {
  231. if (isAnEmptyRichText(this.paperRichJson)) {
  232. this.$message.error("请输入试卷内容!");
  233. return;
  234. }
  235. if (this.loading) return;
  236. this.loading = true;
  237. const res = await questionImportParseRichText({
  238. richText: this.paperRichJson,
  239. courseId: this.data.importData.courseId,
  240. }).catch(() => {});
  241. this.loading = false;
  242. if (!res) return;
  243. const cacheData = this.getCachePaperInfo(
  244. this.getImportPaperData(),
  245. questionInfoField
  246. );
  247. // console.log(cacheData);
  248. this.paperData = this.assignCachePaperData(res.data, cacheData);
  249. this.questionKey = randomCode();
  250. },
  251. getCachePaperInfo(paperData, cacheFields = []) {
  252. let cachePaperInfo = {};
  253. paperData.forEach((detail, dIndex) => {
  254. detail.questionInfo.forEach((question, qIndex) => {
  255. let info = {};
  256. let k = `${dIndex + 1}_${qIndex + 1}`;
  257. if (cacheFields.length) {
  258. cacheFields.forEach((field) => {
  259. info[field] = question[field];
  260. });
  261. } else {
  262. info = { ...question };
  263. }
  264. cachePaperInfo[k] = info;
  265. if (question.subQuestions && question.subQuestions.length) {
  266. question.subQuestions.forEach((subq, subqIndex) => {
  267. let info = {};
  268. let k = `${dIndex + 1}_${qIndex + 1}_${subqIndex + 1}`;
  269. if (cacheFields.length) {
  270. cacheFields.forEach((field) => {
  271. info[field] = subq[field];
  272. });
  273. } else {
  274. info = { ...subq };
  275. }
  276. cachePaperInfo[k] = info;
  277. });
  278. }
  279. });
  280. });
  281. // console.log(cachePaperInfo);
  282. return cachePaperInfo;
  283. },
  284. assignCachePaperData(paperData, cacheData, mergeReverse = false) {
  285. return paperData.map((detail, dIndex) => {
  286. detail.questions = detail.questions.map((question, qIndex) => {
  287. let k = `${dIndex + 1}_${qIndex + 1}`;
  288. let nq = this.mergeObjData(
  289. question,
  290. cacheData[k] || {},
  291. mergeReverse
  292. );
  293. if (question.subQuestions && question.subQuestions.length) {
  294. nq.subQuestions = question.subQuestions.map((subq, subqIndex) => {
  295. let k = `${dIndex + 1}_${qIndex + 1}_${subqIndex + 1}`;
  296. return this.mergeObjData(subq, cacheData[k] || {}, mergeReverse);
  297. });
  298. }
  299. return nq;
  300. });
  301. return detail;
  302. });
  303. },
  304. isNull(val) {
  305. if (val) {
  306. if (val === "[]") return true;
  307. if (objTypeOf(val) === "array" && !val.length) return true;
  308. }
  309. return val === null || val === "" || val === undefined;
  310. },
  311. mergeObjData(targetObj, cacheObj, mergeReverse) {
  312. let data = { ...targetObj };
  313. Object.keys(cacheObj).forEach((k) => {
  314. if (mergeReverse) {
  315. data[k] = this.isNull(cacheObj[k]) ? targetObj[k] : cacheObj[k];
  316. } else {
  317. data[k] = this.isNull(targetObj[k]) ? cacheObj[k] : targetObj[k];
  318. }
  319. });
  320. return data;
  321. },
  322. getImportPaperData() {
  323. if (!this.$refs.QuestionImportPaperEdit) return [];
  324. let paperData = deepCopy(this.$refs.QuestionImportPaperEdit.getData());
  325. const transformFieldMap = { body: "quesBody", options: "quesOptions" };
  326. const fields = Object.keys(transformFieldMap);
  327. const course = {
  328. id: this.data.importData.courseId,
  329. name: this.data.importData.courseName,
  330. };
  331. const transformQuestion = (question) => {
  332. question.id = null;
  333. question.course = course;
  334. fields.forEach((field) => {
  335. question[transformFieldMap[field]] = question[field];
  336. delete question[field];
  337. });
  338. if (question.quesOptions && question.quesOptions.length) {
  339. question.quesOptions = question.quesOptions.map((option) => {
  340. option.optionBody = option.body;
  341. delete option.body;
  342. return option;
  343. });
  344. }
  345. return question;
  346. };
  347. const detailInfo = paperData.map((detail) => {
  348. const questionInfo = detail.questions.map((question) => {
  349. transformQuestion(question);
  350. if (question.subQuestions && question.subQuestions.length) {
  351. question.subQuestions = question.subQuestions.map((subq) =>
  352. transformQuestion(subq)
  353. );
  354. question.score = calcSum(
  355. question.subQuestions.map((q) => q.score || 0)
  356. );
  357. }
  358. return question;
  359. });
  360. return {
  361. name: detail.name,
  362. number: detail.number,
  363. questionCount: questionInfo.length,
  364. questionInfo,
  365. questionScore: detail.questionScore,
  366. totalScore: calcSum(questionInfo.map((q) => q.score || 0)),
  367. };
  368. });
  369. // console.log(detailInfo);
  370. return detailInfo;
  371. },
  372. checkImportPaperData(paperData) {
  373. this.$message.closeAll();
  374. // 题目内容校验
  375. const MATCHING_QUESTION = ["PARAGRAPH_MATCHING", "BANKED_CLOZE"];
  376. const SELECT_QUESTION = [
  377. "SINGLE_ANSWER_QUESTION",
  378. "MULTIPLE_ANSWER_QUESTION",
  379. ...MATCHING_QUESTION,
  380. ];
  381. const NESTED_QUESTION = [
  382. ...MATCHING_QUESTION,
  383. "READING_COMPREHENSION",
  384. "CLOZE",
  385. ];
  386. let errInfos = [];
  387. paperData.forEach((detail) => {
  388. detail.questionInfo.forEach((question) => {
  389. const { questionType, quesBody } = question;
  390. const questionTitle = `第${detail.number}大题第${question.number}小题`;
  391. let qErrInfo = [];
  392. // 题干
  393. if (
  394. !MATCHING_QUESTION.includes(questionType) &&
  395. (!quesBody || isAnEmptyRichText(quesBody))
  396. ) {
  397. qErrInfo.push(`没有题干`);
  398. }
  399. // 选项
  400. if (SELECT_QUESTION.includes(questionType)) {
  401. if (!question.quesOptions.length) {
  402. qErrInfo.push(`没有选项`);
  403. }
  404. if (
  405. question.quesOptions.some((option) =>
  406. isAnEmptyRichText(option.optionBody)
  407. )
  408. ) {
  409. qErrInfo.push(`有选择内容为空`);
  410. }
  411. }
  412. // 小题数
  413. if (
  414. NESTED_QUESTION.includes(questionType) &&
  415. !question.subQuestions.length
  416. ) {
  417. qErrInfo.push(`没有小题`);
  418. }
  419. if (qErrInfo.length) {
  420. errInfos.push(`${questionTitle}${qErrInfo.join("、")}`);
  421. qErrInfo = [];
  422. }
  423. // 选词填空、段落匹配,单用模式时校验输入答案是否重复
  424. if (
  425. MATCHING_QUESTION.includes(questionType) &&
  426. question.quesParam.matchingMode === 1
  427. ) {
  428. let selectedAnswer = [],
  429. errorQuestionIndexs = [];
  430. question.subQuestions.forEach((subq, sindex) => {
  431. if (selectedAnswer.includes(subq.quesAnswer)) {
  432. errorQuestionIndexs.push(`${question.number}-${sindex + 1}`);
  433. } else {
  434. if (subq.quesAnswer !== "[]")
  435. selectedAnswer.push(subq.quesAnswer);
  436. }
  437. });
  438. if (errorQuestionIndexs.length) {
  439. errInfos.push(
  440. `第${
  441. detail.number
  442. }大题${errorQuestionIndexs.join()}小题答案重复!`
  443. );
  444. }
  445. }
  446. if (!NESTED_QUESTION.includes(questionType)) return;
  447. // 套题小题校验
  448. question.subQuestions.forEach((subq, sindex) => {
  449. const subqTitle = `第${detail.number}大题第${question.number}-${
  450. sindex + 1
  451. }小题`;
  452. if (
  453. questionType === "READING_COMPREHENSION" &&
  454. (!subq.quesBody || isAnEmptyRichText(subq.quesBody))
  455. ) {
  456. qErrInfo.push(`没有题干`);
  457. }
  458. if (
  459. SELECT_QUESTION.includes(subq.subqType) &&
  460. !MATCHING_QUESTION.includes(questionType)
  461. ) {
  462. if (!subq.quesOptions.length) {
  463. qErrInfo.push(`没有选项`);
  464. }
  465. if (
  466. subq.quesOptions.some((option) =>
  467. isAnEmptyRichText(option.optionBody)
  468. )
  469. ) {
  470. qErrInfo.push(`有选择内容为空`);
  471. }
  472. }
  473. if (qErrInfo.length) {
  474. errInfos.push(`${subqTitle}${qErrInfo.join("、")}`);
  475. qErrInfo = [];
  476. }
  477. });
  478. });
  479. });
  480. if (errInfos.length) {
  481. this.$message({
  482. showClose: true,
  483. message: errInfos.join("。"),
  484. type: "error",
  485. duration: 0,
  486. });
  487. return;
  488. }
  489. if (!this.data.importData.useOriginalPaper) return true;
  490. let detailNumbers = paperData.map((detail) => detail.number);
  491. // 大题号重复性校验
  492. let repeatDetaiNumbers = [];
  493. let detailNums = [];
  494. for (let i = 0; i < detailNumbers.length; i++) {
  495. const num = detailNumbers[i];
  496. if (detailNums.includes(num)) {
  497. if (!repeatDetaiNumbers.includes(num)) repeatDetaiNumbers.push(num);
  498. } else {
  499. detailNums.push(num);
  500. }
  501. }
  502. if (repeatDetaiNumbers.length) {
  503. this.$message({
  504. showClose: true,
  505. message: `大题号${repeatDetaiNumbers.join("、")}重复`,
  506. type: "error",
  507. duration: 0,
  508. });
  509. return;
  510. }
  511. // 大题号连续性校验
  512. for (let i = 0; i < detailNumbers.length; i++) {
  513. if (detailNumbers[i] - 1 !== i) {
  514. this.$message({
  515. showClose: true,
  516. message: "大题号不连续",
  517. type: "error",
  518. duration: 0,
  519. });
  520. return;
  521. }
  522. }
  523. // 答案、分数校验
  524. let totalScore = calcSum(paperData.map((d) => d.totalScore));
  525. let errQuestions = [];
  526. paperData.forEach((detail) => {
  527. detail.questionInfo.forEach((question) => {
  528. if (question.subQuestions && question.subQuestions.length) {
  529. let subIndexs = [];
  530. question.subQuestions.forEach((subq, sind) => {
  531. if (!subq.score)
  532. subIndexs.push(question.number + "-" + (sind + 1));
  533. });
  534. if (subIndexs.length)
  535. errQuestions.push(
  536. `第${detail.number}大题第${subIndexs.join()}小题`
  537. );
  538. } else {
  539. if (!question.score) {
  540. errQuestions.push(
  541. `第${detail.number}大题第${question.number}小题`
  542. );
  543. }
  544. }
  545. });
  546. });
  547. if (errQuestions.length) {
  548. this.$message({
  549. showClose: true,
  550. message: `请设置如下试题的分值:${errQuestions.join("、")}。`,
  551. type: "error",
  552. duration: 0,
  553. });
  554. return;
  555. }
  556. if (
  557. this.data.importData.checkTotalScore &&
  558. totalScore !== this.data.importData.totalScore
  559. ) {
  560. this.$message({
  561. showClose: true,
  562. message: `试卷总分与导入设置的总分不一致!`,
  563. type: "error",
  564. duration: 0,
  565. });
  566. return;
  567. }
  568. return true;
  569. },
  570. async confirm() {
  571. const confirm = await this.$confirm("确认加入题库吗?", "提示", {
  572. type: "warning",
  573. }).catch(() => {});
  574. if (confirm !== "confirm") return;
  575. const detailInfo = this.getImportPaperData();
  576. if (!this.checkImportPaperData(detailInfo)) return;
  577. if (this.loading) return;
  578. this.loading = true;
  579. const res = await questionImportPaperSave({
  580. ...this.data.importData,
  581. detailInfo,
  582. }).catch(() => {});
  583. this.loading = false;
  584. if (!res) return;
  585. this.$message.success("提交成功!");
  586. this.$emit("modified");
  587. this.cancel();
  588. },
  589. // 导入答案属性
  590. toImportAnswer() {
  591. const detailInfo = this.getImportPaperData();
  592. this.uploadAnswerData = {
  593. detailInfo: JSON.stringify(detailInfo),
  594. ...this.data.importData,
  595. };
  596. this.$refs.ImportAnswerDialog.open();
  597. },
  598. async answerTemplateDownload() {
  599. const detailInfo = this.getImportPaperData();
  600. const res = await downloadByApi(() => {
  601. return questionImportDownloadTemplate({
  602. detailInfo,
  603. ...this.data.importData,
  604. });
  605. }).catch((e) => {
  606. this.$message.error(e || "下载失败,请重新尝试!");
  607. });
  608. if (!res) return;
  609. this.$message.success("下载成功!");
  610. },
  611. answerUploaded(res) {
  612. const cacheData = this.getCachePaperInfo(
  613. res.data.detailInfo,
  614. questionInfoField
  615. );
  616. this.paperData = this.assignCachePaperData(
  617. this.paperData,
  618. cacheData,
  619. true
  620. );
  621. this.questionKey = randomCode();
  622. },
  623. },
  624. };
  625. </script>