MatchingPreview.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. <!-- eslint-disable vue/no-v-html -->
  2. <template>
  3. <div class="matching-preview" :style="[getAreaStyle(), getComponentStyle()]">
  4. <SerialNumberPosition v-if="isEnable(data.property.sn_display_mode)" :property="data.property" />
  5. <div class="main">
  6. <ul ref="list" class="option-list">
  7. <li v-for="(item, i) in data.option_list" :key="i" class="list-item">
  8. <div
  9. v-for="({ content, mark, multilingual, paragraph_list }, j) in item"
  10. :key="mark"
  11. :class="['item-wrapper', `item-${mark}`, computedAnswerClass(mark, i, j), { isMobile: isMobile }]"
  12. :style="{
  13. cursor: disabled ? 'default' : 'pointer',
  14. flex: isMobile ? '0 1 auto' : '',
  15. width: isMobile ? 'calc(50% - 8px)' : '',
  16. overflow: isMobile ? 'auto' : '',
  17. }"
  18. @mousedown="mousedown($event, i, j, mark)"
  19. @mouseup="mouseup($event, i, j, mark)"
  20. @click="handleClickConnection($event, i, j, mark)"
  21. >
  22. <PinyinText
  23. v-if="isEnable(data.property.view_pinyin)"
  24. class="content"
  25. :paragraph-list="paragraph_list"
  26. :pinyin-position="data.property.pinyin_position"
  27. :is-preview="true"
  28. />
  29. <span v-else class="content rich-text" v-html="convertText(sanitizeHTML(content))"></span>
  30. <div v-if="showLang" class="lang">
  31. {{ multilingual.find((item) => item.type === getLang())?.translation }}
  32. </div>
  33. </div>
  34. </li>
  35. </ul>
  36. </div>
  37. <PreviewOperation @showAnswerAnalysis="showAnswerAnalysis" />
  38. <AnswerCorrect
  39. :answer-correct="data?.answer_correct"
  40. :visible.sync="visibleAnswerCorrect"
  41. @closeAnswerCorrect="closeAnswerCorrect"
  42. />
  43. <AnswerAnalysis
  44. :visible.sync="visibleAnswerAnalysis"
  45. :answer-list="data.answer_list"
  46. :analysis-list="data.analysis_list"
  47. @closeAnswerAnalysis="closeAnswerAnalysis"
  48. >
  49. <div slot="right-answer" class="right-answer">
  50. <div class="title">正确答案</div>
  51. <ul ref="answerList" class="option-list">
  52. <li v-for="(item, i) in data.option_list" :key="i" class="list-item">
  53. <div v-for="{ content, mark } in item" :key="mark" :class="['item-wrapper', `answer-item-${mark}`]">
  54. <span class="content rich-text" v-html="convertText(sanitizeHTML(content))"></span>
  55. </div>
  56. </li>
  57. </ul>
  58. </div>
  59. </AnswerAnalysis>
  60. </div>
  61. </template>
  62. <script>
  63. import { getMatchingData, svgNS } from '@/views/book/courseware/data/matching';
  64. import { arraysIntersect } from '@/utils/validate';
  65. import PreviewMixin from '../common/PreviewMixin';
  66. export default {
  67. name: 'MatchingPreview',
  68. mixins: [PreviewMixin],
  69. data() {
  70. return {
  71. data: getMatchingData(),
  72. answerList: [], // 答案列表
  73. curConnectionPoint: { i: -1, j: -1, mark: '' }, // 当前连线点
  74. // 拖拽相关
  75. drag: false,
  76. mouseEvent: {
  77. clientX: 0,
  78. clientY: 0,
  79. },
  80. };
  81. },
  82. watch: {
  83. 'data.option_list': {
  84. handler(val) {
  85. this.clearLine();
  86. if (!val) return;
  87. let list = val.map((item) => {
  88. return item.map(({ mark }) => {
  89. return { mark, preMark: [], nextMark: [] };
  90. });
  91. });
  92. this.$set(this, 'answerList', list);
  93. },
  94. immediate: true,
  95. },
  96. answerList: {
  97. handler(list) {
  98. let arr = [];
  99. list.forEach((item) => {
  100. let markArr = [];
  101. item.forEach(({ mark, nextMark }, j) => {
  102. if (j === 0) {
  103. markArr.push(mark);
  104. markArr.push([...nextMark]);
  105. } else if (item.length === 3 && j === 1) {
  106. markArr.push([...nextMark]);
  107. }
  108. });
  109. arr.push(markArr);
  110. });
  111. // 答案数组为 第一个值为固定字符串,后面为连接的值数组,如:['A', ['B', 'C'], ['1', '2']],根据此结构来判断对错、绘制连线
  112. this.answer.answer_list = arr;
  113. },
  114. deep: true,
  115. },
  116. isShowRightAnswer(cur) {
  117. if (cur) {
  118. this.$nextTick(() => {
  119. this.circulateAnswerList();
  120. });
  121. }
  122. },
  123. },
  124. created() {
  125. document.addEventListener('click', this.handleEventConnection);
  126. document.addEventListener('mousemove', this.documentMousemouse);
  127. document.addEventListener('mouseup', this.documentMouseup);
  128. },
  129. beforeDestroy() {
  130. document.removeEventListener('click', this.handleEventConnection);
  131. document.removeEventListener('mousemove', this.documentMousemouse);
  132. document.removeEventListener('mouseup', this.documentMouseup);
  133. },
  134. methods: {
  135. /* 用 mouse 事件模拟拖拽 开始*/
  136. documentMousemouse(e) {
  137. if (!this.drag) return;
  138. // 使用 svg 绘制跟随鼠标移动的连接线
  139. let svg = document.querySelector('.move-connection');
  140. let list = this.$refs.list.getBoundingClientRect(); // 列表的位置
  141. let { clientX, clientY } = e;
  142. let isLeft = clientX < this.mouseEvent.clientX; // 鼠标是否向左移动
  143. // 计算 svg 的宽度,宽度不能超过列表的宽度
  144. let width = Math.min(list.width, isLeft ? list.width - clientX + list.left - 1 : clientX - list.left - 1);
  145. if (svg) {
  146. svg.setAttribute(
  147. 'style',
  148. `position: absolute; ${isLeft ? 'right: 0;' : 'left: 0;'} width: ${width}px; height: 100%;`,
  149. );
  150. } else {
  151. svg = document.createElementNS(svgNS, 'svg');
  152. svg.classList.add('move-connection');
  153. svg.setAttribute('style', `position: absolute; width: ${width}px; height: 100%;`);
  154. let path = document.createElementNS(svgNS, 'path');
  155. this.setPathAttr(path);
  156. svg.appendChild(path);
  157. this.$refs.list.appendChild(svg);
  158. }
  159. let top = this.mouseEvent.clientY - list.top;
  160. let left = isLeft
  161. ? this.mouseEvent.clientX - list.left - Math.abs(width - list.width)
  162. : this.mouseEvent.clientX - list.left;
  163. let mouseX = isLeft ? clientX - list.left - Math.abs(width - list.width) : clientX - list.left;
  164. let mouseY = clientY - list.top;
  165. let path = svg.querySelector('path');
  166. path.setAttribute('d', `M ${left} ${top} L ${mouseX} ${mouseY}`);
  167. },
  168. documentMouseup() {
  169. if (!this.drag) return;
  170. this.drag = false;
  171. document.querySelector('.move-connection')?.remove();
  172. document.body.style.userSelect = 'auto'; // 允许选中文本
  173. this.mousePointer = { i: -1, j: -1, mark: '' };
  174. this.mouseEvent = { clientX: 0, clientY: 0 };
  175. },
  176. /**
  177. * 鼠标按下事件,设置当前连线点
  178. * @param {PointerEvent} e 事件对象
  179. * @param {number} i 选项列表索引
  180. * @param {number} j 选项索引
  181. * @param {string} mark 选项标识
  182. */
  183. mousedown(e, i, j, mark) {
  184. if (this.disabled) return;
  185. this.drag = true;
  186. document.body.style.userSelect = 'none'; // 禁止选中文本
  187. this.mouseEvent = { clientX: e.clientX, clientY: e.clientY };
  188. this.mousePointer = { i, j, mark };
  189. },
  190. /**
  191. * 鼠标抬起事件,如果是一个合适的连接点,则创建连接线
  192. * @param {PointerEvent} e 事件对象
  193. * @param {Number} i 选项列表索引
  194. * @param {Number} j 选项索引
  195. * @param {String} mark 选项标识
  196. */
  197. mouseup(e, i, j, mark) {
  198. if (this.disabled || !this.drag) return;
  199. let { i: curI, j: curJ, mark: curMark } = this.mousePointer;
  200. if (curI === -1 && curJ === -1) return;
  201. if (Math.abs(curJ - j) > 1 || mark === curMark) return;
  202. this.changeConnectionList(mark, j, true);
  203. this.createLine(mark, true);
  204. },
  205. /* 用 mouse 事件模拟拖拽 结束 */
  206. // 重置当前连线点
  207. resetCurConnectionPoint() {
  208. this.curConnectionPoint = { i: -1, j: -1, mark: '' };
  209. },
  210. /**
  211. * 当点击的不是连线点时,清除所有连线点的选中状态
  212. * @param {PointerEvent} e
  213. */
  214. handleEventConnection(e) {
  215. let currentNode = e.target;
  216. while (currentNode !== null) {
  217. if (currentNode.classList && currentNode.classList.contains('item-wrapper')) {
  218. break;
  219. }
  220. currentNode = currentNode.parentNode;
  221. }
  222. if (currentNode) return;
  223. Array.from(document.getElementsByClassName('item-wrapper')).forEach((item) => {
  224. item.classList.remove('focus');
  225. });
  226. this.resetCurConnectionPoint();
  227. },
  228. /**
  229. * 处理点击连线
  230. * @param {PointerEvent} e 事件对象
  231. * @param {Number} i 选项列表索引
  232. * @param {Number} j 选项索引
  233. * @param {String} mark 选项标识
  234. */
  235. handleClickConnection(e, i, j, mark) {
  236. if (this.disabled) return;
  237. let { i: curI, j: curJ, mark: curMark } = this.curConnectionPoint;
  238. // 获取 item-wrapper 元素
  239. let currentNode = e.target;
  240. while (currentNode !== null) {
  241. if (currentNode.classList && currentNode.classList.contains('item-wrapper')) {
  242. break;
  243. }
  244. currentNode = currentNode.parentNode;
  245. }
  246. // 如果当前连线点不存在或就是当前连线点,则设置当前连线点
  247. if ((curI === -1 && curJ === -1) || mark === curMark) {
  248. this.curConnectionPoint = { i, j, mark };
  249. currentNode.classList.add('focus');
  250. return;
  251. }
  252. // 如果当前连线点存在,清除所有连线点的选中状态
  253. Array.from(this.$refs.list.getElementsByClassName('item-wrapper')).forEach((item) => {
  254. item.classList.remove('focus');
  255. });
  256. // 如果当前连线点与上一个连线点不在相邻的位置,则设置点击的连接点为当前连线点
  257. if (Math.abs(curJ - j) > 1 || mark === curMark || (curJ === j && curI !== i)) {
  258. this.curConnectionPoint = { i, j, mark };
  259. currentNode.classList.add('focus');
  260. return;
  261. }
  262. this.changeConnectionList(mark, j);
  263. // 如果当前连线点与上一个连线点在相邻的位置,则创建连接线
  264. this.createLine(mark);
  265. },
  266. /**
  267. * @description 循环答案列表
  268. */
  269. circulateAnswerList() {
  270. this.data.answer.answer_list.forEach((item, index) => {
  271. let preMark = ''; // 上一个连线点标识
  272. item.forEach(({ mark }, j) => {
  273. if (j === 0) {
  274. preMark = mark;
  275. return;
  276. }
  277. if (mark.length <= 0) return;
  278. if (j === 2) {
  279. preMark = this.data.option_list[index][j - 1].mark;
  280. }
  281. let cur = { i: -1, j: -1, mark: '' }; // 当前连线点
  282. // 根据 preMark 查找当前连线点的位置
  283. this.data.option_list.find((list, i) => {
  284. return list.find((li, idx) => {
  285. if (li.mark === preMark) {
  286. cur = { i, j: idx };
  287. return true;
  288. }
  289. });
  290. });
  291. // 循环 mark 数组,创建连接线
  292. mark.forEach((m) => {
  293. this.curConnectionPoint = { i: cur.i, j: cur.j, mark: preMark };
  294. this.createLine(m, false, true);
  295. });
  296. });
  297. });
  298. },
  299. /**
  300. * 创建连接线
  301. * @param {String} mark 选项标识
  302. * @param {Boolean} isDrag 是否是拖拽
  303. * @param {Boolean} isShowRightAnswer 是否是显示正确答案
  304. */
  305. createLine(mark, isDrag = false, isShowRightAnswer = false) {
  306. const { offsetWidth, offsetLeft, offsetTop, offsetHeight } = document.getElementsByClassName(
  307. `${isShowRightAnswer ? 'answer-' : ''}item-${mark}`,
  308. )[0];
  309. const { curOffsetWidth, curOffsetLeft, curOffsetTop, curOffsetHeight, curMark } = this.computedCurConnectionPoint(
  310. isDrag,
  311. isShowRightAnswer,
  312. );
  313. let top = Math.min(offsetTop + offsetHeight / 2, curOffsetTop + curOffsetHeight / 2);
  314. // 判断是否是同一行
  315. const isSameRow = Math.abs(offsetTop + offsetHeight / 2 - (curOffsetTop + curOffsetHeight / 2)) <= 2;
  316. let left = Math.min(offsetLeft + offsetWidth, curOffsetLeft + curOffsetWidth);
  317. let width = Math.abs(
  318. offsetLeft > curOffsetLeft
  319. ? curOffsetLeft - offsetLeft + offsetWidth
  320. : offsetLeft - curOffsetLeft + curOffsetWidth,
  321. );
  322. let height = 0;
  323. if (!isSameRow) {
  324. height =
  325. curOffsetTop > offsetTop
  326. ? Math.abs(offsetTop + offsetHeight / 2 - (curOffsetTop + curOffsetHeight / 2))
  327. : Math.abs(offsetTop - curOffsetTop + offsetHeight / 2 - curOffsetHeight / 2);
  328. }
  329. let size = offsetLeft > curOffsetLeft ? offsetTop > curOffsetTop : offsetTop < curOffsetTop; // 判断是左上还是右下
  330. // 创建一个空的SVG元素
  331. let svg = document.createElementNS(svgNS, 'svg');
  332. svg.setAttribute(
  333. 'style',
  334. `position:absolute; width: 74px; height: ${Math.max(8, height)}px; top: ${top}px; left: ${left}px;overflow: visible;`,
  335. );
  336. svg.classList.add('connection-line', `svg-${mark}-${curMark}`); // 添加类名
  337. // 向SVG元素添加 path 元素
  338. let path = document.createElementNS(svgNS, 'path');
  339. path.setAttribute('d', `M ${size ? 0 : width} 0 L ${size ? width : 0} ${height}`); // 设置路径数据
  340. this.setPathAttr(path);
  341. svg.appendChild(path);
  342. // 在 svg 元素中的 path 元素上两端添加圆形标记
  343. let circleStart = document.createElementNS(svgNS, 'circle');
  344. circleStart.setAttribute('cx', size ? '0' : width); // 设置圆心的 x 坐标
  345. circleStart.setAttribute('cy', '0'); // 设置圆心的 y 坐标
  346. circleStart.setAttribute('r', '4'); // 设置半径
  347. const assistColor = this.data?.unified_attrib?.topic_color || '#306eff';
  348. circleStart.setAttribute('fill', assistColor); // 设置填充颜色
  349. svg.appendChild(circleStart);
  350. let circleEnd = document.createElementNS(svgNS, 'circle');
  351. circleEnd.setAttribute('cx', size ? width : '0'); // 设置圆心的 x 坐标
  352. circleEnd.setAttribute('cy', height); // 设置圆心的 y 坐标
  353. circleEnd.setAttribute('r', '4'); // 设置半径
  354. circleEnd.setAttribute('fill', assistColor); // 设置填充颜色
  355. svg.appendChild(circleEnd);
  356. this.$refs[isShowRightAnswer ? 'answerList' : 'list'].appendChild(svg); // 将SVG元素插入到文档中
  357. // 清除当前连线点
  358. this.resetCurConnectionPoint();
  359. },
  360. // 设置 path 公用属性
  361. setPathAttr(path) {
  362. path.setAttribute('stroke-width', '2'); // 设置线条宽度
  363. path.setAttribute('stroke-linecap', 'round'); // 设置线段的两端样式
  364. const assistColor = this.data?.unified_attrib?.topic_color || '#306eff';
  365. path.setAttribute('stroke', assistColor); // 设置填充颜色
  366. },
  367. /**
  368. * 计算当前连线点的位置
  369. * @param {Boolean} isDrag 是否是拖拽
  370. * @param {Boolean} isShowRightAnswer 是否是显示正确答案
  371. */
  372. computedCurConnectionPoint(isDrag = false, isShowRightAnswer = false) {
  373. let { mark } = isDrag ? this.mousePointer : this.curConnectionPoint;
  374. let type = Object.prototype.toString.call(mark);
  375. if (type === '[object String]') {
  376. type = 'string';
  377. } else if (type === '[object Object]') {
  378. type = 'object';
  379. }
  380. if (type === 'object') {
  381. mark = mark.mark;
  382. }
  383. const dom = document.getElementsByClassName(`${isShowRightAnswer ? 'answer-' : ''}item-${mark}`)[0];
  384. return {
  385. curOffsetWidth: dom.offsetWidth,
  386. curOffsetLeft: dom.offsetLeft,
  387. curOffsetTop: dom.offsetTop,
  388. curOffsetHeight: dom.offsetHeight,
  389. curMark: mark,
  390. };
  391. },
  392. // 清除所有连接线
  393. clearLine() {
  394. document.querySelectorAll('svg.connection-line').forEach((item) => {
  395. item.remove();
  396. });
  397. },
  398. /**
  399. * 修改连接列表
  400. * @param {String} mark 选项标识
  401. * @param {Number} j 当前选项索引
  402. * @param {Boolean} isDrag 是否是拖拽
  403. */
  404. changeConnectionList(mark, j, isDrag = false) {
  405. const { mark: curMark, j: curJ } = isDrag ? this.mousePointer : this.curConnectionPoint;
  406. this.changeAnswerList(curMark, mark, curJ < j);
  407. this.changeAnswerList(mark, curMark, curJ > j);
  408. },
  409. /**
  410. * 改变答案列表
  411. * @param {String} curMark 当前选项标识
  412. * @param {String} mark 选项标识
  413. */
  414. changeAnswerList(curMark, mark, isPre) {
  415. this.answerList.find((item) =>
  416. item.find((li) => {
  417. if (li.mark === curMark) {
  418. if (isPre && !li.preMark.includes(mark)) {
  419. li.nextMark.push(mark);
  420. } else if (!isPre && !li.nextMark.includes(mark)) {
  421. li.preMark.push(mark);
  422. }
  423. return true;
  424. }
  425. }),
  426. );
  427. },
  428. /**
  429. * 根据 mark 查找 nextMark 或 preMark
  430. * @param {Array} list 答案列表
  431. * @param {String} mark 标记
  432. * @param {'pre'|'next'} type 类型
  433. * @returns {String} 返回 nextMark 或 preMark
  434. */
  435. findMark(list, mark, type) {
  436. let fMark = '';
  437. list.find((item) => {
  438. return item.find((li) => {
  439. if (mark === li.mark) {
  440. fMark = type === 'pre' ? li.preMark : li.nextMark;
  441. return true;
  442. }
  443. });
  444. });
  445. return fMark;
  446. },
  447. /**
  448. * 计算答题对错选项class
  449. * @param {string} mark 选项标识
  450. * @param {number} i 选项列表索引
  451. * @param {number} j 选项索引
  452. * @returns {string} 返回 class 名称
  453. */
  454. computedAnswerClass(mark, i, j) {
  455. if (!this.isJudgingRightWrong) return '';
  456. if (j === 0) {
  457. return this.judgeFirstAnswerRightWrong(i);
  458. }
  459. let answer = this.data.answer.answer_list.find((item) => {
  460. return item.some((li) => Array.isArray(li.mark) && li.mark.includes(mark));
  461. }); // 正确答案列
  462. let userAnswer = this.answer.answer_list.find((item) => {
  463. return item.some((li) => Array.isArray(li) && li.includes(mark));
  464. }); // 选项对应的用户答案列
  465. if (answer === undefined || userAnswer === undefined) return 'wrong';
  466. if (answer.length === 0 && userAnswer.length === 0) return '';
  467. if (answer.length === 0 || userAnswer.length === 0) return 'wrong';
  468. let isRight = true;
  469. if (j === 1) {
  470. // 判断第二行答案对错,answer[0].mark 和 userAnswer[0] 是否相等
  471. isRight = answer[0].mark === userAnswer[0];
  472. } else if (j === 2) {
  473. // 判断第三行答案对错,answer[1].mark 和 userAnswer[1] 是否相交
  474. isRight = arraysIntersect(answer[1].mark, userAnswer[1]);
  475. }
  476. return isRight ? 'right' : 'wrong';
  477. },
  478. /**
  479. * 判断第一列答案对错,是否有个一个选项连接正确
  480. * @param {number} i 选项列表索引
  481. */
  482. judgeFirstAnswerRightWrong(i) {
  483. let answer = this.data.answer.answer_list[i]; // 正确答案列
  484. let userAnswer = this.answer.answer_list[i]; // 选项对应的用户答案列
  485. if (answer === undefined || userAnswer === undefined) return 'wrong';
  486. if (answer.length === 0 && userAnswer.length === 0) return '';
  487. if (answer.length === 0 || userAnswer.length === 0) return 'wrong';
  488. let isRight = true;
  489. // 判断 answer[1].mark 和 userAnswer[1] 是否相交
  490. const answerSet = new Set(answer[1].mark);
  491. isRight = userAnswer[1].some((x) => answerSet.has(x));
  492. return isRight ? 'right' : 'wrong';
  493. },
  494. },
  495. };
  496. </script>
  497. <style lang="scss" scoped>
  498. @use '@/styles/mixin.scss' as *;
  499. .matching-preview {
  500. @include preview-base;
  501. overflow: auto;
  502. .option-list {
  503. position: relative;
  504. display: flex;
  505. flex-direction: column;
  506. row-gap: 16px;
  507. .list-item {
  508. display: flex;
  509. column-gap: 8px;
  510. align-items: stretch;
  511. padding: 1px;
  512. .item-wrapper {
  513. position: relative;
  514. display: flex;
  515. flex: 1;
  516. flex-wrap: wrap;
  517. column-gap: 24px;
  518. align-items: center;
  519. min-height: 48px;
  520. padding: 12px 24px;
  521. background-color: $content-color;
  522. border-radius: 4px;
  523. &:not(:last-child, .isMobile) {
  524. margin-right: 64px;
  525. }
  526. &.focus {
  527. background-color: #dcdbdd;
  528. }
  529. &.right {
  530. background-color: $right-bc-color;
  531. }
  532. &.wrong {
  533. box-shadow: 0 0 0 1px $error-color;
  534. }
  535. .content {
  536. flex: 1;
  537. }
  538. .lang {
  539. width: 100%;
  540. }
  541. }
  542. }
  543. }
  544. .right-answer {
  545. .title {
  546. margin: 24px 0;
  547. }
  548. }
  549. }
  550. </style>