index.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  1. import { ActionSheet, Button, Image, Popup, Swipe, SwipeItem } from 'vant';
  2. import {
  3. computed,
  4. defineComponent,
  5. nextTick,
  6. onMounted,
  7. onUnmounted,
  8. reactive,
  9. watch,
  10. ref
  11. } from 'vue';
  12. import { useRoute, useRouter } from 'vue-router';
  13. import styles from './index.module.less';
  14. import iconButtonList from '../images/icon-button-list.png';
  15. import MSticky from '@/components/m-sticky';
  16. import ChoiceQuestion from '../model/choice-question';
  17. import AnswerList from '../model/answer-list';
  18. import DragQuestion from '../model/drag-question';
  19. import KeepLookQuestion from '../model/keep-look-question';
  20. import PlayQuestion from '../model/play-question';
  21. import ErrorMode from '../model/error-mode';
  22. import ResultFinish from '../model/result-finish';
  23. import { eventUnit, QuestionType } from '../unit';
  24. import request from '@/helpers/request';
  25. import { useRect } from '@vant/use';
  26. import MHeader from '@/components/m-header';
  27. import { useEventListener, useInterval, useWindowScroll } from '@vueuse/core';
  28. export default defineComponent({
  29. name: 'unit-detail',
  30. setup() {
  31. const route = useRoute();
  32. const router = useRouter();
  33. const swipeRef = ref();
  34. const state = reactive({
  35. background: 'transparent',
  36. color: '#fff',
  37. visiableError: false,
  38. visiableAnswer: false,
  39. id: route.query.id,
  40. currentIndex: 0,
  41. questionList: [] as any,
  42. page: 1,
  43. rows: 10,
  44. total: 0,
  45. isFinish: false, // 是否完成加载
  46. visiableInfo: {
  47. show: false,
  48. operationType: 'RESULT' as 'RESULT' | 'BACK' | 'CONTINUE' | 'GRASP',
  49. type: 'DEFAULT' as 'DEFAULT' | 'FAIL' | 'PASS' | 'GOOD' | 'COUNTDOWN',
  50. content: '',
  51. showCancelButton: false,
  52. confirmButtonText: '',
  53. cancelButtonText: '',
  54. title: '',
  55. graspItem: {} as any
  56. },
  57. nextStatus: false,
  58. swipeHeight: 'auto' as any,
  59. answerAnalysis: '',
  60. questionTypeCode: '',
  61. overResult: {
  62. time: '00:00', // 时长
  63. questionLength: 0, // 答题数
  64. errorLength: 0, // 错题数
  65. rate: 0 // 正确率
  66. }
  67. });
  68. // 计时
  69. const { counter, resume, pause } = useInterval(1000, { controls: true });
  70. const getExamDetails = async () => {
  71. try {
  72. const { data } = await request.post(
  73. '/edu-app/studentUnitExamination/errorEdition',
  74. {
  75. data: {
  76. page: state.page,
  77. rows: state.rows
  78. }
  79. }
  80. );
  81. const temp = data || {};
  82. state.total = temp.total || 0;
  83. state.isFinish = temp.current < temp.pages ? false : true;
  84. temp.records.forEach((item: any) => {
  85. item.showAnalysis = false; // 默认不显示解析
  86. item.grasp = false; // 是否掌握题目
  87. item.analysis = {
  88. message: item.answerAnalysis,
  89. topic: true, // 是否显示结果
  90. userResult: false // 用户答题对错
  91. };
  92. item.userAnswer = []; // 用户答题
  93. });
  94. state.questionList.push(...(temp.records || []));
  95. } catch {
  96. //
  97. }
  98. };
  99. // 监听索引
  100. watch(
  101. () => state.currentIndex,
  102. () => {
  103. console.log(state.currentIndex, 'index');
  104. // 判断是否在倒数第三题,并且没有加载完
  105. if (
  106. state.currentIndex + 3 >= state.questionList.length &&
  107. !state.isFinish
  108. ) {
  109. state.page = state.page + 1;
  110. getExamDetails();
  111. }
  112. }
  113. );
  114. /** 已掌握此题 */
  115. const onGraspQuestion = async (item: any) => {
  116. // 判断是否掌握此题
  117. if (item.grasp) return;
  118. state.visiableInfo.show = true;
  119. state.visiableInfo.title = '确定掌握此题?';
  120. state.visiableInfo.showCancelButton = true;
  121. state.visiableInfo.operationType = 'GRASP';
  122. state.visiableInfo.cancelButtonText = '取消';
  123. state.visiableInfo.confirmButtonText = '确定';
  124. state.visiableInfo.content = `你确定已掌握该题知识要点,此题将移除你的错题集。`;
  125. state.visiableInfo.graspItem = item;
  126. };
  127. /** 已掌握此题确认 */
  128. const onGraspQuestionConfirm = async () => {
  129. try {
  130. state.visiableInfo.show = false;
  131. await request.get('/edu-app/studentExaminationErrorEdition/del', {
  132. hideLoading: false,
  133. params: {
  134. studentExaminationErrorEditionId:
  135. state.visiableInfo.graspItem.studentExaminationErrorEditionId
  136. }
  137. });
  138. state.visiableInfo.graspItem.grasp = true;
  139. // 只有一道题
  140. if (state.questionList.length === 1) {
  141. onAfter();
  142. router.back();
  143. return;
  144. }
  145. // 后面还有题
  146. if (state.questionList.length > state.currentIndex + 1) {
  147. const index = state.questionList.findIndex(
  148. (item: any) =>
  149. item.studentExaminationErrorEditionId ===
  150. state.visiableInfo.graspItem.studentExaminationErrorEditionId
  151. );
  152. state.questionList.splice(index, 1);
  153. state.total -= 1;
  154. resizeSwipeItemHeight();
  155. // swipeRef.value?.next();
  156. return;
  157. }
  158. // 后面没有题
  159. if (state.questionList.length === state.currentIndex + 1) {
  160. swipeRef.value?.prev();
  161. return;
  162. }
  163. } catch {
  164. //
  165. }
  166. };
  167. /**
  168. * @description 下一题 | 测试完成
  169. */
  170. const onNextQuestion = async () => {
  171. try {
  172. const questionList = state.questionList || [];
  173. let result: any = {};
  174. questionList.forEach((question: any, index: number) => {
  175. // 格式化所有题目的答案
  176. if (index === state.currentIndex) {
  177. result = {
  178. questionId: question.id,
  179. details: question.userAnswer || []
  180. };
  181. }
  182. });
  183. const { data } = await request.post(
  184. '/edu-app/studentUnitExamination/submitTrainingAnswer',
  185. {
  186. hideLoading: true,
  187. data: result
  188. }
  189. );
  190. // 初始化是否显示解析
  191. questionList.forEach((question: any, index: number) => {
  192. // 格式化所有题目的答案
  193. if (index === state.currentIndex) {
  194. state.answerAnalysis = question.answerAnalysis;
  195. state.questionTypeCode = question.questionTypeCode;
  196. question.showAnalysis = true;
  197. question.analysis.userResult = data;
  198. }
  199. });
  200. // 判断是否是最后一题
  201. if (state.questionList.length === state.currentIndex + 1) {
  202. eventUnit.emit('unitAudioStop');
  203. state.visiableInfo.show = true;
  204. state.visiableInfo.title = '练习完成';
  205. state.visiableInfo.showCancelButton = true;
  206. state.visiableInfo.operationType = 'CONTINUE';
  207. state.visiableInfo.cancelButtonText = '再等等';
  208. state.visiableInfo.confirmButtonText = '确认完成';
  209. state.visiableInfo.content = `确认本次练习的题目都完成了吗?`;
  210. onAfter();
  211. return;
  212. }
  213. if (data) {
  214. swipeRef.value?.next();
  215. } else {
  216. state.visiableError = true;
  217. }
  218. } catch {
  219. //
  220. }
  221. };
  222. //
  223. const getAnswerResult = computed(() => {
  224. const questionList = state.questionList || [];
  225. let count = 0;
  226. let passCount = 0;
  227. let noPassCount = 0;
  228. questionList.forEach((item: any) => {
  229. if (item.showAnalysis) {
  230. count += 1;
  231. if (item.analysis.userResult) {
  232. passCount += 1;
  233. } else {
  234. noPassCount += 1;
  235. }
  236. }
  237. });
  238. return {
  239. count,
  240. passCount,
  241. noPassCount
  242. };
  243. });
  244. /**
  245. * @description 重置当前的题目高度
  246. * @param {any} scroll 是否滚动到顶部
  247. */
  248. let size = 0;
  249. const resizeSwipeItemHeight = (scroll = true) => {
  250. nextTick(() => {
  251. scroll && window.scrollTo(0, 0);
  252. setTimeout(() => {
  253. const currentItemDom: any = document
  254. .querySelectorAll('.van-swipe-item')
  255. [state.currentIndex]?.querySelector('.swipe-item-question');
  256. const allImg = currentItemDom?.querySelectorAll(
  257. '.answerTitleImg img'
  258. );
  259. let status = true;
  260. // console.log(allImg)
  261. allImg?.forEach((img: any) => {
  262. if (!img.complete) {
  263. status = false;
  264. }
  265. });
  266. // 判断图片是否加载完了
  267. if (!status && size < 3) {
  268. setTimeout(() => {
  269. size += 1;
  270. resizeSwipeItemHeight(scroll);
  271. }, 300);
  272. }
  273. if (status) {
  274. size = 0;
  275. }
  276. const rect = useRect(currentItemDom);
  277. state.swipeHeight = rect.height;
  278. }, 100);
  279. });
  280. };
  281. const onConfirmResult = () => {
  282. if (state.visiableInfo.operationType === 'RESULT') {
  283. state.visiableInfo.show = false;
  284. router.back();
  285. router.back();
  286. } else if (state.visiableInfo.operationType === 'BACK') {
  287. state.visiableInfo.show = false;
  288. onAfter();
  289. } else if (state.visiableInfo.operationType === 'CONTINUE') {
  290. onResultPopup();
  291. } else if (state.visiableInfo.operationType === 'GRASP') {
  292. onGraspQuestionConfirm();
  293. }
  294. };
  295. const onCloseResult = async () => {
  296. const operationType = state.visiableInfo.operationType;
  297. if (operationType === 'RESULT') {
  298. } else if (operationType === 'BACK') {
  299. state.visiableInfo.show = false;
  300. window.history.pushState(null, '', document.URL);
  301. window.addEventListener('popstate', onBack, false);
  302. } else if (operationType === 'CONTINUE' || operationType === 'GRASP') {
  303. state.visiableInfo.show = false;
  304. }
  305. };
  306. /** 结果页面弹窗 */
  307. const onResultPopup = () => {
  308. const answerResult = getAnswerResult.value;
  309. let rate = 0;
  310. if (answerResult.count > 0) {
  311. rate = Math.floor((answerResult.passCount / answerResult.count) * 100);
  312. }
  313. const times = counter.value;
  314. const minute =
  315. Math.floor(times / 60) >= 10
  316. ? Math.floor(times / 60)
  317. : '0' + Math.floor(times / 60);
  318. const seconds = times % 60 >= 10 ? times % 60 : '0' + (times % 60);
  319. state.overResult = {
  320. time: minute + ':' + seconds, // 时长
  321. questionLength: answerResult.count, // 答题数
  322. errorLength: answerResult.noPassCount, // 错题数
  323. rate // 正确率
  324. };
  325. // 重置计时
  326. pause();
  327. counter.value = 0;
  328. // 60 及格
  329. // 85 及以上优秀
  330. state.visiableInfo.show = true;
  331. state.visiableInfo.title = '已完成';
  332. state.visiableInfo.showCancelButton = false;
  333. state.visiableInfo.operationType = 'RESULT';
  334. state.visiableInfo.confirmButtonText = '确认';
  335. state.visiableInfo.content = `<div>您已完成本次测试,答对<span class='${
  336. styles.right
  337. }'>${answerResult.passCount}</span>,答错<span class='${styles.error}'>${
  338. answerResult.count - answerResult.passCount
  339. }</span>,正确率<span class='${styles.primary}'>${rate}%</span>~</div>`;
  340. };
  341. // 拦截
  342. const onBack = () => {
  343. const answerResult = getAnswerResult.value;
  344. state.visiableInfo.show = true;
  345. state.visiableInfo.title = '确认退出吗?';
  346. state.visiableInfo.showCancelButton = true;
  347. state.visiableInfo.operationType = 'BACK';
  348. state.visiableInfo.cancelButtonText = '退出';
  349. state.visiableInfo.confirmButtonText = '继续';
  350. state.visiableInfo.content = `您已经完成${
  351. answerResult.passCount + answerResult.noPassCount
  352. }道题了,继续做题可以巩固所学知识哦~`;
  353. eventUnit.emit('unitAudioStop');
  354. };
  355. const onAfter = () => {
  356. window.removeEventListener('popstate', onBack, false);
  357. router.back();
  358. };
  359. onMounted(async () => {
  360. useEventListener(document, 'scroll', () => {
  361. const { y } = useWindowScroll();
  362. if (y.value > 52) {
  363. state.background = '#fff';
  364. state.color = '#323333';
  365. } else {
  366. state.background = 'transparent';
  367. state.color = '#fff';
  368. }
  369. });
  370. await getExamDetails();
  371. resizeSwipeItemHeight();
  372. window.history.pushState(null, '', document.URL);
  373. window.addEventListener('popstate', onBack, false);
  374. });
  375. onUnmounted(() => {
  376. // 关闭所有音频
  377. eventUnit.emit('unitAudioStop');
  378. });
  379. return () => (
  380. <div class={styles.unitDetail}>
  381. <MSticky position="top">
  382. <MHeader
  383. border={false}
  384. background={state.background}
  385. color={state.color}
  386. />
  387. </MSticky>
  388. <Swipe
  389. loop={false}
  390. showIndicators={false}
  391. ref={swipeRef}
  392. duration={300}
  393. touchable={false}
  394. class={styles.unitSwipe}
  395. style={{ paddingBottom: '12px' }}
  396. lazyRender
  397. height={state.swipeHeight}
  398. onChange={(index: number) => {
  399. eventUnit.emit('unitAudioStop');
  400. state.currentIndex = index;
  401. resizeSwipeItemHeight();
  402. }}>
  403. {state.questionList.map((item: any, index: number) => (
  404. <SwipeItem>
  405. <div class="swipe-item-question">
  406. {item.questionTypeCode === QuestionType.RADIO && (
  407. <ChoiceQuestion
  408. v-model:value={item.userAnswer}
  409. index={index + 1}
  410. data={item}
  411. type="radio"
  412. showAnalysis={item.showAnalysis}
  413. analysis={item.analysis}>
  414. {{
  415. title: () => (
  416. <div class={styles.questionTitle}>
  417. <div class={styles.questionNum}>
  418. <p class={styles.pointName}>
  419. {item.knowledgePointName}
  420. </p>
  421. <span>{state.currentIndex + 1}</span>/{state.total}
  422. </div>
  423. <Button
  424. round
  425. plain
  426. size="mini"
  427. color={item.grasp ? '#FF5A56' : '#1CACF1'}
  428. class={styles.controlBtn}
  429. disabled={item.grasp}
  430. onClick={() => onGraspQuestion(item)}>
  431. {item.grasp ? '已掌握此题' : '掌握此题'}
  432. </Button>
  433. </div>
  434. )
  435. }}
  436. </ChoiceQuestion>
  437. )}
  438. {item.questionTypeCode === QuestionType.CHECKBOX && (
  439. <ChoiceQuestion
  440. v-model:value={item.userAnswer}
  441. index={index + 1}
  442. data={item}
  443. type="checkbox"
  444. showAnalysis={item.showAnalysis}
  445. analysis={item.analysis}>
  446. {{
  447. title: () => (
  448. <div class={styles.questionTitle}>
  449. <div class={styles.questionNum}>
  450. <p class={styles.pointName}>
  451. {item.knowledgePointName}
  452. </p>
  453. <span>{state.currentIndex + 1}</span>/{state.total}
  454. </div>
  455. <Button
  456. round
  457. plain
  458. size="mini"
  459. color={item.grasp ? '#FF5A56' : '#1CACF1'}
  460. class={styles.controlBtn}
  461. disabled={item.grasp}
  462. onClick={() => onGraspQuestion(item)}>
  463. {item.grasp ? '已掌握此题' : '掌握此题'}
  464. </Button>
  465. </div>
  466. )
  467. }}
  468. </ChoiceQuestion>
  469. )}
  470. {item.questionTypeCode === QuestionType.SORT && (
  471. <DragQuestion
  472. v-model:value={item.userAnswer}
  473. onUpdate:value={() => {
  474. // 如果是空则滑动到顶部
  475. const status =
  476. item.userAnswer && item.userAnswer.length > 0
  477. ? false
  478. : true;
  479. resizeSwipeItemHeight(status);
  480. }}
  481. data={item}
  482. index={index + 1}
  483. showAnalysis={item.showAnalysis}
  484. analysis={item.analysis}>
  485. {{
  486. title: () => (
  487. <div class={styles.questionTitle}>
  488. <div class={styles.questionNum}>
  489. <p class={styles.pointName}>
  490. {item.knowledgePointName}
  491. </p>
  492. <span>{state.currentIndex + 1}</span>/{state.total}
  493. </div>
  494. <Button
  495. round
  496. plain
  497. size="mini"
  498. color={item.grasp ? '#FF5A56' : '#1CACF1'}
  499. class={styles.controlBtn}
  500. disabled={item.grasp}
  501. onClick={() => onGraspQuestion(item)}>
  502. {item.grasp ? '已掌握此题' : '掌握此题'}
  503. </Button>
  504. </div>
  505. )
  506. }}
  507. </DragQuestion>
  508. )}
  509. {item.questionTypeCode === QuestionType.LINK && (
  510. <KeepLookQuestion
  511. v-model:value={item.userAnswer}
  512. data={item}
  513. index={index + 1}
  514. showAnalysis={item.showAnalysis}
  515. analysis={item.analysis}>
  516. {{
  517. title: () => (
  518. <div class={styles.questionTitle}>
  519. <div class={styles.questionNum}>
  520. <p class={styles.pointName}>
  521. {item.knowledgePointName}
  522. </p>
  523. <span>{state.currentIndex + 1}</span>/{state.total}
  524. </div>
  525. <Button
  526. round
  527. plain
  528. size="mini"
  529. color={item.grasp ? '#FF5A56' : '#1CACF1'}
  530. class={styles.controlBtn}
  531. disabled={item.grasp}
  532. onClick={() => onGraspQuestion(item)}>
  533. {item.grasp ? '已掌握此题' : '掌握此题'}
  534. </Button>
  535. </div>
  536. )
  537. }}
  538. </KeepLookQuestion>
  539. )}
  540. {item.questionTypeCode === QuestionType.PLAY && (
  541. <PlayQuestion
  542. v-model:value={item.userAnswer}
  543. data={item}
  544. index={index + 1}
  545. unitId={state.id as any}
  546. showAnalysis={item.showAnalysis}
  547. analysis={item.analysis}>
  548. {{
  549. title: () => (
  550. <div class={styles.questionTitle}>
  551. <div class={styles.questionNum}>
  552. <span>{state.currentIndex + 1}</span>/{state.total}
  553. </div>
  554. {/* <div class={styles.questionType}>
  555. <i></i>
  556. <span>{item.knowledgePointName}</span>
  557. </div> */}
  558. <Button
  559. round
  560. plain
  561. size="mini"
  562. color={item.grasp ? '#FF5A56' : '#1CACF1'}
  563. disabled={item.grasp}
  564. class={styles.controlBtn}
  565. onClick={() => onGraspQuestion(item)}>
  566. {item.grasp ? '已掌握此题' : '掌握此题'}
  567. </Button>
  568. </div>
  569. )
  570. }}
  571. </PlayQuestion>
  572. )}
  573. </div>
  574. </SwipeItem>
  575. ))}
  576. </Swipe>
  577. <MSticky position="bottom">
  578. <div class={['btnGroup btnMore', styles.btnSection]}>
  579. <Button
  580. round
  581. block
  582. class={
  583. state.currentIndex > 0 ? styles.activePrevBtn : styles.prevBtn
  584. }
  585. disabled={state.currentIndex > 0 ? false : true}
  586. onClick={() => {
  587. swipeRef.value?.prev();
  588. }}>
  589. 上一题
  590. </Button>
  591. <Button
  592. block
  593. round
  594. class={styles.nextBtn}
  595. onClick={onNextQuestion}
  596. loading={state.nextStatus}
  597. disabled={state.nextStatus}>
  598. {state.questionList.length === state.currentIndex + 1
  599. ? '提交'
  600. : '下一题'}
  601. </Button>
  602. <Image
  603. src={iconButtonList}
  604. class={[styles.wapList, 'van-haptics-feedback']}
  605. onClick={() => (state.visiableAnswer = true)}
  606. />
  607. </div>
  608. </MSticky>
  609. {/* 题目集合 */}
  610. <ActionSheet
  611. v-model:show={state.visiableAnswer}
  612. title="题目列表"
  613. safeAreaInsetBottom>
  614. <AnswerList
  615. value={state.questionList}
  616. lookType={'PRACTICE'}
  617. statusList={[
  618. {
  619. text: '答对',
  620. color: '#1CACF1'
  621. },
  622. {
  623. text: '答错',
  624. color: '#FF8486'
  625. },
  626. {
  627. text: '未答',
  628. color: '#EAEAEA'
  629. }
  630. ]}
  631. onSelect={(item: any) => {
  632. // 跳转,并且跳过动画
  633. swipeRef.value?.swipeTo(item, {
  634. immediate: true
  635. });
  636. state.visiableAnswer = false;
  637. }}
  638. />
  639. </ActionSheet>
  640. <Popup
  641. v-model:show={state.visiableError}
  642. style={{ width: '90%' }}
  643. round
  644. closeOnClickOverlay={false}>
  645. <ErrorMode
  646. onClose={() => (state.visiableError = false)}
  647. answerAnalysis={state.answerAnalysis}
  648. questionTypeCode={state.questionTypeCode}
  649. onConform={() => {
  650. swipeRef.value?.next();
  651. state.answerAnalysis = '';
  652. }}
  653. />
  654. </Popup>
  655. <Popup
  656. v-model:show={state.visiableInfo.show}
  657. closeOnClickOverlay={false}
  658. style={{
  659. background: 'transparent',
  660. width: '100%',
  661. maxWidth: '100%',
  662. transform: 'translateY(-55%)'
  663. }}>
  664. <ResultFinish
  665. title={state.visiableInfo.title}
  666. showCancelButton={state.visiableInfo.showCancelButton}
  667. cancelButtonText={state.visiableInfo.cancelButtonText}
  668. confirmButtonText={state.visiableInfo.confirmButtonText}
  669. status={state.visiableInfo.type}
  670. content={state.visiableInfo.content}
  671. contentHtml
  672. onConform={onConfirmResult}
  673. onClose={onCloseResult}
  674. />
  675. </Popup>
  676. </div>
  677. );
  678. }
  679. });