index.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  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. return;
  211. }
  212. if (data) {
  213. swipeRef.value?.next();
  214. } else {
  215. state.visiableError = true;
  216. }
  217. } catch {
  218. //
  219. }
  220. };
  221. //
  222. const getAnswerResult = computed(() => {
  223. const questionList = state.questionList || [];
  224. let count = 0;
  225. let passCount = 0;
  226. let noPassCount = 0;
  227. questionList.forEach((item: any) => {
  228. if (item.showAnalysis) {
  229. count += 1;
  230. if (item.analysis.userResult) {
  231. passCount += 1;
  232. } else {
  233. noPassCount += 1;
  234. }
  235. }
  236. });
  237. return {
  238. count,
  239. passCount,
  240. noPassCount
  241. };
  242. });
  243. /**
  244. * @description 重置当前的题目高度
  245. * @param {any} scroll 是否滚动到顶部
  246. */
  247. let size = 0;
  248. const resizeSwipeItemHeight = (scroll = true) => {
  249. nextTick(() => {
  250. scroll && window.scrollTo(0, 0);
  251. setTimeout(() => {
  252. const currentItemDom: any = document
  253. .querySelectorAll('.van-swipe-item')
  254. [state.currentIndex]?.querySelector('.swipe-item-question');
  255. const allImg = currentItemDom?.querySelectorAll(
  256. '.answerTitleImg img'
  257. );
  258. let status = true;
  259. // console.log(allImg)
  260. allImg?.forEach((img: any) => {
  261. if (!img.complete) {
  262. status = false;
  263. }
  264. });
  265. // 判断图片是否加载完了
  266. if (!status && size < 3) {
  267. setTimeout(() => {
  268. size += 1;
  269. resizeSwipeItemHeight(scroll);
  270. }, 300);
  271. }
  272. if (status) {
  273. size = 0;
  274. }
  275. const rect = useRect(currentItemDom);
  276. state.swipeHeight = rect.height;
  277. }, 100);
  278. });
  279. };
  280. const onConfirmResult = () => {
  281. if (state.visiableInfo.operationType === 'RESULT') {
  282. state.visiableInfo.show = false;
  283. onAfter();
  284. } else if (state.visiableInfo.operationType === 'BACK') {
  285. state.visiableInfo.show = false;
  286. onAfter();
  287. } else if (state.visiableInfo.operationType === 'CONTINUE') {
  288. onResultPopup();
  289. } else if (state.visiableInfo.operationType === 'GRASP') {
  290. onGraspQuestionConfirm();
  291. }
  292. };
  293. const onCloseResult = async () => {
  294. const operationType = state.visiableInfo.operationType;
  295. if (operationType === 'RESULT') {
  296. } else if (operationType === 'BACK') {
  297. state.visiableInfo.show = false;
  298. window.history.pushState(null, '', document.URL);
  299. window.addEventListener('popstate', onBack, false);
  300. } else if (operationType === 'CONTINUE' || operationType === 'GRASP') {
  301. state.visiableInfo.show = false;
  302. }
  303. };
  304. /** 结果页面弹窗 */
  305. const onResultPopup = () => {
  306. const answerResult = getAnswerResult.value;
  307. let rate = 0;
  308. if (answerResult.count > 0) {
  309. rate = Math.floor((answerResult.passCount / answerResult.count) * 100);
  310. }
  311. const times = counter.value;
  312. const minute =
  313. Math.floor(times / 60) >= 10
  314. ? Math.floor(times / 60)
  315. : '0' + Math.floor(times / 60);
  316. const seconds = times % 60 >= 10 ? times % 60 : '0' + (times % 60);
  317. state.overResult = {
  318. time: minute + ':' + seconds, // 时长
  319. questionLength: answerResult.count, // 答题数
  320. errorLength: answerResult.noPassCount, // 错题数
  321. rate // 正确率
  322. };
  323. // 重置计时
  324. pause();
  325. counter.value = 0;
  326. // 60 及格
  327. // 85 及以上优秀
  328. state.visiableInfo.show = true;
  329. state.visiableInfo.title = '已完成';
  330. state.visiableInfo.showCancelButton = false;
  331. state.visiableInfo.operationType = 'RESULT';
  332. state.visiableInfo.confirmButtonText = '确认';
  333. state.visiableInfo.content = `<div>您已完成本次测试,答对<span class='${
  334. styles.right
  335. }'>${answerResult.passCount}</span>,答错<span class='${styles.error}'>${
  336. answerResult.count - answerResult.passCount
  337. }</span>,正确率${rate}%~</div>`;
  338. };
  339. // 拦截
  340. const onBack = () => {
  341. const answerResult = getAnswerResult.value;
  342. state.visiableInfo.show = true;
  343. state.visiableInfo.title = '确认退出吗?';
  344. state.visiableInfo.showCancelButton = true;
  345. state.visiableInfo.operationType = 'BACK';
  346. state.visiableInfo.cancelButtonText = '取消';
  347. state.visiableInfo.confirmButtonText = '确定';
  348. state.visiableInfo.content = `您已经完成${
  349. answerResult.passCount + answerResult.noPassCount
  350. }道题了,继续做题可以巩固所学知识哦~`;
  351. eventUnit.emit('unitAudioStop');
  352. };
  353. const onAfter = () => {
  354. window.removeEventListener('popstate', onBack, false);
  355. router.back();
  356. };
  357. onMounted(async () => {
  358. useEventListener(document, 'scroll', () => {
  359. const { y } = useWindowScroll();
  360. if (y.value > 52) {
  361. state.background = '#fff';
  362. state.color = '#323333';
  363. } else {
  364. state.background = 'transparent';
  365. state.color = '#fff';
  366. }
  367. });
  368. await getExamDetails();
  369. resizeSwipeItemHeight();
  370. window.history.pushState(null, '', document.URL);
  371. window.addEventListener('popstate', onBack, false);
  372. });
  373. onUnmounted(() => {
  374. // 关闭所有音频
  375. eventUnit.emit('unitAudioStop');
  376. });
  377. return () => (
  378. <div class={styles.unitDetail}>
  379. <MSticky position="top">
  380. <MHeader
  381. border={false}
  382. background={state.background}
  383. color={state.color}
  384. />
  385. </MSticky>
  386. <Swipe
  387. loop={false}
  388. showIndicators={false}
  389. ref={swipeRef}
  390. duration={300}
  391. touchable={false}
  392. class={styles.unitSwipe}
  393. style={{ paddingBottom: '12px' }}
  394. lazyRender
  395. height={state.swipeHeight}
  396. onChange={(index: number) => {
  397. eventUnit.emit('unitAudioStop');
  398. state.currentIndex = index;
  399. resizeSwipeItemHeight();
  400. }}>
  401. {state.questionList.map((item: any, index: number) => (
  402. <SwipeItem>
  403. <div class="swipe-item-question">
  404. {item.questionTypeCode === QuestionType.RADIO && (
  405. <ChoiceQuestion
  406. v-model:value={item.userAnswer}
  407. index={index + 1}
  408. data={item}
  409. type="radio"
  410. showAnalysis={item.showAnalysis}
  411. analysis={item.analysis}>
  412. {{
  413. title: () => (
  414. <div class={styles.questionTitle}>
  415. <div class={styles.questionNum}>
  416. <p class={styles.pointName}>
  417. {item.knowledgePointName}
  418. </p>
  419. <span>{state.currentIndex + 1}</span>/{state.total}
  420. </div>
  421. <Button
  422. round
  423. plain
  424. size="mini"
  425. color={item.grasp ? '#FF5A56' : '#1CACF1'}
  426. class={styles.controlBtn}
  427. disabled={item.grasp}
  428. onClick={() => onGraspQuestion(item)}>
  429. {item.grasp ? '已掌握此题' : '掌握此题'}
  430. </Button>
  431. </div>
  432. )
  433. }}
  434. </ChoiceQuestion>
  435. )}
  436. {item.questionTypeCode === QuestionType.CHECKBOX && (
  437. <ChoiceQuestion
  438. v-model:value={item.userAnswer}
  439. index={index + 1}
  440. data={item}
  441. type="checkbox"
  442. showAnalysis={item.showAnalysis}
  443. analysis={item.analysis}>
  444. {{
  445. title: () => (
  446. <div class={styles.questionTitle}>
  447. <div class={styles.questionNum}>
  448. <p class={styles.pointName}>
  449. {item.knowledgePointName}
  450. </p>
  451. <span>{state.currentIndex + 1}</span>/{state.total}
  452. </div>
  453. <Button
  454. round
  455. plain
  456. size="mini"
  457. color={item.grasp ? '#FF5A56' : '#1CACF1'}
  458. class={styles.controlBtn}
  459. disabled={item.grasp}
  460. onClick={() => onGraspQuestion(item)}>
  461. {item.grasp ? '已掌握此题' : '掌握此题'}
  462. </Button>
  463. </div>
  464. )
  465. }}
  466. </ChoiceQuestion>
  467. )}
  468. {item.questionTypeCode === QuestionType.SORT && (
  469. <DragQuestion
  470. v-model:value={item.userAnswer}
  471. onUpdate:value={() => {
  472. // 如果是空则滑动到顶部
  473. const status =
  474. item.userAnswer && item.userAnswer.length > 0
  475. ? false
  476. : true;
  477. resizeSwipeItemHeight(status);
  478. }}
  479. data={item}
  480. index={index + 1}
  481. showAnalysis={item.showAnalysis}
  482. analysis={item.analysis}>
  483. {{
  484. title: () => (
  485. <div class={styles.questionTitle}>
  486. <div class={styles.questionNum}>
  487. <p class={styles.pointName}>
  488. {item.knowledgePointName}
  489. </p>
  490. <span>{state.currentIndex + 1}</span>/{state.total}
  491. </div>
  492. <Button
  493. round
  494. plain
  495. size="mini"
  496. color={item.grasp ? '#FF5A56' : '#1CACF1'}
  497. class={styles.controlBtn}
  498. disabled={item.grasp}
  499. onClick={() => onGraspQuestion(item)}>
  500. {item.grasp ? '已掌握此题' : '掌握此题'}
  501. </Button>
  502. </div>
  503. )
  504. }}
  505. </DragQuestion>
  506. )}
  507. {item.questionTypeCode === QuestionType.LINK && (
  508. <KeepLookQuestion
  509. v-model:value={item.userAnswer}
  510. data={item}
  511. index={index + 1}
  512. showAnalysis={item.showAnalysis}
  513. analysis={item.analysis}>
  514. {{
  515. title: () => (
  516. <div class={styles.questionTitle}>
  517. <div class={styles.questionNum}>
  518. <p class={styles.pointName}>
  519. {item.knowledgePointName}
  520. </p>
  521. <span>{state.currentIndex + 1}</span>/{state.total}
  522. </div>
  523. <Button
  524. round
  525. plain
  526. size="mini"
  527. color={item.grasp ? '#FF5A56' : '#1CACF1'}
  528. class={styles.controlBtn}
  529. disabled={item.grasp}
  530. onClick={() => onGraspQuestion(item)}>
  531. {item.grasp ? '已掌握此题' : '掌握此题'}
  532. </Button>
  533. </div>
  534. )
  535. }}
  536. </KeepLookQuestion>
  537. )}
  538. {item.questionTypeCode === QuestionType.PLAY && (
  539. <PlayQuestion
  540. v-model:value={item.userAnswer}
  541. data={item}
  542. index={index + 1}
  543. unitId={state.id as any}
  544. showAnalysis={item.showAnalysis}
  545. analysis={item.analysis}>
  546. {{
  547. title: () => (
  548. <div class={styles.questionTitle}>
  549. <div class={styles.questionNum}>
  550. <span>{state.currentIndex + 1}</span>/{state.total}
  551. </div>
  552. {/* <div class={styles.questionType}>
  553. <i></i>
  554. <span>{item.knowledgePointName}</span>
  555. </div> */}
  556. <Button
  557. round
  558. plain
  559. size="mini"
  560. color={item.grasp ? '#FF5A56' : '#1CACF1'}
  561. disabled={item.grasp}
  562. class={styles.controlBtn}
  563. onClick={() => onGraspQuestion(item)}>
  564. {item.grasp ? '已掌握此题' : '掌握此题'}
  565. </Button>
  566. </div>
  567. )
  568. }}
  569. </PlayQuestion>
  570. )}
  571. </div>
  572. </SwipeItem>
  573. ))}
  574. </Swipe>
  575. <MSticky position="bottom">
  576. <div class={['btnGroup btnMore', styles.btnSection]}>
  577. <Button
  578. round
  579. block
  580. class={
  581. state.currentIndex > 0 ? styles.activePrevBtn : styles.prevBtn
  582. }
  583. disabled={state.currentIndex > 0 ? false : true}
  584. onClick={() => {
  585. swipeRef.value?.prev();
  586. }}>
  587. 上一题
  588. </Button>
  589. <Button
  590. block
  591. round
  592. class={styles.nextBtn}
  593. onClick={onNextQuestion}
  594. loading={state.nextStatus}
  595. disabled={state.nextStatus}>
  596. {state.questionList.length === state.currentIndex + 1
  597. ? '提交'
  598. : '下一题'}
  599. </Button>
  600. <Image
  601. src={iconButtonList}
  602. class={[styles.wapList, 'van-haptics-feedback']}
  603. onClick={() => (state.visiableAnswer = true)}
  604. />
  605. </div>
  606. </MSticky>
  607. {/* 题目集合 */}
  608. <ActionSheet
  609. v-model:show={state.visiableAnswer}
  610. title="题目列表"
  611. safeAreaInsetBottom>
  612. <AnswerList
  613. value={state.questionList}
  614. lookType={'PRACTICE'}
  615. statusList={[
  616. {
  617. text: '答对',
  618. color: '#1CACF1'
  619. },
  620. {
  621. text: '答错',
  622. color: '#FF8486'
  623. },
  624. {
  625. text: '未答',
  626. color: '#EAEAEA'
  627. }
  628. ]}
  629. onSelect={(item: any) => {
  630. // 跳转,并且跳过动画
  631. swipeRef.value?.swipeTo(item, {
  632. immediate: true
  633. });
  634. state.visiableAnswer = false;
  635. }}
  636. />
  637. </ActionSheet>
  638. <Popup
  639. v-model:show={state.visiableError}
  640. style={{ width: '90%' }}
  641. round
  642. closeOnClickOverlay={false}>
  643. <ErrorMode
  644. onClose={() => (state.visiableError = false)}
  645. answerAnalysis={state.answerAnalysis}
  646. questionTypeCode={state.questionTypeCode}
  647. onConform={() => {
  648. swipeRef.value?.next();
  649. state.answerAnalysis = '';
  650. }}
  651. />
  652. </Popup>
  653. <Popup
  654. v-model:show={state.visiableInfo.show}
  655. closeOnClickOverlay={false}
  656. style={{
  657. background: 'transparent',
  658. width: '100%',
  659. maxWidth: '100%',
  660. transform: 'translateY(-55%)'
  661. }}>
  662. <ResultFinish
  663. title={state.visiableInfo.title}
  664. showCancelButton={state.visiableInfo.showCancelButton}
  665. cancelButtonText={state.visiableInfo.cancelButtonText}
  666. confirmButtonText={state.visiableInfo.confirmButtonText}
  667. status={state.visiableInfo.type}
  668. content={state.visiableInfo.content}
  669. contentHtml
  670. onConform={onConfirmResult}
  671. onClose={onCloseResult}
  672. />
  673. </Popup>
  674. </div>
  675. );
  676. }
  677. });