start-exam copy.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. <template>
  2. <ie-page :fix-height="true" :safe-area-inset-bottom="false">
  3. <ie-navbar :title="pageTitle" custom-back @left-click="handleLeftClick">
  4. <template v-if="isReady" #headerRight>
  5. <view v-if="isExamMode" class="countdown-text" :class="{ 'text-red-500': examDuration < 30 }">
  6. {{ formatExamDuration }}
  7. </view>
  8. <view v-else class="">{{ formatPracticeDuration }}</view>
  9. </template>
  10. </ie-navbar>
  11. <view v-if="isReady" class="px-20 py-14 bg-back flex justify-between items-center gap-x-20">
  12. <text class="flex-1 min-w-1 text-26 ellipsis-1">{{ pageSubtitle }}</text>
  13. <view class="flex items-baseline">
  14. <text class="text-34 text-primary font-bold">{{ currentIndex + 1 }}</text>/
  15. <text class="text-28 text-fore-subtitle">{{ totalCount }}</text>
  16. </view>
  17. </view>
  18. <view class="flex-1 min-h-1 relative">
  19. <view class="absolute inset-0 ">
  20. <swiper class="h-full" :disable-touch="false" :current="currentIndex" :duration="swiperDuration"
  21. @change="handleSwiperChange" @transition="handleSwiperTransition"
  22. @animationfinish="handleSwiperAnimationFinish">
  23. <swiper-item class="h-full" v-for="(item, index) in questionList" :key="index">
  24. <question-wrap :question="item" @update:question="handleUpdateQuestion" />
  25. </swiper-item>
  26. </swiper>
  27. </view>
  28. </view>
  29. <ie-safe-toolbar v-if="isReady" :height="64" :shadow="false">
  30. <view class="px-18 h-full flex items-center justify-around border-0 border-t border-solid border-[#EFEFEF]">
  31. <view class="w-48 h-48 flex items-center justify-center" @click="handleFavorite">
  32. <uv-icon v-if="currentQuestion.isFavorite" name="star-fill" color="#FF9A18" size="27" />
  33. <uv-icon v-else name="star" size="27" />
  34. </view>
  35. <view class="w-48 h-48 flex items-center justify-center" @click="handleMark">
  36. <ie-image
  37. :src="currentQuestion.isMark ? '/pagesStudy/static/image/icon-mark-active.png' : '/pagesStudy/static/image/icon-mark.png'"
  38. custom-class="w-38 h-38" mode="aspectFill" />
  39. </view>
  40. <view class="w-48 h-48 flex items-center justify-center" @click="handleCalendar">
  41. <uv-icon name="calendar" size="28" />
  42. </view>
  43. </view>
  44. </ie-safe-toolbar>
  45. </ie-page>
  46. <question-stats-popup ref="questionStatsPopupRef">
  47. <template #title>
  48. <view class="ml-20">
  49. <text class="text-30 text-primary">{{ doneCount }}</text>
  50. <text>/</text>
  51. <text class="text-30 text-fore-light">{{ totalCount }}</text>
  52. </view>
  53. </template>
  54. <view class="popup-content">
  55. <view class="flex-1 min-h-1">
  56. <scroll-view class="h-full" scroll-y>
  57. <view v-for="(item, i) in groupedQuestionList" :key="i" class="">
  58. <template v-if="item.list.length > 0">
  59. <view class="h-70 bg-back px-20 leading-70 text-fore-subcontent">{{ questionTypeDesc[item.type] }}</view>
  60. <view class="grid grid-cols-5 place-items-center gap-x-20 gap-y-20 p-30">
  61. <view v-for="(qs, j) in item.list" :key="j" class="aspect-square flex items-center justify-center"
  62. @click="hanadleNavigate(qs.index)">
  63. <view
  64. class="w-74 h-74 rounded-full flex items-center justify-center bg-white border border-solid border-border relative"
  65. :class="{
  66. 'is-done': qs.question.isDone,
  67. 'is-not-know': qs.question.isNotKnow,
  68. 'is-mark': qs.question.isMark
  69. }">
  70. <text class="z-1">{{ qs.index + 1 }}</text>
  71. <ie-image v-if="qs.question.isMark" src="/pagesStudy/static/image/icon-mark-active.png"
  72. custom-class="absolute -top-12 left-14 w-28 h-28 z-1" mode="aspectFill" />
  73. <question-progress :progress="qs.question.progress || 0" />
  74. </view>
  75. </view>
  76. </view>
  77. </template>
  78. </view>
  79. </scroll-view>
  80. </view>
  81. <view class="h-150 bg-white flex items-center gap-x-120 px-40">
  82. <view class="flex flex-col items-center gap-x-10" @click="handleReset">
  83. <uv-icon name="reload" size="20" :color="doneCount > 0 ? '#999' : '#cccccc'" />
  84. <text class="mt-4 text-20 text-subcontent" :class="{ 'text-fore-light': doneCount <= 0 }">重新作答</text>
  85. </view>
  86. <view class="flex-1 py-20 text-center rounded-full bg-primary text-white" @click="beforeSubmit">交卷</view>
  87. </view>
  88. </view>
  89. </question-stats-popup>
  90. </template>
  91. <script lang="ts" setup>
  92. import QuestionItem from './components/question-item.vue';
  93. import QuestionWrap from './components/question-wrap.vue';
  94. import QuestionStatsPopup from './components/question-stats-popup.vue';
  95. import QuestionProgress from './components/question-progress.vue';
  96. import { useTransferPage } from '@/hooks/useTransferPage';
  97. import { EnumExamMode } from '@/common/enum';
  98. import { getOpenExaminee, getPaper, commitExamineePaper, collectQuestion, cancelCollectQuestion } from '@/api/modules/study';
  99. import { useExam } from '@/composables/useExam';
  100. import { Study } from '@/types';
  101. import { NEXT_QUESTION, PREV_QUESTION, NEXT_QUESTION_QUICKLY, PREV_QUESTION_QUICKLY } from '@/types/injectionSymbols';
  102. // import { Examinee, ExamPaper, ExamPaperSubmit } from '@/types/study';
  103. const { prevData, transferBack } = useTransferPage();
  104. const { setQuestionList, questionList, groupedQuestionList, stateQuestionList, questionTypeDesc, favoriteList, notKnowList, markList, currentIndex,
  105. totalCount, doneCount, notDoneCount, isAllDone, nextQuestion, prevQuestion, nextQuestionQuickly, prevQuestionQuickly, swiperDuration,
  106. formatPracticeDuration, formatExamDuration, practiceDuration, startPracticeDuration, stopPracticeDuration,
  107. examDuration, startExamDuration, stopExamDuration, setExamDuration, setCountDownCallback, changeIndex, setDuration, reset } = useExam();
  108. provide(NEXT_QUESTION, nextQuestion);
  109. provide(PREV_QUESTION, prevQuestion);
  110. provide(NEXT_QUESTION_QUICKLY, nextQuestionQuickly);
  111. provide(PREV_QUESTION_QUICKLY, prevQuestionQuickly);
  112. const isAnimationFinish = ref(false);
  113. const transitionStartX = ref(null);
  114. const transitionEndX = ref(null);
  115. const isReady = ref(false);
  116. // 自动提交只提醒1次
  117. const hasShowSubmitConfirm = ref(false);
  118. const examineerData = ref<Study.Examinee>({} as Study.Examinee);
  119. const paperData = ref<Study.ExamPaper>({} as Study.ExamPaper);
  120. const pageTitle = computed(() => {
  121. const { mode } = prevData.value;
  122. return mode === EnumExamMode.PRACTICE ? '练习' : '考试';
  123. });
  124. const pageSubtitle = computed(() => {
  125. const { mode, name } = prevData.value;
  126. return (mode === EnumExamMode.PRACTICE ? '知识点练习' : '考试') + '-' + name;
  127. });
  128. const isExamMode = computed(() => {
  129. return prevData.value.mode === EnumExamMode.EXAM;
  130. });
  131. const handleLeftClick = () => {
  132. if (!isReady.value) {
  133. transferBack();
  134. return;
  135. }
  136. beforeQuit();
  137. };
  138. const handleSwiperChange = (e: any) => {
  139. currentIndex.value = e.detail.current;
  140. };
  141. const beforeQuit = () => {
  142. const { mode } = prevData.value;
  143. if (!isReady.value) {
  144. return;
  145. }
  146. stopTime();
  147. const msg = mode === EnumExamMode.PRACTICE ? '当前练习未完成,确认退出?' : '当前考试未完成,确认退出?';
  148. uni.$ie.showModal({
  149. title: '提示',
  150. content: msg,
  151. }).then(confirm => {
  152. if (confirm) {
  153. handleSubmit(true);
  154. } else {
  155. startTime();
  156. }
  157. });
  158. };
  159. const currentQuestion = computed(() => {
  160. return questionList.value[currentIndex.value] || {};
  161. });
  162. const hanadleNavigate = (index: number) => {
  163. changeIndex(index);
  164. }
  165. const handleFavorite = async () => {
  166. if (!currentQuestion.value.isFavorite) {
  167. await collectQuestion(currentQuestion.value.id);
  168. currentQuestion.value.isFavorite = true;
  169. uni.$ie.showToast('收藏成功');
  170. } else {
  171. await cancelCollectQuestion(currentQuestion.value.id);
  172. currentQuestion.value.isFavorite = false;
  173. uni.$ie.showToast('取消收藏成功');
  174. }
  175. };
  176. const handleMark = () => {
  177. currentQuestion.value.isMark = !currentQuestion.value.isMark;
  178. };
  179. const questionStatsPopupRef = ref();
  180. const handleCalendar = () => {
  181. questionStatsPopupRef.value.open();
  182. };
  183. const handleSwiperTransition = (e: any) => {
  184. if (currentIndex.value === questionList.value.length - 1) {
  185. if (!transitionStartX.value) {
  186. transitionStartX.value = e.detail.dx;
  187. } else {
  188. transitionEndX.value = e.detail.dx;
  189. }
  190. return;
  191. }
  192. };
  193. const startTime = () => {
  194. if (isExamMode.value) {
  195. startExamDuration();
  196. } else {
  197. startPracticeDuration();
  198. }
  199. }
  200. const stopTime = () => {
  201. if (isExamMode.value) {
  202. stopExamDuration();
  203. } else {
  204. stopPracticeDuration();
  205. }
  206. }
  207. const handleSwiperAnimationFinish = (e: any) => {
  208. if (transitionStartX.value == null || transitionEndX.value == null || currentIndex.value !== questionList.value.length - 1) {
  209. isAnimationFinish.value = true;
  210. transitionStartX.value = null;
  211. transitionEndX.value = null;
  212. return;
  213. }
  214. const offsetX = transitionEndX.value - transitionStartX.value;
  215. if (offsetX < 0 && offsetX > -150) {
  216. beforeSubmit();
  217. }
  218. isAnimationFinish.value = true;
  219. transitionStartX.value = null;
  220. transitionEndX.value = null;
  221. };
  222. const beforeSubmit = () => {
  223. const text = notDoneCount.value > 0 ? `还有${notDoneCount.value}题未做,确认交卷?` : '是否确认交卷?';
  224. stopTime();
  225. uni.$ie.showModal({
  226. title: '提示',
  227. content: text,
  228. }).then(confirm => {
  229. if (confirm) {
  230. handleSubmit(false);
  231. } else {
  232. startTime();
  233. }
  234. });
  235. }
  236. const handleReset = () => {
  237. if (doneCount.value <= 0) {
  238. return;
  239. }
  240. uni.$ie.showModal({
  241. title: '重新作答',
  242. content: '是否确认清空全部作答数据?',
  243. }).then(confirm => {
  244. if (confirm) {
  245. questionStatsPopupRef.value.close();
  246. reset();
  247. setTimeout(() => {
  248. startTime();
  249. }, 300);
  250. }
  251. });
  252. }
  253. const autoSubmit = () => {
  254. if (isAllDone.value) {
  255. if (hasShowSubmitConfirm.value) {
  256. return;
  257. }
  258. hasShowSubmitConfirm.value = true;
  259. beforeSubmit();
  260. }
  261. }
  262. const handleSubmit = (tempSave: boolean = false) => {
  263. console.log('handleSubmit', questionList.value)
  264. const msg = tempSave ? '保存中...' : '提交中...';
  265. uni.$ie.showLoading(msg);
  266. setTimeout(() => {
  267. uni.$ie.hideLoading();
  268. const params = {
  269. ...paperData.value,
  270. questions: questionList.value.map(item => {
  271. return {
  272. ...item,
  273. isDone: tempSave ? item.isDone : true
  274. };
  275. }),
  276. examineeId: examineerData.value.examineeId,
  277. isDone: isAllDone.value,
  278. duration: isExamMode.value ? examDuration.value : practiceDuration.value
  279. } as Study.ExamPaperSubmit;
  280. if (!isExamMode.value) {
  281. params.duration = practiceDuration.value;
  282. }
  283. commitExamineePaper(params);
  284. uni.navigateBack();
  285. }, 1000);
  286. }
  287. const handleUpdateQuestion = (question: Study.Question) => {
  288. if (currentIndex.value === questionList.value.length - 1) {
  289. autoSubmit();
  290. }
  291. }
  292. /**
  293. * 恢复上次做题历史数据
  294. * @param savedQuestion 上次做题历史数据
  295. * @param fullQuestion 当前试卷数据
  296. */
  297. const restoreQuestion = (savedQuestion: Study.ExamineeQuestion[], fullQuestion: Study.ExamineeQuestion[]) => {
  298. if (!savedQuestion) {
  299. return fullQuestion;
  300. }
  301. console.log(savedQuestion, fullQuestion)
  302. for (const item of fullQuestion) {
  303. const savedQs = savedQuestion.find(q => q.id === item.id);
  304. if (savedQs) {
  305. if (savedQs.answers) {
  306. item.answers = savedQs.answers.filter(ans => ans.trim());
  307. }
  308. item.isMark = savedQs.isMark;
  309. item.isFavorite = savedQs.isFavorite;
  310. item.isNotKnow = savedQs.isNotKnow;
  311. if (item.subQuestions) {
  312. restoreQuestion(savedQs.subQuestions, item.subQuestions);
  313. }
  314. }
  315. }
  316. return fullQuestion;
  317. }
  318. const loadData = async () => {
  319. uni.$ie.showLoading();
  320. const { data } = await getOpenExaminee({
  321. paperType: prevData.value.paperType,
  322. relateId: prevData.value.relateId,
  323. directed: prevData.value.directed
  324. });
  325. if (!data) {
  326. uni.$ie.hideLoading();
  327. transferBack();
  328. return;
  329. }
  330. examineerData.value = data;
  331. if (data.paperId) {
  332. const res = await getPaper({
  333. type: prevData.value.paperType,
  334. id: data.paperId
  335. });
  336. uni.$ie.hideLoading();
  337. paperData.value = res.data;
  338. paperData.value.questions = restoreQuestion(data.questions, res.data.questions);
  339. setQuestionList(paperData.value.questions);
  340. setDuration(data.duration || 0);
  341. }
  342. isReady.value = true;
  343. setCountDownCallback(() => {
  344. handleSubmit(false);
  345. });
  346. startTime();
  347. };
  348. onLoad(() => {
  349. loadData();
  350. });
  351. </script>
  352. <style lang="scss" scoped>
  353. .countdown-text {
  354. height: 100%;
  355. display: flex;
  356. align-items: center;
  357. font-weight: bold;
  358. font-family: 'Courier New', Courier, monospace;
  359. }
  360. .popup-content {
  361. @apply h-[42vh] flex flex-col;
  362. }
  363. .scroll-view {
  364. @apply h-full;
  365. }
  366. .is-done {
  367. @apply text-primary border-[#EBF9FF] bg-[#EBF9FF];
  368. }
  369. .is-not-know {
  370. @apply text-fore-title border-[#F2F2F2] bg-[#F2F2F2];
  371. }
  372. </style>