index.tsx 21 KB

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