index.tsx 23 KB

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