瀏覽代碼

优化做题计时功能,增加每题用时

shmily1213 1 月之前
父節點
當前提交
b226c638d6

+ 104 - 65
src/composables/useExam.ts

@@ -107,7 +107,13 @@ export const useExam = () => {
     EnumQuestionType.OTHER
   ];
   let interval: NodeJS.Timeout | null = null;
+  let animationFrameId: number | null = null; // requestAnimationFrame 的 ID
+  let countStart: number = 0;
+  let countTime: number = 0;
   const countDownCallback = ref<() => void>(() => { });
+  // 练习计时相关变量
+  let practiceStartTime: number = 0; // 练习开始时间戳(毫秒)
+  let practiceAccumulatedTime: number = 0; // 累计的练习时间(秒)
   // 练习时长
   const practiceDuration = ref<number>(0);
   const formatPracticeDuration = computed(() => {
@@ -252,7 +258,8 @@ export const useExam = () => {
     if (subQuestionIndex.value >= currentQuestion.value.subQuestions.length) {
       return null;
     }
-    return currentQuestion.value.subQuestions[subQuestionIndex.value];
+    // return currentQuestion.value.subQuestions[subQuestionIndex.value];
+    return currentQuestion.value.subQuestions[currentQuestion.value.activeSubIndex];
   });
   /// 总题量,不区分子题,等同于接口返回的题目列表
   const totalCount = computed(() => {
@@ -282,6 +289,20 @@ export const useExam = () => {
   const notDoneCount = computed(() => {
     return virtualTotalCount.value - doneCount.value;
   });
+  watch(() => virtualCurrentIndex.value, (newVal, oldVal) => {
+    updateQuestionDuration(oldVal);
+  });
+  // 更新单个题目做题时间
+  const updateQuestionDuration = (index: number, continueCount = true) => {
+    const question = flatQuestionList.value[index];
+    const time = stopCount();
+    question.duration += time;
+    // 每次结算后都清空累计时长,避免多次提交或多次 stop 导致重复累加
+    clearCount();
+    if (continueCount) {
+      startCount();
+    }
+  }
   /// 包含子题的题目计算整体做题进度
   const calcProgress = (qs: Study.Question): number => {
     if (qs.subQuestions && qs.subQuestions.length > 0) {
@@ -335,46 +356,19 @@ export const useExam = () => {
   }
   // 是否可以切换上一题
   const prevEnable = computed(() => {
-    return virtualCurrentIndex.value > 0;
+    return currentIndex.value > 0 || currentQuestion.value.activeSubIndex > 0;
   });
   // 是否可以切换下一题
   const nextEnable = computed(() => {
-    if (currentQuestion.value) {
-      if (currentQuestion.value.isSubQuestion) {
-        console.log(5, subQuestionIndex.value < currentQuestion.value.subQuestions.length - 1)
-        return subQuestionIndex.value < currentQuestion.value.subQuestions.length - 1;
-      } else {
-        if (currentQuestion.value.subQuestions && currentQuestion.value.subQuestions.length > 0) {
-          console.log(subQuestionIndex.value, currentQuestion.value.subQuestions.length - 1)
-          // 子题可以切换
-          return subQuestionIndex.value < currentQuestion.value.subQuestions.length - 1;
-        } else {
-          // 大题可以切换
-          return currentIndex.value < questionList.value.length - 1;
-        }
-      }
-    }
-    console.log(7, currentQuestion.value)
-    return false;
+    return currentIndex.value < questionList.value.length - 1 || currentQuestion.value.activeSubIndex < currentQuestion.value.subQuestions.length - 1;
   });
   // 下一题
   const nextQuestion = () => {
     if (!nextEnable.value) {
       return;
     }
-    // if (currentIndex.value < questionList.value.length - 1) {
-    //   currentIndex.value++;
-    //   setTimeout(() => {
-    //     subQuestionIndex.value = 0;
-    //   }, 300);
-    // }
-    if (currentQuestion.value.subQuestions && currentQuestion.value.subQuestions.length > 0) {
-      if (subQuestionIndex.value < currentQuestion.value.subQuestions.length - 1) {
-        subQuestionIndex.value++;
-      } else {
-        currentIndex.value++;
-        subQuestionIndex.value = 0;
-      }
+    if (currentQuestion.value.activeSubIndex < currentQuestion.value.subQuestions.length - 1) {
+      currentQuestion.value.activeSubIndex++;
     } else {
       currentIndex.value++;
     }
@@ -384,26 +378,15 @@ export const useExam = () => {
     if (!prevEnable.value) {
       return;
     }
-    if (currentIndex.value > 0) {
-      // currentIndex.value--;
-      // setTimeout(() => {
-      //   subQuestionIndex.value = 0;
-      // }, 300);
-    }
     if (currentQuestion.value.subQuestions && currentQuestion.value.subQuestions.length > 0) {
-      if (subQuestionIndex.value > 0) {
-        subQuestionIndex.value--;
+      if (currentQuestion.value.activeSubIndex > 0) {
+        currentQuestion.value.activeSubIndex--;
       } else {
         currentIndex.value--;
       }
     } else {
       if (currentIndex.value > 0) {
         currentIndex.value--;
-        // 如果上一个题是子题,那么,默认选中最后一个子题
-        const prevQuestion = questionList.value[currentIndex.value - 1];
-        if (prevQuestion && prevQuestion.subQuestions && prevQuestion.subQuestions.length > 0) {
-          subQuestionIndex.value = prevQuestion.subQuestions.length - 1;
-        }
       }
     }
   }
@@ -439,26 +422,72 @@ export const useExam = () => {
     setTimeout(() => {
       currentIndex.value = index;
       if (!subIndex !== undefined) {
-        subQuestionIndex.value = subIndex || 0;
+        if (subIndex !== undefined) {
+          questionList.value[index].activeSubIndex = subIndex || 0;
+        }
       }
       setTimeout(() => {
         swiperDuration.value = 300;
       }, 0);
     }, 0);
   }
-  const changeSubIndex = (index: number) => {
-    subQuestionIndex.value = index;
-  }
   // 开始计时
   const startTiming = () => {
-    interval = setInterval(() => {
-      practiceDuration.value += 1;
-    }, 1000);
+    startCount();
+    
+    // 记录开始时间戳(毫秒)
+    if (practiceStartTime === 0) {
+      practiceStartTime = performance.now();
+    }
+    
+    // 使用 requestAnimationFrame 更新显示,更流畅且性能更好
+    const updatePracticeDuration = () => {
+      if (practiceStartTime > 0) {
+        // 计算实际经过的时间(秒)
+        const elapsed = (performance.now() - practiceStartTime) / 1000;
+        practiceDuration.value = Math.floor(practiceAccumulatedTime + elapsed);
+        // 继续下一帧更新
+        animationFrameId = requestAnimationFrame(updatePracticeDuration);
+      }
+    };
+    
+    // 开始动画帧循环
+    animationFrameId = requestAnimationFrame(updatePracticeDuration);
   }
   // 停止计时
   const stopTiming = () => {
-    interval && clearInterval(interval);
-    interval = null;
+    stopCount();
+    
+    // 取消动画帧
+    if (animationFrameId !== null) {
+      cancelAnimationFrame(animationFrameId);
+      animationFrameId = null;
+    }
+    
+    // 如果正在计时,累加经过的时间
+    if (practiceStartTime > 0) {
+      const elapsed = (performance.now() - practiceStartTime) / 1000;
+      practiceAccumulatedTime += elapsed;
+      practiceDuration.value = Math.floor(practiceAccumulatedTime);
+      practiceStartTime = 0;
+    }
+  }
+  const startCount = () => {
+    if (countStart === 0) {
+      countStart = performance.now();
+    }
+  }
+  const stopCount = () => {
+    // 如果当前没有在计时(countStart 为 0),说明已经 stop 过了,直接返回累计时长,避免重复累加
+    if (countStart === 0) {
+      return countTime;
+    }
+    countTime += (performance.now() - countStart);
+    countStart = 0;
+    return countTime;
+  }
+  const clearCount = () => {
+    countTime = 0;
   }
   // 开始倒计时
   const startCountdown = () => {
@@ -482,6 +511,8 @@ export const useExam = () => {
   }
   const setPracticeDuration = (duration: number) => {
     practiceDuration.value = duration;
+    practiceAccumulatedTime = duration;
+    practiceStartTime = 0;
   }
   const setCountDownCallback = (callback: () => void) => {
     countDownCallback.value = callback;
@@ -489,6 +520,8 @@ export const useExam = () => {
   const setDuration = (duration: number) => {
     examDuration.value = duration;
     practiceDuration.value = duration;
+    practiceAccumulatedTime = duration;
+    practiceStartTime = 0;
   }
   /// 整理题目结构
   const transerQuestions = (arr: Study.Question[]) => {
@@ -550,7 +583,10 @@ export const useExam = () => {
         totalScore: item.totalScore || 0,
         offset: 0,
         index: index,
-        virtualIndex: 0
+        virtualIndex: 0,
+        duration: 0,
+        activeSubIndex: 0,
+        hasSubQuestions: item.subQuestions?.length > 0
       } as Study.Question
     }
     questionList.value = transerQuestions(list.map((item, index) => transerQuestion(item, index)));
@@ -587,31 +623,33 @@ export const useExam = () => {
         })
       }
     });
-    console.log(questionList.value)
     changeIndex(0);
     practiceDuration.value = 0;
+    practiceAccumulatedTime = 0;
+    practiceStartTime = 0;
     examDuration.value = 0;
     interval && clearInterval(interval);
     interval = null;
+    if (animationFrameId !== null) {
+      cancelAnimationFrame(animationFrameId);
+      animationFrameId = null;
+    }
   }
   /// 设置子题下标
   const setSubQuestionIndex = (index: number) => {
-    subQuestionIndex.value = index;
+    currentQuestion.value.activeSubIndex = index;
   }
   // 切换阅卷模式
   const setPracticeSettings = (settings: Study.PracticeSettings) => {
     practiceSettings.value = settings;
   }
-  // watch(() => currentIndex.value, (val) => {
-  //   subQuestionIndex.value = 0;
-  // }, {
-  //   immediate: false
-  // });
-  watch([() => currentIndex.value, () => subQuestionIndex.value], (val) => {
-    console.log('currentIndex.value', currentIndex.value)
-    console.log('subQuestionIndex.value', subQuestionIndex.value)
+  const submit = () => {
+    updateQuestionDuration(virtualCurrentIndex.value, false);
+  }
+  watch([() => currentIndex.value, () => currentQuestion.value?.activeSubIndex], (val) => {
     const qs = questionList.value[val[0]];
     virtualCurrentIndex.value = qs.index + qs.offset + val[1];
+    console.log(virtualCurrentIndex.value, 777)
   }, {
     immediate: false
   });
@@ -657,6 +695,7 @@ export const useExam = () => {
     setQuestionList,
     changeIndex,
     reset,
+    submit,
     isQuestionCorrect,
     isOptionCorrect,
     setPracticeSettings,

+ 1 - 1
src/pagesStudy/pages/exam-start/components/exam-stats-card.vue

@@ -89,7 +89,7 @@ const props = defineProps<{
   readonly?: boolean;
 }>();
 const examData = inject(EXAM_DATA) || {} as ReturnType<typeof useExam>;
-const { doneCount, virtualTotalCount, groupedQuestionList, questionTypeDesc, reset, startTiming, stopTiming, changeIndex, setSubQuestionIndex } = examData;
+const { doneCount, virtualTotalCount, groupedQuestionList, questionTypeDesc, reset, startTiming, stopTiming, changeIndex } = examData;
 const examPageOptions = inject(EXAM_PAGE_OPTIONS) || {} as Transfer.ExamAnalysisPageOptions;
 const isViewMode = computed(() => {
   return examPageOptions?.readonly || false;

+ 14 - 12
src/pagesStudy/pages/exam-start/components/question-item.vue

@@ -1,22 +1,24 @@
 <template>
   <view class="question-item">
     <question-title :question="question" />
-    <question-options :question="question" />
-    <question-result :question="question" />
-    <question-parse :question="question" />
-    <view v-if="question.subQuestions.length">
-      <scroll-view class="w-full h-fit sticky top-0 bg-white py-10 z-1" scroll-x :scroll-into-view="scrollIntoView"
+    <template v-if="!question.hasSubQuestions">
+      <question-options :question="question" />
+      <question-result :question="question" />
+      <question-parse :question="question" />
+    </template>
+    <view v-else class="mt-20">
+      <scroll-view class="w-full h-fit sticky top-0 py-10 z-1" scroll-x :scroll-into-view="scrollIntoView"
         :scroll-left="scrollLeft">
         <view class="flex items-center px-20 gap-x-20">
-          <view class="px-40 py-8 rounded-full" :id="`sub_question_${getNo(subIndex)}`"
-            :class="[subIndex === subQuestionIndex ? 'bg-[#EBF9FF] text-primary font-bold' : 'bg-back']"
-            v-for="(subQuestion, subIndex) in question.subQuestions" @click="setSubQuestionIndex(subIndex)">
-            {{ getNo(subIndex) }}
+          <view class="px-40 py-8 rounded-full" :id="`sub_question_${getNo(index)}`"
+            :class="[index === question.activeSubIndex ? 'bg-[#EBF9FF] text-primary font-bold' : 'bg-back']"
+            v-for="(subQuestion, index) in question.subQuestions" @click="setSubQuestionIndex(index)">
+            {{ getNo(index) }}
           </view>
         </view>
       </scroll-view>
-      <view v-if="currentSubQuestion" class="mt-20">
-        <question-item :question="currentSubQuestion" />
+      <view v-if="question.subQuestions[question.activeSubIndex]" class="mt-20">
+        <question-item :question="question.subQuestions[question.activeSubIndex]" />
       </view>
     </view>
   </view>
@@ -33,7 +35,7 @@ import { EXAM_DATA, EXAM_PAGE_OPTIONS, EXAM_AUTO_SUBMIT } from '@/types/injectio
 
 const examPageOptions = inject(EXAM_PAGE_OPTIONS) || {} as Transfer.ExamAnalysisPageOptions;
 const examData = inject(EXAM_DATA) || {} as ReturnType<typeof useExam>;
-const { subQuestionIndex, nextQuestion, isAllDone, currentSubQuestion, setSubQuestionIndex } = examData;
+const { subQuestionIndex, setSubQuestionIndex } = examData;
 const examAutoSubmit = inject(EXAM_AUTO_SUBMIT);
 
 const scrollIntoView = ref('')

+ 15 - 7
src/pagesStudy/pages/exam-start/components/question-options.vue

@@ -78,7 +78,7 @@ const isMultipleChoice = computed(() => {
 });
 
 const showParseTip = computed(() => {
-  return !isReadOnly.value && isOnlySubjective.value && !(props.question.subQuestions && props.question.subQuestions.length > 0);
+  return !isReadOnly.value && isOnlySubjective.value;
 });
 
 const getStyleClass = (option: Study.QuestionOption) => {
@@ -127,25 +127,33 @@ const handleNotKnow = () => {
 }
 
 const handleNext = () => {
-  // 如果是正常的练习,默认下一题,如果是背题模式,需要根据是否自动下一题来决定
   console.log('handleNext')
+  // 如果已经完成所有题目,则自动提交
+  if (isAllDone.value) {
+    examAutoSubmit?.();
+    return;
+  }
+  // 如果是正常的练习,默认下一题,如果是背题模式,需要根据是否自动下一题来决定
   if (practiceSettings.value.reviewMode === EnumReviewMode.DURING_ANSWER) {
     console.log(1)
     if (practiceSettings.value.autoNext) {
       console.log(2)
       if (props.question.typeId !== EnumQuestionType.MULTIPLE_CHOICE) {
         console.log(3)
-        nextQuestion();
+        changeNextQuestion();
       }
     }
   } else {
-    nextQuestion();
-  }
-  if (isAllDone.value) {
-    examAutoSubmit?.();
+    changeNextQuestion();
   }
 }
 
+const changeNextQuestion = () => {
+  setTimeout(() => {
+    nextQuestion();
+  }, 250);
+}
+
 const handleSelect = async (option: Study.QuestionOption) => {
   if (isReadOnly.value) {
     return;

+ 0 - 2
src/pagesStudy/pages/exam-start/components/question-parse.vue

@@ -4,13 +4,11 @@
     <view v-if="isOnlySubjective" class="mb-20">
       <view class="text-30 text-fore-title font-bold">答案</view>
       <view class="mt-10 text-26 text-fore-light">
-        <!-- <uv-parse :content="question.answer2 || '略'"></uv-parse> -->
         <mp-html :content="decodeHtmlEntities(question.answer2 || '略')" />
       </view>
     </view>
     <view class="text-30 text-fore-title font-bold">解析</view>
     <view class="mt-10 text-26 text-fore-light">
-      <!-- <uv-parse :content="question.parse || '暂无解析'"></uv-parse> -->
       <mp-html :content="decodeHtmlEntities(question.parse || '暂无解析')" />
     </view>
   </view>

+ 5 - 4
src/pagesStudy/pages/exam-start/exam-start.vue

@@ -37,9 +37,9 @@ const userStore = useUserStore();
 // import { Examinee, ExamPaper, ExamPaperSubmit } from '@/types/study';
 const { prevData, transferBack, transferTo } = useTransferPage<Transfer.ExamAnalysisPageOptions, {}>();
 const examData = useExam();
-const { setQuestionList, questionList, flatQuestionList, setPracticeSettings, setSubQuestionIndex,
-  notDoneCount, isAllDone, nextQuestion, prevQuestion, nextQuestionQuickly, prevQuestionQuickly,
-  practiceDuration, startTiming, stopTiming, changeIndex, setDuration } = examData;
+const { setQuestionList, questionList, flatQuestionList, setSubQuestionIndex,
+  notDoneCount, isAllDone,
+  practiceDuration, startTiming, stopTiming, submit, changeIndex, setDuration } = examData;
 
 //
 const showSwiperTip = ref(false);
@@ -159,7 +159,8 @@ const beforeSubmit = () => {
  * @param tempSave 是否临时保存
  */
 const handleSubmit = (tempSave: boolean = false) => {
-  console.log('handleSubmit', questionList.value)
+  // 执行完后续逻辑
+  submit();
   const msg = tempSave ? '保存中...' : '提交中...';
   uni.$ie.showLoading(msg);
   setTimeout(() => {

+ 1 - 1
src/pagesStudy/pages/knowledge-practice-detail/knowledge-practice-detail.vue

@@ -50,7 +50,7 @@ const { prevData, transferTo } = useTransferPage<Transfer.PracticeResultPageOpti
 const rightRate = computed(() => {
   const { totalCount = 0, wrongCount = 0 } = examineeData.value || {};
   const rate = (totalCount - wrongCount) / totalCount * 100;
-  return rate < 0.1 ? 0.1 : Number(rate.toFixed(1));
+  return Number(rate.toFixed(1));
 });
 const paperName = computed(() => {
   return '知识点练习-' + prevData.value.name;

+ 26 - 16
src/pagesStudy/pages/knowledge-practice/knowledge-practice.vue

@@ -1,17 +1,21 @@
 <template>
-  <ie-page ref="iePageRef">
-    <ie-navbar :title="pageTitle" />
-    <uv-tabs :list="subjectList" key-name="subjectName" @click="handleChangeTab" :scrollable="true"></uv-tabs>
-    <view class="px-30 py-16 bg-back">
-      <view class="flex items-center justify-end gap-x-4" @click="handleViewHistory">
-        <uv-icon name="clock" size="16" color="#31A0FC"></uv-icon>
-        <text class="text-28 text-primary">查看记录</text>
-        <uv-icon name="arrow-right" size="16" color="#31A0FC"></uv-icon>
+  <ie-page>
+    <z-paging ref="pagingRef" v-model="treeData" :loading-more-enabled="false" :auto="false" @query="loadKnowledgeList">
+      <template #top>
+        <ie-navbar :title="pageTitle" />
+        <uv-tabs :list="subjectList" key-name="subjectName" @click="handleChangeTab" :scrollable="true"></uv-tabs>
+        <view class="px-30 py-16 bg-back">
+          <view class="flex items-center justify-end gap-x-4" @click="handleViewHistory">
+            <uv-icon name="clock" size="16" color="#31A0FC"></uv-icon>
+            <text class="text-28 text-primary">查看记录</text>
+            <uv-icon name="arrow-right" size="16" color="#31A0FC"></uv-icon>
+          </view>
+        </view>
+      </template>
+      <view class="px-40">
+        <knowledgeTree :tree-data="treeData" @start-practice="handleStartPractice" />
       </view>
-    </view>
-    <view class="px-40">
-      <knowledgeTree :tree-data="treeData" @start-practice="handleStartPractice" />
-    </view>
+    </z-paging>
   </ie-page>
 </template>
 
@@ -28,7 +32,8 @@ import { useAuth } from '@/hooks/useAuth';
 const { prevData, transferTo } = useTransferPage();
 const currentSubjectIndex = ref<number>(-1);
 const userStore = useUserStore();
-const iePageRef = ref<InstanceType<typeof IePage>>();
+const pagingRef = ref();
+
 const { hasPermission } = useAuth();
 const pageTitle = computed(() => {
   if (prevData.value.directed) {
@@ -65,15 +70,16 @@ const loadKnowledgeList = async () => {
       directed: prevData.value.directed
     });
     treeData.value = data as Study.KnowledgeNode[];
+    pagingRef.value.complete(data, data.length);
   } catch (error) {
     console.log(error);
+    pagingRef.value.complete(false);
   } finally {
     uni.$ie.hideLoading();
   }
 }
 
 const handleStartPractice = async (node: Study.KnowledgeNode) => {
-  // const isVip = await userStore.checkVip();
   const hasAuth = hasPermission([EnumUserRole.VIP, EnumUserRole.AGENT, EnumUserRole.TEACHER]);
   if (hasAuth) {
     transferTo('/pagesStudy/pages/exam-start/exam-start', {
@@ -91,7 +97,7 @@ const handleStartPractice = async (node: Study.KnowledgeNode) => {
 }
 
 watch(() => currentSubjectIndex.value, () => {
-  loadKnowledgeList();
+  pagingRef.value.reload();
 }, {
   immediate: false
 });
@@ -109,7 +115,11 @@ onLoad(() => {
   loadData();
 });
 onShow(() => {
-  loadKnowledgeList();
+  nextTick(() => {
+    if (subjectList.value.length > 0) {
+      pagingRef.value.refresh();
+    }
+  });
 });
 </script>
 

+ 1 - 1
src/pagesStudy/pages/study-history/components/knowledge-history-student.vue

@@ -15,7 +15,7 @@ const loadData = async () => {
       const rate = item.rate;
       return {
         ...item,
-        rate: rate < 0.1 ? 0.1 : Number(rate.toFixed(1))
+        rate: Number(rate.toFixed(1))
       }
     });
   } finally {

+ 1 - 1
src/static/theme/var.scss

@@ -14,7 +14,7 @@ $fore-placeholder: #B3B3B3;
 
 $border: #E6E6E6;
 
-$back: #f6f6f6;
+$back: #F6F8FA;
 $back-light: #f7f8fa;
 
 $warning: #f9ae3d;

+ 10 - 5
src/types/study.ts

@@ -224,16 +224,21 @@ export interface Question extends QuestionState {
   subQuestions: Question[];
   totalScore: number;
   //
-  index: number; // 索引
-  offset: number; // 偏移量
+  index: number; // 当前题目在原始数组中的索引,如果是子题,则index和父题的 index 一致
+  offset: number; // 前面所有子题数量的偏移量
   isSubQuestion?: boolean; // 是否是子题
   //
-  parentIndex?: number; // 父题索引
+  parentIndex?: number; // 子题中父题在原始数组中的索引
   parentId?: number; // 父题ID
   parentTypeId?: number; // 父题类型
-  subIndex?: number; // 子题索引
+  subIndex?: number; // 子题在父题中的索引
   //
-  virtualIndex: number; // 虚拟索引
+  virtualIndex: number; // 在平铺数组中的虚拟索引
+  activeSubIndex: number; // 父题中正在展示的子题索引
+  // 单题做题时间
+  duration: number;
+  // 是否有子题
+  hasSubQuestions: boolean;
 }
 
 export interface SubjectListRequestDTO {

+ 1 - 1
src/uni_modules/z-paging/components/z-paging/config/index.js

@@ -3,5 +3,5 @@
 export default {
     autoShowSystemLoading: false, // 首次触发query时,是否显示uni.showLoading
     autoShowBackToTop: true, // 自动显示回到顶部按钮
-    bgColor: 'var(--bg-color)'
+    bgColor: '#ffffff'
 }