|
@@ -0,0 +1,393 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <ie-page :fix-height="true" :safe-area-inset-bottom="false">
|
|
|
|
|
+ <ie-navbar :title="pageTitle" custom-back @left-click="handleLeftClick">
|
|
|
|
|
+ <template v-if="isReady" #headerRight>
|
|
|
|
|
+ <view v-if="isExamMode" class="countdown-text" :class="{ 'text-red-500': examDuration < 30 }">
|
|
|
|
|
+ {{ formatExamDuration }}
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-else class="">{{ formatPracticeDuration }}</view>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </ie-navbar>
|
|
|
|
|
+ <view v-if="isReady" class="px-20 py-14 bg-back flex justify-between items-center gap-x-20">
|
|
|
|
|
+ <text class="flex-1 min-w-1 text-26 ellipsis-1">{{ pageSubtitle }}</text>
|
|
|
|
|
+ <view class="flex items-baseline">
|
|
|
|
|
+ <text class="text-34 text-primary font-bold">{{ currentIndex + 1 }}</text>/
|
|
|
|
|
+ <text class="text-28 text-fore-subtitle">{{ totalCount }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="flex-1 min-h-1 relative">
|
|
|
|
|
+ <view class="absolute inset-0 ">
|
|
|
|
|
+ <swiper class="h-full" :disable-touch="false" :current="currentIndex" :duration="swiperDuration"
|
|
|
|
|
+ @change="handleSwiperChange" @transition="handleSwiperTransition"
|
|
|
|
|
+ @animationfinish="handleSwiperAnimationFinish">
|
|
|
|
|
+ <swiper-item class="h-full" v-for="(item, index) in questionList" :key="index">
|
|
|
|
|
+ <question-wrap :question="item" @update:question="handleUpdateQuestion" />
|
|
|
|
|
+ </swiper-item>
|
|
|
|
|
+ </swiper>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <ie-safe-toolbar v-if="isReady" :height="64" :shadow="false">
|
|
|
|
|
+ <view class="px-18 h-full flex items-center justify-around border-0 border-t border-solid border-[#EFEFEF]">
|
|
|
|
|
+ <view class="w-48 h-48 flex items-center justify-center" @click="handleFavorite">
|
|
|
|
|
+ <uv-icon v-if="currentQuestion.isFavorite" name="star-fill" color="#FF9A18" size="27" />
|
|
|
|
|
+ <uv-icon v-else name="star" size="27" />
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="w-48 h-48 flex items-center justify-center" @click="handleMark">
|
|
|
|
|
+ <ie-image
|
|
|
|
|
+ :src="currentQuestion.isMark ? '/pagesStudy/static/image/icon-mark-active.png' : '/pagesStudy/static/image/icon-mark.png'"
|
|
|
|
|
+ custom-class="w-38 h-38" mode="aspectFill" />
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="w-48 h-48 flex items-center justify-center" @click="handleCalendar">
|
|
|
|
|
+ <uv-icon name="calendar" size="28" />
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </ie-safe-toolbar>
|
|
|
|
|
+ </ie-page>
|
|
|
|
|
+ <question-stats-popup ref="questionStatsPopupRef">
|
|
|
|
|
+ <template #title>
|
|
|
|
|
+ <view class="ml-20">
|
|
|
|
|
+ <text class="text-30 text-primary">{{ doneCount }}</text>
|
|
|
|
|
+ <text>/</text>
|
|
|
|
|
+ <text class="text-30 text-fore-light">{{ totalCount }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <view class="popup-content">
|
|
|
|
|
+ <view class="flex-1 min-h-1">
|
|
|
|
|
+ <scroll-view class="h-full" scroll-y>
|
|
|
|
|
+ <view v-for="(item, i) in groupedQuestionList" :key="i" class="">
|
|
|
|
|
+ <template v-if="item.list.length > 0">
|
|
|
|
|
+ <view class="h-70 bg-back px-20 leading-70 text-fore-subcontent">{{ questionTypeDesc[item.type] }}</view>
|
|
|
|
|
+ <view class="grid grid-cols-5 place-items-center gap-x-20 gap-y-20 p-30">
|
|
|
|
|
+ <view v-for="(qs, j) in item.list" :key="j" class="aspect-square flex items-center justify-center"
|
|
|
|
|
+ @click="hanadleNavigate(qs.index)">
|
|
|
|
|
+ <view
|
|
|
|
|
+ class="w-74 h-74 rounded-full flex items-center justify-center bg-white border border-solid border-border relative"
|
|
|
|
|
+ :class="{
|
|
|
|
|
+ 'is-done': qs.question.isDone,
|
|
|
|
|
+ 'is-not-know': qs.question.isNotKnow,
|
|
|
|
|
+ 'is-mark': qs.question.isMark
|
|
|
|
|
+ }">
|
|
|
|
|
+ <text class="z-1">{{ qs.index + 1 }}</text>
|
|
|
|
|
+ <ie-image v-if="qs.question.isMark" src="/pagesStudy/static/image/icon-mark-active.png"
|
|
|
|
|
+ custom-class="absolute -top-12 left-14 w-28 h-28 z-1" mode="aspectFill" />
|
|
|
|
|
+ <question-progress :progress="qs.question.progress || 0" />
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </scroll-view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="h-150 bg-white flex items-center gap-x-120 px-40">
|
|
|
|
|
+ <view class="flex flex-col items-center gap-x-10" @click="handleReset">
|
|
|
|
|
+ <uv-icon name="reload" size="20" :color="doneCount > 0 ? '#999' : '#cccccc'" />
|
|
|
|
|
+ <text class="mt-4 text-20 text-subcontent" :class="{ 'text-fore-light': doneCount <= 0 }">重新作答</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="flex-1 py-20 text-center rounded-full bg-primary text-white" @click="beforeSubmit">交卷</view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </question-stats-popup>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script lang="ts" setup>
|
|
|
|
|
+import QuestionItem from './components/question-item.vue';
|
|
|
|
|
+import QuestionWrap from './components/question-wrap.vue';
|
|
|
|
|
+import QuestionStatsPopup from './components/question-stats-popup.vue';
|
|
|
|
|
+import QuestionProgress from './components/question-progress.vue';
|
|
|
|
|
+import { useTransferPage } from '@/hooks/useTransferPage';
|
|
|
|
|
+import { EnumExamMode } from '@/common/enum';
|
|
|
|
|
+import { getOpenExaminee, getPaper, commitExamineePaper, collectQuestion, cancelCollectQuestion } from '@/api/modules/study';
|
|
|
|
|
+import { useExam } from '@/composables/useExam';
|
|
|
|
|
+import { Study } from '@/types';
|
|
|
|
|
+import { NEXT_QUESTION, PREV_QUESTION, NEXT_QUESTION_QUICKLY, PREV_QUESTION_QUICKLY } from '@/types/injectionSymbols';
|
|
|
|
|
+// import { Examinee, ExamPaper, ExamPaperSubmit } from '@/types/study';
|
|
|
|
|
+const { prevData, transferBack } = useTransferPage();
|
|
|
|
|
+const { setQuestionList, questionList, groupedQuestionList, stateQuestionList, questionTypeDesc, favoriteList, notKnowList, markList, currentIndex,
|
|
|
|
|
+ totalCount, doneCount, notDoneCount, notKnowCount, markCount, isAllDone, nextQuestion, prevQuestion, nextQuestionQuickly, prevQuestionQuickly, swiperDuration,
|
|
|
|
|
+ formatPracticeDuration, formatExamDuration, practiceDuration, startPracticeDuration, stopPracticeDuration,
|
|
|
|
|
+ examDuration, startExamDuration, stopExamDuration, setExamDuration, setCountDownCallback, changeIndex, setDuration, reset } = useExam();
|
|
|
|
|
+
|
|
|
|
|
+provide(NEXT_QUESTION, nextQuestion);
|
|
|
|
|
+provide(PREV_QUESTION, prevQuestion);
|
|
|
|
|
+provide(NEXT_QUESTION_QUICKLY, nextQuestionQuickly);
|
|
|
|
|
+provide(PREV_QUESTION_QUICKLY, prevQuestionQuickly);
|
|
|
|
|
+
|
|
|
|
|
+const isAnimationFinish = ref(false);
|
|
|
|
|
+const transitionStartX = ref(null);
|
|
|
|
|
+const transitionEndX = ref(null);
|
|
|
|
|
+const isReady = ref(false);
|
|
|
|
|
+// 自动提交只提醒1次
|
|
|
|
|
+const hasShowSubmitConfirm = ref(false);
|
|
|
|
|
+const examineerData = ref<Study.Examinee>({} as Study.Examinee);
|
|
|
|
|
+const paperData = ref<Study.ExamPaper>({} as Study.ExamPaper);
|
|
|
|
|
+const pageTitle = computed(() => {
|
|
|
|
|
+ const { mode } = prevData.value;
|
|
|
|
|
+ return mode === EnumExamMode.PRACTICE ? '练习' : '考试';
|
|
|
|
|
+});
|
|
|
|
|
+const pageSubtitle = computed(() => {
|
|
|
|
|
+ const { mode, name } = prevData.value;
|
|
|
|
|
+ return (mode === EnumExamMode.PRACTICE ? '知识点练习' : '考试') + '-' + name;
|
|
|
|
|
+});
|
|
|
|
|
+const isExamMode = computed(() => {
|
|
|
|
|
+ return prevData.value.mode === EnumExamMode.EXAM;
|
|
|
|
|
+});
|
|
|
|
|
+const handleLeftClick = () => {
|
|
|
|
|
+ if (!isReady.value) {
|
|
|
|
|
+ transferBack();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ beforeQuit();
|
|
|
|
|
+};
|
|
|
|
|
+const handleSwiperChange = (e: any) => {
|
|
|
|
|
+ currentIndex.value = e.detail.current;
|
|
|
|
|
+};
|
|
|
|
|
+const beforeQuit = () => {
|
|
|
|
|
+ const { mode } = prevData.value;
|
|
|
|
|
+ if (!isReady.value) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ stopTime();
|
|
|
|
|
+ const msg = mode === EnumExamMode.PRACTICE ? '当前练习未完成,确认退出?' : '当前考试未完成,确认退出?';
|
|
|
|
|
+ uni.$ie.showModal({
|
|
|
|
|
+ title: '提示',
|
|
|
|
|
+ content: msg,
|
|
|
|
|
+ }).then(confirm => {
|
|
|
|
|
+ if (confirm) {
|
|
|
|
|
+ handleSubmit(true);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ startTime();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+};
|
|
|
|
|
+const currentQuestion = computed(() => {
|
|
|
|
|
+ return questionList.value[currentIndex.value] || {};
|
|
|
|
|
+});
|
|
|
|
|
+const hanadleNavigate = (index: number) => {
|
|
|
|
|
+ changeIndex(index);
|
|
|
|
|
+}
|
|
|
|
|
+const handleFavorite = async () => {
|
|
|
|
|
+ if (!currentQuestion.value.isFavorite) {
|
|
|
|
|
+ await collectQuestion(currentQuestion.value.id);
|
|
|
|
|
+ currentQuestion.value.isFavorite = true;
|
|
|
|
|
+ uni.$ie.showToast('收藏成功');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await cancelCollectQuestion(currentQuestion.value.id);
|
|
|
|
|
+ currentQuestion.value.isFavorite = false;
|
|
|
|
|
+ uni.$ie.showToast('取消收藏成功');
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const handleMark = () => {
|
|
|
|
|
+ currentQuestion.value.isMark = !currentQuestion.value.isMark;
|
|
|
|
|
+};
|
|
|
|
|
+const questionStatsPopupRef = ref();
|
|
|
|
|
+const handleCalendar = () => {
|
|
|
|
|
+ questionStatsPopupRef.value.open();
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const handleSwiperTransition = (e: any) => {
|
|
|
|
|
+ if (currentIndex.value === questionList.value.length - 1) {
|
|
|
|
|
+ if (!transitionStartX.value) {
|
|
|
|
|
+ transitionStartX.value = e.detail.dx;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ transitionEndX.value = e.detail.dx;
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const startTime = () => {
|
|
|
|
|
+ if (isExamMode.value) {
|
|
|
|
|
+ startExamDuration();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ startPracticeDuration();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const stopTime = () => {
|
|
|
|
|
+ if (isExamMode.value) {
|
|
|
|
|
+ stopExamDuration();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ stopPracticeDuration();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleSwiperAnimationFinish = (e: any) => {
|
|
|
|
|
+ if (transitionStartX.value == null || transitionEndX.value == null || currentIndex.value !== questionList.value.length - 1) {
|
|
|
|
|
+ isAnimationFinish.value = true;
|
|
|
|
|
+ transitionStartX.value = null;
|
|
|
|
|
+ transitionEndX.value = null;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const offsetX = transitionEndX.value - transitionStartX.value;
|
|
|
|
|
+ if (offsetX < 0 && offsetX > -150) {
|
|
|
|
|
+ beforeSubmit();
|
|
|
|
|
+ }
|
|
|
|
|
+ isAnimationFinish.value = true;
|
|
|
|
|
+ transitionStartX.value = null;
|
|
|
|
|
+ transitionEndX.value = null;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const beforeSubmit = () => {
|
|
|
|
|
+ const text = notDoneCount.value > 0 ? `还有${notDoneCount.value}题未做,确认交卷?` : '是否确认交卷?';
|
|
|
|
|
+ stopTime();
|
|
|
|
|
+ uni.$ie.showModal({
|
|
|
|
|
+ title: '提示',
|
|
|
|
|
+ content: text,
|
|
|
|
|
+ }).then(confirm => {
|
|
|
|
|
+ if (confirm) {
|
|
|
|
|
+ handleSubmit(false);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ startTime();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleReset = () => {
|
|
|
|
|
+ if (doneCount.value <= 0) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ uni.$ie.showModal({
|
|
|
|
|
+ title: '重新作答',
|
|
|
|
|
+ content: '是否确认清空全部作答数据?',
|
|
|
|
|
+ }).then(confirm => {
|
|
|
|
|
+ if (confirm) {
|
|
|
|
|
+ questionStatsPopupRef.value.close();
|
|
|
|
|
+ reset();
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ startTime();
|
|
|
|
|
+ }, 300);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const autoSubmit = () => {
|
|
|
|
|
+ if (isAllDone.value) {
|
|
|
|
|
+ if (hasShowSubmitConfirm.value) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ hasShowSubmitConfirm.value = true;
|
|
|
|
|
+ beforeSubmit();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleSubmit = (tempSave: boolean = false) => {
|
|
|
|
|
+ console.log('handleSubmit', questionList.value)
|
|
|
|
|
+ const msg = tempSave ? '保存中...' : '提交中...';
|
|
|
|
|
+ uni.$ie.showLoading(msg);
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ uni.$ie.hideLoading();
|
|
|
|
|
+ const params = {
|
|
|
|
|
+ ...paperData.value,
|
|
|
|
|
+ questions: questionList.value.map(item => {
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...item,
|
|
|
|
|
+ isDone: tempSave ? item.isDone : true
|
|
|
|
|
+ };
|
|
|
|
|
+ }),
|
|
|
|
|
+ examineeId: examineerData.value.examineeId,
|
|
|
|
|
+ isDone: isAllDone.value,
|
|
|
|
|
+ duration: isExamMode.value ? examDuration.value : practiceDuration.value
|
|
|
|
|
+ } as Study.ExamPaperSubmit;
|
|
|
|
|
+ if (!isExamMode.value) {
|
|
|
|
|
+ params.duration = practiceDuration.value;
|
|
|
|
|
+ }
|
|
|
|
|
+ commitExamineePaper(params);
|
|
|
|
|
+ uni.navigateBack();
|
|
|
|
|
+ }, 1000);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleUpdateQuestion = (question: Study.Question) => {
|
|
|
|
|
+ if (currentIndex.value === questionList.value.length - 1) {
|
|
|
|
|
+ autoSubmit();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+/**
|
|
|
|
|
+ * 恢复上次做题历史数据
|
|
|
|
|
+ * @param savedQuestion 上次做题历史数据
|
|
|
|
|
+ * @param fullQuestion 当前试卷数据
|
|
|
|
|
+ */
|
|
|
|
|
+const restoreQuestion = (savedQuestion: Study.ExamineeQuestion[], fullQuestion: Study.ApiQuestion[]) => {
|
|
|
|
|
+ if (!savedQuestion) {
|
|
|
|
|
+ return fullQuestion;
|
|
|
|
|
+ }
|
|
|
|
|
+ console.log(savedQuestion, fullQuestion)
|
|
|
|
|
+ for (const item of fullQuestion) {
|
|
|
|
|
+ const savedQs = savedQuestion.find(q => q.id === item.id);
|
|
|
|
|
+ if (savedQs) {
|
|
|
|
|
+ if (savedQs.answers) {
|
|
|
|
|
+ item.answers = savedQs.answers.filter(ans => ans.trim());
|
|
|
|
|
+ }
|
|
|
|
|
+ item.isMark = savedQs.isMark;
|
|
|
|
|
+ item.isFavorite = savedQs.isFavorite;
|
|
|
|
|
+ item.isNotKnow = savedQs.isNotKnow;
|
|
|
|
|
+ if (item.subQuestions) {
|
|
|
|
|
+ restoreQuestion(savedQs.subQuestions, item.subQuestions);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return fullQuestion;
|
|
|
|
|
+}
|
|
|
|
|
+const loadData = async () => {
|
|
|
|
|
+ uni.$ie.showLoading();
|
|
|
|
|
+ const { data } = await getOpenExaminee({
|
|
|
|
|
+ paperType: prevData.value.paperType,
|
|
|
|
|
+ relateId: prevData.value.relateId,
|
|
|
|
|
+ directed: prevData.value.directed
|
|
|
|
|
+ });
|
|
|
|
|
+ if (!data) {
|
|
|
|
|
+ uni.$ie.hideLoading();
|
|
|
|
|
+ transferBack();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ examineerData.value = data;
|
|
|
|
|
+ if (data.paperId) {
|
|
|
|
|
+ const res = await getPaper({
|
|
|
|
|
+ type: prevData.value.paperType,
|
|
|
|
|
+ id: data.paperId
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ uni.$ie.hideLoading();
|
|
|
|
|
+ paperData.value = res.data;
|
|
|
|
|
+ paperData.value.questions = restoreQuestion(data.questions, res.data.questions);
|
|
|
|
|
+ setQuestionList(paperData.value.questions);
|
|
|
|
|
+ setDuration(data.duration || 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ isReady.value = true;
|
|
|
|
|
+ setCountDownCallback(() => {
|
|
|
|
|
+ handleSubmit(false);
|
|
|
|
|
+ });
|
|
|
|
|
+ startTime();
|
|
|
|
|
+};
|
|
|
|
|
+onLoad(() => {
|
|
|
|
|
+ loadData();
|
|
|
|
|
+});
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style lang="scss" scoped>
|
|
|
|
|
+.countdown-text {
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ font-family: 'Courier New', Courier, monospace;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.popup-content {
|
|
|
|
|
+ @apply h-[42vh] flex flex-col;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.scroll-view {
|
|
|
|
|
+ @apply h-full;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.is-done {
|
|
|
|
|
+ @apply text-primary border-[#EBF9FF] bg-[#EBF9FF];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.is-not-know {
|
|
|
|
|
+ @apply text-fore-title border-[#F2F2F2] bg-[#F2F2F2];
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|