index.tsx 20 KB

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