Sfoglia il codice sorgente

添加刷题页面

shmily1213 1 mese fa
parent
commit
25875efbe2
42 ha cambiato i file con 1384 aggiunte e 85 eliminazioni
  1. 8 3
      src/api/flyio.ts
  2. 8 0
      src/api/modules/login.ts
  3. 30 2
      src/api/modules/study.ts
  4. 1 1
      src/api/webApi/webVideo.js
  5. 37 0
      src/common/enum.ts
  6. 1 1
      src/common/mxConst.js
  7. 24 12
      src/components/ie-picker/ie-picker.vue
  8. 13 4
      src/components/ie-sms/ie-captcha.vue
  9. 12 0
      src/components/ie-sms/ie-sms.vue
  10. 294 0
      src/composables/useExam.ts
  11. 18 8
      src/composables/useSchool.ts
  12. 9 16
      src/hooks/useValidation.ts
  13. 20 1
      src/pages.json
  14. 1 1
      src/pagesMain/pages/me/components/me-menu.vue
  15. 2 1
      src/pagesOther/pages/video-center/index/index.vue
  16. 16 10
      src/pagesStudy/components/knowledge-tree-node.vue
  17. 15 11
      src/pagesStudy/components/knowledge-tree.vue
  18. 1 1
      src/pagesStudy/pages/index/compoentns/index-menu.vue
  19. 8 2
      src/pagesStudy/pages/index/index.vue
  20. 79 0
      src/pagesStudy/pages/knowledge-practice/knowledge-practice.vue
  21. 117 0
      src/pagesStudy/pages/start-exam/components/question-item.vue
  22. 92 0
      src/pagesStudy/pages/start-exam/components/question-stats-popup.vue
  23. 9 0
      src/pagesStudy/pages/start-exam/components/sub-question-item.vue
  24. 294 0
      src/pagesStudy/pages/start-exam/start-exam.vue
  25. 1 1
      src/pagesStudy/pages/targeted-practice/targeted-practice.vue
  26. BIN
      src/pagesStudy/static/image/icon-mark-active.png
  27. BIN
      src/pagesStudy/static/image/icon-mark.png
  28. 1 1
      src/pagesSystem/components/content-card.vue
  29. 3 2
      src/pagesSystem/pages/bind-profile/bind-profile.vue
  30. 0 0
      src/pagesSystem/pages/bind-profile/components/exam-info copy 2.vue
  31. 0 0
      src/pagesSystem/pages/bind-profile/components/exam-info copy 3.vue
  32. 0 0
      src/pagesSystem/pages/bind-profile/components/exam-info copy.vue
  33. 0 0
      src/pagesSystem/pages/bind-profile/components/exam-info.vue
  34. 2 2
      src/pagesSystem/pages/bind-profile/user-profile copy.vue
  35. 1 1
      src/pagesSystem/pages/card-verify/card-verify.vue
  36. 205 0
      src/pagesSystem/pages/edit-profile/edit-profile.vue
  37. 2 3
      src/pagesSystem/pages/login/login.vue
  38. 6 1
      src/pagesSystem/pages/phone-verify/phone-verify.vue
  39. BIN
      src/pagesSystem/static/image/icon/icon-lock.png
  40. 9 0
      src/types/injectionSymbols.ts
  41. 43 0
      src/types/study.ts
  42. 2 0
      src/types/user.ts

+ 8 - 3
src/api/flyio.ts

@@ -2,9 +2,6 @@
 import Fly from "flyio/dist/npm/wx"
 import config from "@/config";
 import { useUserStore } from '@/store/userStore';
-import { storeToRefs } from 'pinia';
-import { ApiResponse } from "@/types";
-
 const { serverBaseUrl } = config;
 
 const requestConfig = {
@@ -27,6 +24,14 @@ fly.interceptors.request.use((request: any) => {
   if (token) {
     request.headers['Authorization'] = 'Bearer ' + token;
   }
+  const examType = userStore.getExamType;
+  const location = userStore.getLocation;
+  if (examType) {
+    request.headers['examType'] = examType;
+  }
+  if (location) {
+    request.headers['location'] = encodeURIComponent(location);
+  }
   return request;
 });
 fly.interceptors.response.use(

+ 8 - 0
src/api/modules/login.ts

@@ -37,3 +37,11 @@ export function improve(params: BindCardInfo) {
 export function getUserInfo() {
   return flyio.get('/front/user/getInfo') as Promise<ApiResponse<UserInfo>>;
 }
+
+/**
+ * 更新用户信息
+ * @returns 用户信息
+ */
+export function updateUserInfo(params: UserInfo) {
+  return flyio.put('/front/user/userInfo', params) as Promise<ApiResponse<UserInfo>>;
+}

+ 30 - 2
src/api/modules/study.ts

@@ -1,6 +1,6 @@
 import { ApiResponse } from "@/types";
 import flyio from "../flyio";
-import { StudyPlan } from "@/types/study";
+import { Knowledge, StudyPlan, Subject } from "@/types/study";
 
 /**
  * 获取学习计划
@@ -25,7 +25,7 @@ export function saveStudyPlan(params: StudyPlan) {
  * @param params 
  * @returns 
  */
-export function getStudyPlanStats(params :any) {
+export function getStudyPlanStats(params: any) {
   return flyio.get('/front/student/plan/stats', params) as Promise<ApiResponse<any>>;
 }
 
@@ -48,3 +48,31 @@ export function getDirectedSchool() {
 export function saveDirectedSchool(params: any) {
   return flyio.post('/front/student/directed/school', params) as Promise<ApiResponse<any>>;
 }
+
+
+/**
+ * 获取科目列表
+ * @param params 
+ * @returns 
+ */
+export function getSubjectList(params: any) {
+  return flyio.get('/front/paper/subject', params) as Promise<ApiResponse<Subject[]>>;
+}
+
+/**
+ * 获取科目下的知识点
+ * @param params 
+ * @returns 
+ */
+export function getKnowledgeList(params: { subjectId: number }) {
+  return flyio.get('/front/paper/knownledge', params) as Promise<ApiResponse<Knowledge[]>>;
+}
+
+/**
+ * 获取试卷
+ * @param params 
+ * @returns 
+ */
+export function getOpenExaminee(params: { paperType: string, relateId: number }) {
+  return flyio.get('/front/exam/openExaminee', params) as Promise<ApiResponse<any>>;
+}

+ 1 - 1
src/api/webApi/webVideo.js

@@ -102,7 +102,7 @@ export function videoInfoTree(query) {
 // 获取视频播放信息 
 export function getVideoPlayInfo(query) {
     return request({
-        url: '/common/vod/getVideoPlayInfo',
+        url: '/front/comm/vod/getVideoPlayInfo',
         method: 'get',
         params: query
     })

+ 37 - 0
src/common/enum.ts

@@ -72,4 +72,41 @@ export enum STATIC_PAGE_PATH {
    * 绑定手机号
    */
   BIND_PHONE = '/pagesSystem/pages/bind-phone/bind-phone'
+}
+
+export enum EnumExamMode {
+  /**
+   * 练习
+   */
+  PRACTICE = 1,
+  /**
+   * 考试
+   */
+  EXAM = 2
+}
+
+/**
+ * 题目类型
+ */
+export enum EnumQuestionType {
+  /**
+   * 单选
+   */
+  SINGLE_CHOICE = 1,
+  /**
+   * 多选
+   */
+  MULTIPLE_CHOICE = 2,
+  /**
+   * 判断
+   */
+  JUDGMENT = 3,
+  /**
+   * 填空
+   */
+  FILL_IN_THE_BLANK = 4,
+  /**
+   * 简答
+   */
+  SHORT_ANSWER = 5
 }

+ 1 - 1
src/common/mxConst.js

@@ -27,7 +27,7 @@ const consts = {
         newsIndex: '/pages/news/index/index',
         newsDetail: '/pagesOther/pages/news/detail/detail',
         newsGroup: '/pagesOther/pages/news/group/group',
-        videoPlay: '/pages/video-center/play/play'
+        videoPlay: '/pagesOther/pages/video-center/play/play'
     },
     globalEvents: {
         paperCompleted: 'globalEvents-paperCompleted',

+ 24 - 12
src/components/ie-picker/ie-picker.vue

@@ -13,18 +13,30 @@
       </view>
     </view>
   </view>
-  <root-portal>
-    <uv-picker ref="pickerRef" :showToolbar="false" :columns="columns" :defaultIndex="defaultIndex" :round="16"
-      activeColor="#31A0FC" :keyName="keyLabel" :title="title" @change="handleChange" @close="onClose">
-      <template #toolbar>
-        <view class="flex items-center justify-between pt-20">
-          <view class="px-46 py-20 text-28 text-fore-light" @click="handleCancel">取消</view>
-          <text class="text-30 text-fore-title font-bold">{{ title }}</text>
-          <view class="px-46 py-20 text-28 text-fore-title" @click="handleConfirm">确认</view>
-        </view>
-      </template>
-    </uv-picker>
-  </root-portal>
+  <!-- #ifdef H5 -->
+  <teleport to="body">
+    <!-- #endif -->
+    <!-- #ifdef MP-WEIXIN -->
+    <root-portal externalClass="theme-ie">
+      <!-- #endif -->
+      <uv-picker ref="pickerRef" :showToolbar="false" :columns="columns" :defaultIndex="defaultIndex" :round="16"
+        activeColor="#31A0FC" :keyName="keyLabel" :title="title" @change="handleChange" @close="onClose">
+        <template #toolbar>
+          <view class="theme-ie">
+            <view class="flex items-center justify-between pt-20">
+              <view class="px-46 py-20 text-28 text-fore-light" @click="handleCancel">取消</view>
+              <text class="text-30 text-fore-title font-bold">{{ title }}</text>
+              <view class="px-46 py-20 text-28 text-fore-title" @click="handleConfirm">确认</view>
+            </view>
+          </view>
+        </template>
+      </uv-picker>
+      <!-- #ifdef MP-WEIXIN -->
+    </root-portal>
+    <!-- #endif -->
+    <!-- #ifdef H5 -->
+  </teleport>
+  <!-- #endif -->
 </template>
 <script lang="ts" setup>
 

+ 13 - 4
src/components/ie-sms/ie-captcha.vue

@@ -1,14 +1,23 @@
 <template>
+  <!-- #ifdef H5 -->
+  <teleport to="body">
+  <!-- #endif -->
+  <!-- #ifdef MP-WEIXIN -->
   <root-portal externalClass="theme-ie">
+  <!-- #endif -->
     <uv-popup ref="popupRef" mode="bottom" :close-on-click-overlay="false" :closeable="true" :round="16">
-      <view class="w-auto mx-20 box-border px-40 pt-100 pb-60 bg-white">
+      <view class="theme-ie w-auto box-border px-40 pt-100 pb-60 bg-white">
         <ie-image :src="captchaImage" custom-class="w-fit h-104 mx-auto" mode="heightFix" @click="debounceGetImage" />
-        <ie-input v-model="codeModelValue" custom-class="mt-40" type="number" :maxlength="3"
-          placeholder="请输入验证码" />
+        <ie-input v-model="codeModelValue" custom-class="mt-40" type="number" :maxlength="3" placeholder="请输入验证码" />
         <ie-button custom-class="mt-40" @click="handleSubmit">确定</ie-button>
       </view>
     </uv-popup>
-  </root-portal>
+    <!-- #ifdef MP-WEIXIN -->
+    </root-portal>
+    <!-- #endif -->
+  <!-- #ifdef H5 -->
+  </teleport>
+  <!-- #endif -->
 </template>
 <script lang="ts" setup>
 import { getCaptchaImage } from '@/api/modules/system';

+ 12 - 0
src/components/ie-sms/ie-sms.vue

@@ -8,6 +8,7 @@ import { useSms } from '@/hooks/useSms';
 import IeCaptcha from './ie-captcha.vue';
 import { EnumSmsApiType, EnumSmsType } from '@/common/enum';
 import { SmsRequestDTO } from '@/types/user';
+import { validatePhone } from '@/hooks/useValidation';
 const sms = useSms();
 const { smsTip, isCountingDown, smsIsSending } = sms;
 const appConfig = useAppConfig();
@@ -31,6 +32,10 @@ const props = defineProps({
   customClass: {
     type: String,
     default: ''
+  },
+  beforeSend: {
+    type: Function,
+    default: () => true
   }
 });
 
@@ -75,10 +80,17 @@ const emit = defineEmits<{
 }>()
 
 const handleSend = async () => {
+  if (props.beforeSend && !props.beforeSend()) {
+    return;
+  }
   if (!props.phone) {
     uni.$ie.showToast('请输入手机号');
     return;
   }
+  if (!validatePhone(props.phone)) {
+    uni.$ie.showToast('请输入正确的手机号');
+    return;
+  }
   if (smsIsSending.value || isCountingDown.value) {
     return;
   }

+ 294 - 0
src/composables/useExam.ts

@@ -0,0 +1,294 @@
+import { EnumQuestionType } from "@/common/enum";
+import { Study } from "@/types";
+
+export const useExam = () => {
+  const questionTypeDesc: Record<EnumQuestionType, string> = {
+    [EnumQuestionType.SINGLE_CHOICE]: '单选题',
+    [EnumQuestionType.MULTIPLE_CHOICE]: '多选题',
+    [EnumQuestionType.JUDGMENT]: '判断题',
+    [EnumQuestionType.FILL_IN_THE_BLANK]: '填空题',
+    [EnumQuestionType.SHORT_ANSWER]: '简答题'
+  }
+  // 题型顺序
+  const questionTypeOrder = [
+    EnumQuestionType.SINGLE_CHOICE,
+    EnumQuestionType.MULTIPLE_CHOICE,
+    EnumQuestionType.JUDGMENT,
+    EnumQuestionType.FILL_IN_THE_BLANK,
+    EnumQuestionType.SHORT_ANSWER
+  ];
+  let interval: NodeJS.Timeout | null = null;
+  const countDownCallback = ref<() => void>(() => { });
+  // 练习时长
+  const practiceDuration = ref<number>(0);
+  const formatPracticeDuration = computed(() => {
+    const hours = Math.floor(practiceDuration.value / 3600);
+    const minutes = Math.floor((practiceDuration.value % 3600) / 60);
+    const seconds = practiceDuration.value % 60;
+    return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
+  });
+  // 考试时长
+  const examDuration = ref<number>(0);
+  const formatExamDuration = computed(() => {
+    const hours = Math.floor(examDuration.value / 3600);
+    const minutes = Math.floor((examDuration.value % 3600) / 60);
+    const seconds = examDuration.value % 60;
+    return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
+  });
+  const swiperDuration = ref<number>(300);
+  const questionList = ref<Study.Question[]>([]);
+  // 收藏列表
+  const favoriteList = ref<Study.Question[]>([]);
+  // 不会列表
+  const notKnowList = ref<Study.Question[]>([]);
+  // 重点标记列表
+  const markList = ref<Study.Question[]>([]);
+  // 包含状态的问题列表
+  const stateQuestionList = computed(() => {
+    // 状态:已做、未做、是否不会、是否标记,整体按照题型分组
+    const state = questionTypeOrder.map(type => {
+      return {
+        type,
+        list: [] as {
+          question: Study.Question;
+          index: number
+        }[]
+      }
+    });
+    for (let i = 0; i <= questionList.value.length - 1; i++) {
+      const qs = questionList.value[i];
+      state.forEach(item => {
+        if (qs.type === item.type) {
+          qs.isDone = isDone(qs);
+          item.list.push({
+            question: qs,
+            index: i
+          });
+        }
+      });
+    }
+    return state;
+  });
+  // 当前下标
+  const currentIndex = ref<number>(0);
+  const totalCount = computed(() => {
+    return questionList.value.length;
+  });
+  const doneCount = computed(() => {
+    // 有答案的或者不会做的,都认为是做了
+    return stateQuestionList.value.reduce((acc, item) => acc + item.list.filter(q => q.question.isDone || q.question.isNotKnow).length, 0);
+  });
+  const notDoneCount = computed(() => {
+    return questionList.value.length - doneCount.value;
+  });
+  const notKnowCount = computed(() => {
+    return stateQuestionList.value.reduce((acc, item) => acc + item.list.filter(q => q.question.isNotKnow).length, 0);
+  });
+  const markCount = computed(() => {
+    return stateQuestionList.value.reduce((acc, item) => acc + item.list.filter(q => q.question.isMark).length, 0);
+  });
+  const isDone = (qs: Study.Question): boolean => {
+    if (qs.subQuestions.length > 0) {
+      return qs.subQuestions.every(q => isDone(q));
+    }
+    return qs.answer.length > 0;
+  }
+  const nextQuestion = () => {
+    if (currentIndex.value >= questionList.value.length - 1) {
+      return;
+    }
+    currentIndex.value++;
+  }
+  const prevQuestion = () => {
+    if (currentIndex.value <= 0) {
+      return;
+    }
+    currentIndex.value--;
+  }
+  const nextQuestionQuickly = () => {
+    // currentIndex.value += 10;
+    if (currentIndex.value >= questionList.value.length - 1) {
+      return;
+    }
+    swiperDuration.value = 0;
+    setTimeout(() => {
+      nextQuestion();
+      setTimeout(() => {
+        swiperDuration.value = 300;
+      }, 0);
+    }, 0);
+  }
+  const prevQuestionQuickly = () => {
+    if (currentIndex.value <= 0) {
+      return;
+    }
+    swiperDuration.value = 0;
+    setTimeout(() => {
+      prevQuestion();
+      setTimeout(() => {
+        swiperDuration.value = 300;
+      }, 0);
+    }, 0);
+  }
+  // 开始计时
+  const startPracticeDuration = () => {
+    interval = setInterval(() => {
+      practiceDuration.value += 1;
+    }, 1000);
+  }
+  // 停止计时
+  const stopPracticeDuration = () => {
+    interval && clearInterval(interval);
+    interval = null;
+  }
+  // 开始倒计时
+  const startExamDuration = () => {
+    interval = setInterval(() => {
+      if (examDuration.value <= 0) {
+        console.log('停止倒计时')
+        stopExamDuration();
+        return;
+      }
+      examDuration.value -= 1;
+    }, 1000);
+  }
+  // 停止倒计时
+  const stopExamDuration = () => {
+    interval && clearInterval(interval);
+    interval = null;
+    countDownCallback.value && countDownCallback.value();
+  }
+  const setExamDuration = (duration: number) => {
+    examDuration.value = duration;
+  }
+  const setPracticeDuration = (duration: number) => {
+    practiceDuration.value = duration;
+  }
+  const setCountDownCallback = (callback: () => void) => {
+    countDownCallback.value = callback;
+  }
+  const loadExamData = async () => {
+    // const { data } = await getOpenExaminee({
+    //   paperType: prevData.value.paperType,
+    //   relateId: prevData.value.relateId
+    // });
+    // console.log(data)
+    questionList.value = [
+      {
+        id: 1,
+        name: '下列括号中测试字体长度这是一个长问题测试字的读音完全正确的一项是()',
+        type: EnumQuestionType.SINGLE_CHOICE,
+        options: [
+          {
+            id: 1,
+            no: 'A',
+            name: '选项1',
+            isAnswer: false
+          },
+          {
+            id: 2,
+            no: 'B',
+            name: '选项2',
+            isAnswer: false
+          },
+          {
+            id: 3,
+            no: 'C',
+            name: '选项3',
+            isAnswer: false
+          },
+          {
+            id: 4,
+            no: 'D',
+            name: '选项4',
+            isAnswer: false
+          }
+        ],
+        answer: [],
+        subQuestions: []
+      },
+      {
+        id: 2,
+        name: '题目2',
+        type: EnumQuestionType.MULTIPLE_CHOICE,
+        options: [],
+        answer: [],
+        subQuestions: [
+          {
+            id: 1,
+            name: '题目2-1',
+            type: EnumQuestionType.SINGLE_CHOICE,
+            options: [
+              {
+                id: 1,
+                no: 'A',
+                name: '选项1',
+                isAnswer: false
+              },
+              {
+                id: 2,
+                no: 'B',
+                name: '选项2',
+                isAnswer: false
+              },
+              {
+                id: 3,
+                no: 'C',
+                name: '选项3',
+                isAnswer: false
+              },
+              {
+                id: 4,
+                no: 'D',
+                name: '选项4',
+                isAnswer: false
+              }
+            ],
+            answer: [],
+            subQuestions: []
+          }
+        ]
+      },
+      {
+        id: 3,
+        name: '题目3',
+        type: EnumQuestionType.JUDGMENT,
+        options: [],
+        answer: [],
+        subQuestions: []
+      }
+    ];
+    // startPracticeDuration();
+  }
+  return {
+    questionList,
+    stateQuestionList,
+    favoriteList,
+    notKnowList,
+    markList,
+    currentIndex,
+    totalCount,
+    doneCount,
+    notDoneCount,
+    notKnowCount,
+    markCount,
+    loadExamData,
+    questionTypeDesc,
+    nextQuestion,
+    prevQuestion,
+    nextQuestionQuickly,
+    prevQuestionQuickly,
+    swiperDuration,
+    practiceDuration,
+    examDuration,
+    formatExamDuration,
+    formatPracticeDuration,
+    startPracticeDuration,
+    stopPracticeDuration,
+    startExamDuration,
+    stopExamDuration,
+    setExamDuration,
+    setPracticeDuration,
+    setCountDownCallback
+  }
+}

+ 18 - 8
src/composables/useSchool.ts

@@ -1,18 +1,28 @@
 import { ClassItem, SchoolItem } from "@/types/user";
 import { getClassList, getSchoolList } from "@/api/modules/user";
-const useSchool = () => {
-  const form = ref({
-    schoolName: '',
-    schoolId: undefined,
-    className: '',
-    classId: undefined,
-  });
+type SchoolForm = {
+  schoolName: string;
+  schoolId: number;
+  className: string;
+  classId: number;
+}
+export const useSchool = () => {
+  const form = ref<Partial<SchoolForm>>({});
   const schoolList = ref<SchoolItem[]>([]);
   const classList = ref<ClassItem[]>([]);
-
+  const loadClassData = (schoolId: number | undefined) => {
+    if (!schoolId) {
+      return;
+    }
+    form.value.schoolId = schoolId;
+    getClassList({ schoolId: form.value.schoolId }).then(res => {
+      classList.value = res.data;
+    });
+  }
   return {
     form,
     schoolList,
     classList,
+    loadClassData
   };
 }

+ 9 - 16
src/hooks/useValidation.ts

@@ -1,17 +1,10 @@
-export const useValidation = () => {
-  const validatePhone = (phone: string): boolean => {
-    // 简单的手机号验证规则,可根据实际需求调整
-    const phoneRegex = /^1[3-9]\d{9}$/;
-    return phoneRegex.test(phone);
-  };
-  const validateTelephone = (telephone: string): boolean => {
-    // 简单的电话验证规则,可根据实际需求调整
-    const phoneRegex = /^[48]00-?\d{4}-?\d{3}$/;
-    return phoneRegex.test(telephone);
-  }
-
-  return {
-    validatePhone,
-    validateTelephone
-  }
+export const validatePhone = (phone: string): boolean => {
+  // 简单的手机号验证规则,可根据实际需求调整
+  const phoneRegex = /^1[3-9]\d{9}$/;
+  return phoneRegex.test(phone);
+};
+export const validateTelephone = (telephone: string): boolean => {
+  // 简单的电话验证规则,可根据实际需求调整
+  const phoneRegex = /^[48]00-?\d{4}-?\d{3}$/;
+  return phoneRegex.test(telephone);
 }

+ 20 - 1
src/pages.json

@@ -513,7 +513,7 @@
           }
         },
         {
-          "path": "pages/user-profile/user-profile",
+          "path": "pages/bind-profile/bind-profile",
           "style": {
             "navigationBarTitleText": ""
           }
@@ -541,6 +541,13 @@
           "style": {
             "navigationBarTitleText": ""
           }
+        },
+        {
+          "path" : "/pages/edit-profile/edit-profile",
+          "style" : 
+          {
+            "navigationBarTitleText" : ""
+          }
         }
       ]
     },
@@ -559,6 +566,18 @@
             "navigationBarTitleText": ""
           }
         },
+        {
+          "path": "pages/knowledge-practice/knowledge-practice",
+          "style": {
+            "navigationBarTitleText": ""
+          }
+        },
+        {
+          "path": "pages/start-exam/start-exam",
+          "style": {
+            "navigationBarTitleText": ""
+          }
+        },
         {
           "path": "pages/study-plan-edit/study-plan-edit",
           "style": {

+ 1 - 1
src/pagesMain/pages/me/components/me-menu.vue

@@ -13,7 +13,7 @@
     <view class="-mt-10 rounded-8 py-20">
       <uv-cell-group :border="false">
         <uv-cell isLink :cellStyle="cellStyle"
-          @click="handleNavigate('/pagesOther/pages/personal-center/basic-info/basic-info', '基本资料')">
+          @click="handleNavigate('/pagesSystem/pages/edit-profile/edit-profile', '基本资料')">
           <template #title>
             <view class="flex items-center gap-x-10">
               <ie-image src="/static/personal/icon_jibenziliao@2x.png" custom-class="w-34 h-34" />

+ 2 - 1
src/pagesOther/pages/video-center/index/index.vue

@@ -1,6 +1,7 @@
 <template>
     <view class="page-content">
-        <mx-nav-bar title="视频课程"/>
+        <!-- <mx-nav-bar title="视频课程"/> -->
+         <ie-navbar title="视频课程"/>
         <mx-tabs-swiper :tabs="subjects" :key-name="keyName" template="video" border>
             <template #video="subject">
                 <video-page-layout :subject="subject"/>

+ 16 - 10
src/pagesStudy/components/knowledge-tree-node.vue

@@ -7,12 +7,13 @@
             :custom-class="['mr-16 transition-transform duration-300', nodeData.isExpanded ? 'rotate-90' : '']" />
           <view>
             <text class="block text-28 text-fore-title font-bold">{{ nodeData.name }}</text>
-            <text class="mt-4 block text-24 text-fore-light">共{{ nodeData.value }}道题</text>
+            <text class="mt-4 block text-24 text-fore-light">共{{ nodeData.questionCount || 0 }}道题</text>
           </view>
         </view>
         <slot>
           <view v-if="nodeData.isLeaf"
-            class="px-20 py-8 border border-solid border-primary rounded-full text-24 text-primary">开始练习</view>
+            class="px-20 py-8 border border-solid border-primary rounded-full text-24 text-primary"
+            @click.stop="handleStartPractice">开始练习</view>
         </slot>
       </view>
     </view>
@@ -22,7 +23,8 @@
       :class="['ml-40 overflow-hidden transition-all duration-300 h-0']"
       :style="{ height: nodeData.actualHeight + 'px' }">
       <knowledge-tree-node v-for="child in nodeData.children" :key="child.name" :node-data="child"
-        :parent-data="nodeData" @node-click="handleNodeClick" @update-height="handleUpdateHeight">
+        :parent-data="nodeData" @node-click="handleNodeClick" @update-height="handleUpdateHeight"
+        @start-practice="handleStartPractice">
         <template #default>
           <slot></slot>
         </template>
@@ -32,25 +34,25 @@
 </template>
 
 <script lang="ts" setup>
-import { TreeData } from '@/types';
+import * as Study from '@/types/study';
 
 // 定义 props
 const props = defineProps({
   nodeData: {
-    type: Object as PropType<TreeData>,
+    type: Object as PropType<Study.KnowledgeNode>,
     required: true
   },
   parentData: {
-    type: Object as PropType<TreeData>,
+    type: Object as PropType<Study.KnowledgeNode>,
     default: null
   }
 });
 
 // 定义 emits
-const emit = defineEmits(['nodeClick', 'updateHeight']);
+const emit = defineEmits(['nodeClick', 'updateHeight', 'startPractice']);
 
 // 计算子节点高度
-const calculateChildrenHeight = (node: TreeData): number => {
+const calculateChildrenHeight = (node: Study.KnowledgeNode): number => {
   if (!node.children || node.children.length === 0) {
     return 0;
   }
@@ -94,14 +96,18 @@ const handleClick = () => {
   }
 };
 
+// 处理开始练习事件
+const handleStartPractice = () => {
+  emit('startPractice', props.nodeData);
+};
 // 处理子节点点击事件
-const handleNodeClick = (eventData: { node: TreeData; parent: TreeData }) => {
+const handleNodeClick = (eventData: { node: Study.KnowledgeNode; parent: Study.KnowledgeNode }) => {
   // 向上传递点击事件
   emit('nodeClick', eventData);
 };
 
 // 处理高度更新事件
-const handleUpdateHeight = (parentNode: TreeData) => {
+const handleUpdateHeight = (parentNode: Study.KnowledgeNode) => {
 
   // 重新计算父节点高度
   if (parentNode.isExpanded) {

+ 15 - 11
src/pagesStudy/components/knowledge-tree.vue

@@ -1,7 +1,7 @@
 <template>
   <view class="knowledge-tree">
-    <knowledge-tree-node v-for="item in initializedData" :key="item.name" :node-data="item"
-      @node-click="handleNodeClick" @update-height="handleUpdateHeight">
+    <knowledge-tree-node v-for="item in initializedData" :key="item.id" :node-data="item"
+      @node-click="handleNodeClick" @update-height="handleUpdateHeight" @start-practice="handleStartPractice">
       <template #default>
         <slot></slot>
       </template>
@@ -9,25 +9,24 @@
   </view>
 </template>
 <script lang="ts" setup>
-import { TreeData } from '@/types';
+import * as Study from '@/types/study';
 import KnowledgeTreeNode from './knowledge-tree-node.vue';
-
 // 定义 emits
-const emit = defineEmits(['update:treeData', 'nodeClick']);
+const emit = defineEmits(['update:treeData', 'nodeClick', 'startPractice']);
 
 // 定义 props
 const props = defineProps({
   treeData: {
-    type: Array as PropType<TreeData[]>,
+    type: Array as PropType<Study.KnowledgeNode[]>,
     default: () => []
   }
 });
 
 // 初始化后的数据
-const initializedData = ref<TreeData[]>([]);
+const initializedData = ref<Study.KnowledgeNode[]>([]);
 
 // 确保单个项目的必要属性存在
-const ensureItemProperties = (item: TreeData) => {
+const ensureItemProperties = (item: Study.KnowledgeNode) => {
   if (item.isExpanded === undefined) {
     item.isExpanded = false;
   }
@@ -42,7 +41,7 @@ const ensureItemProperties = (item: TreeData) => {
 }
 
 // 初始化数据
-const initializeData = (sourceData: TreeData[]): TreeData[] => {
+const initializeData = (sourceData: Study.KnowledgeNode[]): Study.KnowledgeNode[] => {
   return sourceData.map(item => {
     const children = initializeData(item.children || []);
     return {
@@ -56,17 +55,22 @@ const initializeData = (sourceData: TreeData[]): TreeData[] => {
 }
 
 // 处理节点点击事件
-const handleNodeClick = (eventData: { node: TreeData; parent: TreeData | null }) => {
+const handleNodeClick = (eventData: { node: Study.KnowledgeNode; parent: Study.KnowledgeNode | null }) => {
   // 向上传递点击事件
   emit('nodeClick', eventData);
 };
 
 // 处理高度更新事件
-const handleUpdateHeight = (node: TreeData) => {
+const handleUpdateHeight = (node: Study.KnowledgeNode) => {
   // 通知父组件数据已更新
   emit('update:treeData', initializedData.value);
 };
 
+// 处理开始练习事件
+const handleStartPractice = (node: Study.KnowledgeNode) => {
+  emit('startPractice', node);
+};
+
 // 监听 props.treeData 变化,重新初始化数据
 watch(() => props.treeData, (newTreeData) => {
   initializedData.value = initializeData(newTreeData);

+ 1 - 1
src/pagesStudy/pages/index/compoentns/index-menu.vue

@@ -14,7 +14,7 @@ const menus = [
   {
     label: '课程学习',
     icon: '/menu/menu-course.png',
-    pageUrl: '/pagesStudy/course/index'
+    pageUrl: '/pagesOther/pages/video-center/index/index'
   },
   {
     label: '组卷作业',

+ 8 - 2
src/pagesStudy/pages/index/index.vue

@@ -19,7 +19,7 @@
             <view class="mt-8 text-24 text-fore-tip">根据知识点系统练习</view>
             <view
               class="mt-32 w-200 h-56 flex items-center justify-center rounded-full text-26 text-white bg-gradient-to-r from-[#91E0FE] to-[#16AFF5]"
-              @click="navigateTo('/pagesStudy/targeted-practice/targeted-practice')">
+              @click="handlePracticeAll">
               开始练习
             </view>
           </view>
@@ -66,7 +66,13 @@ const { transferTo } = useTransferPage();
 const navigateTo = (pageUrl: string) => {
   transferTo(pageUrl);
 }
-
+const handlePracticeAll = () => {
+  transferTo('/pagesStudy/pages/knowledge-practice/knowledge-practice', {
+    data: {
+      universityId: undefined
+    }
+  });
+}
 const handleSetting = () => {
   transferTo('/pagesStudy/pages/targeted-setting/targeted-setting');
 }

+ 79 - 0
src/pagesStudy/pages/knowledge-practice/knowledge-practice.vue

@@ -0,0 +1,79 @@
+<template>
+  <ie-page>
+    <ie-navbar title="刷题" />
+    <uv-tabs :list="subjectList" key-name="subjectName" @click="handleChangeTab" :scrollable="true"></uv-tabs>
+    <view class="h-16 bg-back"></view>
+    <view class="px-40">
+      <knowledgeTree :tree-data="treeData" @start-practice="handleStartPractice" />
+    </view>
+  </ie-page>
+</template>
+
+<script lang="ts" setup>
+import { useTransferPage } from '@/hooks/useTransferPage';
+import { getSubjectList, getKnowledgeList } from '@/api/modules/study';
+import knowledgeTree from '@/pagesStudy/components/knowledge-tree.vue';
+import * as Study from '@/types/study';
+import { EnumExamMode } from '@/common/enum';
+const { prevData, transferTo } = useTransferPage();
+const universityId = computed(() => prevData.value.universityId);
+const currentSubjectIndex = ref<number>(-1);
+const currentSubjectId = computed(() => {
+  if (subjectList.value.length > 0 && currentSubjectIndex.value >= 0) {
+    return subjectList.value[currentSubjectIndex.value].subjectId;
+  }
+  return null;
+});
+const subjectList = ref<Study.Subject[]>([]);
+const treeData = ref<Study.KnowledgeNode[]>([]);
+const handleChangeTab = (item: any) => {
+  console.log(item)
+  currentSubjectIndex.value = item.index;
+}
+
+const loadKnowledgeList = async () => {
+  if (!currentSubjectId.value) {
+    return;
+  }
+  try {
+    const { data } = await getKnowledgeList({
+      subjectId: currentSubjectId.value
+    });
+    treeData.value = data as Study.KnowledgeNode[];
+  } catch (error) {
+    console.log(error);
+  }
+}
+
+const handleStartPractice = (node: Study.KnowledgeNode) => {
+  transferTo('/pagesStudy/pages/start-exam/start-exam', {
+    data: {
+      name: node.name,
+      mode: EnumExamMode.PRACTICE,
+      paperType: 'Practice',
+      relateId: node.id
+    }
+  });
+}
+
+watch(() => currentSubjectIndex.value, () => {
+  loadKnowledgeList();
+}, {
+  immediate: true
+});
+
+const loadData = async () => {
+  try {
+    const { data } = await getSubjectList({
+      universityId: universityId.value
+    });
+    subjectList.value = data.map(item => ({ subjectId: item.subjectId, subjectName: item.subjectName }));
+    currentSubjectIndex.value = 0;
+  } catch (error) { }
+}
+onLoad(() => {
+  loadData();
+});
+</script>
+
+<style></style>

+ 117 - 0
src/pagesStudy/pages/start-exam/components/question-item.vue

@@ -0,0 +1,117 @@
+<template>
+  <view class="question-item">
+    <view class="question-type">{{ questionTypeDesc[question.type as EnumQuestionType] }}</view>
+    <view class="question-content">{{ question.name }}</view>
+    <view class="question-options">
+      <view class="question-option" v-for="option in question.options" :class="{ 'question-option-selected': isSelected(option) }"
+        :key="option.id" @click="handleSelect(option)">
+        <view class="question-option-index">{{ option.no }}</view>
+        <view class="question-option-content">{{ option.name }}</view>
+      </view>
+      <view class="question-option" :class="{ 'question-option-not-know': question.isNotKnow }" @click="handleNotKnow">
+        <view class="question-option-index">
+          <uv-icon name="info-circle" :color="question.isNotKnow ? '#31A0FC' : '#999'" size="18" />
+        </view>
+        <view class="question-option-content text-fore-light">不会</view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { Study } from '@/types';
+import { useExam } from '@/composables/useExam';
+import { EnumQuestionType } from '@/common/enum';
+import { NEXT_QUESTION, PREV_QUESTION, NEXT_QUESTION_QUICKLY, PREV_QUESTION_QUICKLY } from '@/types/injectionSymbols';
+const { questionTypeDesc } = useExam();
+const props = defineProps<{
+  question: Study.Question;
+}>();
+const nextQuestion = inject(NEXT_QUESTION);
+const prevQuestion = inject(PREV_QUESTION);
+const nextQuestionQuickly = inject(NEXT_QUESTION_QUICKLY);
+const prevQuestionQuickly = inject(PREV_QUESTION_QUICKLY);
+const handleNotKnow = () => {
+  console.log('handleNotKnow')
+  props.question.isNotKnow = true;
+  nextQuestion?.();
+}
+const handleSelect = (option: Study.QuestionOption) => {
+  console.log('handleSelect', option)
+  if (props.question.type === EnumQuestionType.SINGLE_CHOICE) {
+    props.question.answer.push(option.no);
+    nextQuestion?.();
+  } else if (props.question.type === EnumQuestionType.MULTIPLE_CHOICE) {
+    if (props.question.answer.includes(option.no)) {
+      props.question.answer = props.question.answer.filter(item => item !== option.no);
+    } else {
+      props.question.answer.push(option.no);
+    }
+  } else if (props.question.type === EnumQuestionType.JUDGMENT) {
+    props.question.answer.push(option.no);
+  }
+  // props.question.answer = option.no;
+}
+const isSelected = (option: Study.QuestionOption) => {
+  const { type, answer } = props.question;
+  if (type === EnumQuestionType.SINGLE_CHOICE) {
+    return answer.includes(option.no);
+  } else if (type === EnumQuestionType.MULTIPLE_CHOICE) {
+    return answer.includes(option.no);
+  } else if (type === EnumQuestionType.JUDGMENT) {
+    return answer.includes(option.no);
+  }
+  return false;
+}
+</script>
+
+<style lang="scss" scoped>
+.question-item {
+  @apply h-full pt-20;
+
+  .question-type {
+    @apply px-40 my-20 text-32 text-fore-subtitle font-bold;
+  }
+
+  .question-content {
+    @apply px-40 mt-20 text-32 text-fore-title;
+  }
+
+  .question-options {
+    @apply mt-40 px-40;
+
+    .question-option {
+      @apply flex items-center px-30 py-30 bg-back rounded-10;
+
+      .question-option-index {
+        @apply w-40 h-40 rounded-full bg-transparent text-30 text-fore-light font-bold flex items-center justify-center;
+      }
+
+      .question-option-content {
+        @apply text-30 text-fore-title ml-20;
+      }
+    }
+    .question-option-selected {
+      @apply bg-[#b5eaff8e];
+      .question-option-index {
+        @apply bg-primary text-white;
+      }
+      .question-option-content {
+        @apply text-30 text-primary;
+      }
+    }
+
+    .question-option-not-know {
+      @apply bg-[#b5eaff8e];
+
+      .question-option-content {
+        @apply text-30 text-primary;
+      }
+    }
+
+    .question-option+.question-option {
+      @apply mt-20;
+    }
+  }
+}
+</style>

+ 92 - 0
src/pagesStudy/pages/start-exam/components/question-stats-popup.vue

@@ -0,0 +1,92 @@
+<template>
+  <!-- #ifdef H5 -->
+  <teleport to="body">
+    <!-- #endif -->
+    <!-- #ifdef MP-WEIXIN -->
+    <root-portal externalClass="theme-ie">
+      <!-- #endif -->
+      <uv-popup ref="popupRef" mode="bottom" :close-on-click-overlay="true" :closeable="false" :round="16">
+        <view class="theme-ie w-auto box-border bg-white">
+          <view class="popup-header">
+            <view class="popup-header-left">
+              <uv-icon name="calendar" size="26" />
+              <view class="popup-header-left-title">
+                <text>答题卡</text>
+                <slot name="title" />
+              </view>
+            </view>
+            <view class="popup-header-right">
+              <view class="stats-dot stats-dot-done">已答</view>
+              <view class="stats-dot stats-dot-not-done">未答</view>
+              <view class="stats-dot stats-dot-not-know">不会</view>
+            </view>
+          </view>
+          <slot />
+        </view>
+      </uv-popup>
+      <!-- #ifdef MP-WEIXIN -->
+    </root-portal>
+    <!-- #endif -->
+    <!-- #ifdef H5 -->
+  </teleport>
+  <!-- #endif -->
+</template>
+
+<script lang="ts" setup>
+const popupRef = ref();
+const open = () => {
+  popupRef.value.open();
+}
+const close = () => {
+  popupRef.value.close();
+}
+defineExpose({
+  open,
+  close
+});
+</script>
+
+<style lang="scss" scoped>
+.popup-header {
+  @apply px-30 h-120 flex items-center justify-between;
+}
+
+.popup-header-left {
+  @apply flex items-center;
+}
+
+.popup-header-left-title {
+  @apply flex items-center text-30 text-fore-title font-bold;
+}
+
+.popup-header-right {
+  @apply flex items-center gap-x-60;
+}
+
+.stats-dot {
+  @apply relative text-22 text-fore-light;
+
+  &::before {
+    @apply content-[''] absolute top-6 -left-30 w-18 h-18 rounded-full;
+  }
+
+  &.stats-dot-done {
+    &::before {
+      @apply bg-primary;
+    }
+  }
+
+  &.stats-dot-not-done {
+    &::before {
+      @apply w-14 h-14 border-2 border-solid border-back;
+    }
+  }
+
+  &.stats-dot-not-know {
+    &::before {
+      @apply bg-back;
+    }
+  }
+}
+
+</style>

+ 9 - 0
src/pagesStudy/pages/start-exam/components/sub-question-item.vue

@@ -0,0 +1,9 @@
+<template>
+
+</template>
+
+<script lang="ts" setup>
+
+</script>
+
+<style lang="scss" scoped></style>

+ 294 - 0
src/pagesStudy/pages/start-exam/start-exam.vue

@@ -0,0 +1,294 @@
+<template>
+  <ie-page :fix-height="true" :safe-area-inset-bottom="false">
+    <ie-navbar :title="pageTitle" custom-back @left-click="handleLeftClick">
+      <template #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 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-item :question="item" />
+          </swiper-item>
+        </swiper>
+      </view>
+    </view>
+    <ie-safe-toolbar :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 stateQuestionList" :key="i" class="">
+            <template v-if="item.list.length > 0">
+              <view class="h-60 bg-back px-20 leading-60 text-fore-subcontent">{{ questionTypeDesc[item.type] }}</view>
+              <view class="grid grid-cols-5 place-items-center gap-x-20 gap-y-20">
+                <view v-for="(qs, j) in item.list" :key="j" class="py-20 flex items-center justify-center">
+                  <view
+                    class="w-52 h-52 rounded-full flex items-center justify-center bg-white border border-solid border-border"
+                    :class="{
+                      'is-done': qs.question.isDone,
+                      'is-not-know': qs.question.isNotKnow,
+                      'is-mark': qs.question.isMark
+                    }">
+                    {{ qs.index + 1 }}
+                  </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">
+          <uv-icon name="reload" size="20" />
+          <text class="mt-4 text-20 text-subcontent">重新作答</text>
+        </view>
+        <view class="flex-1 py-20 text-center rounded-full bg-primary text-white">交卷</view>
+      </view>
+    </view>
+  </question-stats-popup>
+</template>
+
+<script lang="ts" setup>
+import QuestionItem from './components/question-item.vue';
+import QuestionStatsPopup from './components/question-stats-popup.vue';
+import { useTransferPage } from '@/hooks/useTransferPage';
+import { EnumExamMode, EnumQuestionType } from '@/common/enum';
+import { getOpenExaminee } 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';
+const { prevData } = useTransferPage();
+const { questionList, stateQuestionList, questionTypeDesc, favoriteList, notKnowList, markList, currentIndex,
+  totalCount, doneCount, notDoneCount, notKnowCount, markCount,
+  loadExamData, nextQuestion, prevQuestion, nextQuestionQuickly, prevQuestionQuickly, swiperDuration,
+  formatPracticeDuration, formatExamDuration, practiceDuration, startPracticeDuration, stopPracticeDuration,
+  examDuration, startExamDuration, stopExamDuration, setExamDuration, setCountDownCallback } = 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 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 = () => {
+  beforeQuit();
+};
+const handleSwiperChange = (e: any) => {
+  console.log(e)
+  currentIndex.value = e.detail.current;
+};
+const beforeQuit = () => {
+  const { mode } = prevData.value;
+  if (mode === EnumExamMode.PRACTICE) {
+    beforeQuitPractice();
+  } else {
+    beforeQuitExam();
+  }
+};
+const beforeQuitPractice = () => {
+  uni.$ie.showModal({
+    title: '提示',
+    content: '当前练习未完成,确认退出?',
+  }).then(confirm => {
+    if (confirm) {
+      uni.$ie.showLoading('保存中...');
+      setTimeout(() => {
+        uni.$ie.hideLoading();
+        uni.navigateBack();
+      }, 1000);
+    }
+  });
+};
+const beforeQuitExam = () => {
+  uni.$ie.showModal({
+    title: '提示',
+    content: '当前考试未完成,确认退出?',
+  }).then(confirm => {
+    if (confirm) {
+      uni.navigateBack();
+    }
+  });
+};
+const currentQuestion = computed(() => {
+  console.log(questionList.value[currentIndex.value])
+  return questionList.value[currentIndex.value];
+});
+const handleFavorite = () => {
+  console.log('handleFavorite')
+  currentQuestion.value.isFavorite = !currentQuestion.value.isFavorite;
+};
+const handleMark = () => {
+  console.log('handleMark')
+  currentQuestion.value.isMark = !currentQuestion.value.isMark;
+};
+const questionStatsPopupRef = ref();
+const handleCalendar = () => {
+  console.log('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) {
+    const text = notDoneCount.value > 0 ? `还有${notDoneCount.value}题未做,确认交卷?` : '是否确认交卷?';
+    stopTime();
+    uni.$ie.showModal({
+      title: '提示',
+      content: text,
+    }).then(confirm => {
+      if (confirm) {
+        // uni.navigateBack();
+        handleSubmit();
+      } else {
+        startTime();
+      }
+    });
+  }
+  isAnimationFinish.value = true;
+  transitionStartX.value = null;
+  transitionEndX.value = null;
+};
+const handleSubmit = () => {
+  uni.$ie.showLoading('保存中...');
+  setTimeout(() => {
+    uni.$ie.hideLoading();
+    uni.navigateBack();
+  }, 1000);
+  console.log('handleSubmit')
+}
+const loadData = async () => {
+  // const { data } = await getOpenExaminee({
+  //   paperType: prevData.value.paperType,
+  //   relateId: prevData.value.relateId
+  // });
+  // console.log(data)
+  // 构造模拟数据
+  console.log(questionList.value)
+  loadExamData();
+  // setExamDuration(35)
+  setCountDownCallback(() => {
+    uni.$ie.showToast('考试结束');
+    // uni.navigateBack();
+  });
+  startTime();
+  // startExamDuration();
+};
+onMounted(() => {
+  setTimeout(() => {
+    console.log(stateQuestionList.value)
+    handleCalendar();
+  }, 500);
+});
+onLoad(() => {
+  console.log(prevData.value)
+  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 border-[#F2F2F2] bg-[#F2F2F2];
+}
+</style>

+ 1 - 1
src/pagesStudy/pages/targeted-practice/targeted-practice.vue

@@ -12,7 +12,7 @@
 <script lang="ts" setup>
 // import knowledgePageLayout from '@/pagesOther/pages/topic-center/index/components/knowledge-page-layout.vue';
 import knowledgeTree from '@/pagesStudy/components/knowledge-tree.vue';
-import { TreeData } from '@/types/study';
+import { TreeData } from '@/types';
 
 const list = [
   {

BIN
src/pagesStudy/static/image/icon-mark-active.png


BIN
src/pagesStudy/static/image/icon-mark.png


+ 1 - 1
src/pagesSystem/pages/user-profile/components/content-card.vue → src/pagesSystem/components/content-card.vue

@@ -1,5 +1,5 @@
 <template>
-  <view class="my-16 bg-white pt-28 px-42">
+  <view class="my-16 bg-white pt-18 px-42">
     <view class="text-32 font-bold text-fore-title">{{ title }}</view>
     <view class="mt-16">
       <slot></slot>

+ 3 - 2
src/pagesSystem/pages/user-profile/user-profile.vue → src/pagesSystem/pages/bind-profile/bind-profile.vue

@@ -77,7 +77,7 @@
       </content-card>
     </uv-form>
     <ie-safe-toolbar :height="84" :shadow="false">
-      <view class="px-18 py-16">
+      <view class="px-30 py-16">
         <ie-button @click="handleSubmit">确认提交</ie-button>
       </view>
     </ie-safe-toolbar>
@@ -85,7 +85,7 @@
 </template>
 
 <script lang="ts" setup>
-import ContentCard from './components/content-card.vue';
+import ContentCard from '../../components/content-card.vue';
 import { useUserStore } from '@/store/userStore';
 import { registry, improve } from '@/api/modules/login';
 import { useTransferPage } from '@/hooks/useTransferPage';
@@ -266,6 +266,7 @@ const startBind = async (params: BindCardInfo) => {
   await improve(params);
   uni.$ie.hideLoading();
   uni.$ie.showSuccess('绑定成功');
+  userStore.getUserInfo();
   goHome();
 }
 

+ 0 - 0
src/pagesSystem/pages/user-profile/components/exam-info copy 2.vue → src/pagesSystem/pages/bind-profile/components/exam-info copy 2.vue


+ 0 - 0
src/pagesSystem/pages/user-profile/components/exam-info copy 3.vue → src/pagesSystem/pages/bind-profile/components/exam-info copy 3.vue


+ 0 - 0
src/pagesSystem/pages/user-profile/components/exam-info copy.vue → src/pagesSystem/pages/bind-profile/components/exam-info copy.vue


+ 0 - 0
src/pagesSystem/pages/user-profile/components/exam-info.vue → src/pagesSystem/pages/bind-profile/components/exam-info.vue


+ 2 - 2
src/pagesSystem/pages/user-profile/user-profile copy.vue → src/pagesSystem/pages/bind-profile/user-profile copy.vue

@@ -75,7 +75,7 @@
       </content-card> -->
     </uv-form>
     <ie-safe-toolbar :height="84" :shadow="false">
-      <view class="px-18 py-16">
+      <view class="px-30 py-16">
         <ie-button @click="handleSubmit">确认提交</ie-button>
       </view>
     </ie-safe-toolbar>
@@ -83,7 +83,7 @@
 </template>
 
 <script lang="ts" setup>
-import ContentCard from './components/content-card.vue';
+import ContentCard from '../../components/content-card.vue';
 import { useUserStore } from '@/store/userStore';
 import { } from '@/api/modules/login';
 import { getExamTypes, getExamMajors, getGraduateYears } from '@/api/modules/system';

+ 1 - 1
src/pagesSystem/pages/card-verify/card-verify.vue

@@ -35,7 +35,7 @@ const handleSubmit = async () => {
   uni.$ie.showLoading();
   verifyCard(cardNo, password).then(() => {
     uni.$ie.hideLoading();
-    transferTo('/pagesSystem/pages/user-profile/user-profile', {
+    transferTo('/pagesSystem/pages/bind-profile/bind-profile', {
       data: {
         cardNo,
         password,

+ 205 - 0
src/pagesSystem/pages/edit-profile/edit-profile.vue

@@ -0,0 +1,205 @@
+<template>
+  <ie-page bg-color="#F6F8FA" :safeAreaInsetBottom="false">
+    <ie-navbar title="基本信息" />
+    <view class="">
+      <uv-form labelPosition="left" :model="form" labelWidth="70px" ref="formRef">
+        <content-card title="考生信息">
+          <uv-form-item label="学生姓名" prop="name" borderBottom>
+            <uv-input v-model="form.nickName" border="none" placeholder="请输入姓名" placeholderClass="text-30"
+              font-size="30rpx" :custom-style="customStyle">
+            </uv-input>
+          </uv-form-item>
+          <uv-form-item label="手机号码" prop="name" borderBottom>
+            <uv-input v-model="form.phonenumber" border="none" placeholder="请输入手机号码" maxlength="11" type="number"
+              placeholderClass="text-30" font-size="30rpx" :custom-style="customStyle" readonly>
+            </uv-input>
+            <text slot="right" class="text-30 text-primary" @click="handleBindPhone">换绑</text>
+          </uv-form-item>
+          <uv-form-item label="所在省份" prop="name" borderBottom>
+            <uv-input v-model="form.location" border="none" placeholder="" placeholderClass="text-30" font-size="30rpx"
+              :custom-style="customStyle" readonly>
+            </uv-input>
+            <ie-image slot="right" src="/pagesSystem/static/image/icon/icon-lock.png" custom-class="w-24 h-30"
+              mode="aspectFill" />
+          </uv-form-item>
+          <uv-form-item label="考试类别" prop="name" borderBottom>
+            <view class="flex-1 pl-[26px]">
+              <ie-dict :dictName="EnumDictName.EXAM_TYPE" :dictValue="form.examType" />
+            </view>
+            <ie-image slot="right" src="/pagesSystem/static/image/icon/icon-lock.png" custom-class="w-24 h-30"
+              mode="aspectFill" />
+          </uv-form-item>
+          <uv-form-item label="毕业年份" prop="name">
+            <uv-input v-model="form.endYear" border="none" placeholder="" placeholderClass="text-30" font-size="30rpx"
+              :custom-style="customStyle" readonly>
+            </uv-input>
+            <ie-image slot="right" src="/pagesSystem/static/image/icon/icon-lock.png" custom-class="w-24 h-30"
+              mode="aspectFill" />
+          </uv-form-item>
+        </content-card>
+
+        <content-card title="文化素质">
+          <uv-form-item label="语文" prop="name" borderBottom>
+            <uv-input v-model="scores.chinese" border="none" placeholder="请输入" placeholderClass="text-30"
+              font-size="30rpx" :custom-style="customStyle" :readonly="form.examType === 'OHS'">
+            </uv-input>
+            <ie-image v-if="form.examType === 'OHS'" slot="right" src="/pagesSystem/static/image/icon/icon-lock.png"
+              custom-class="w-24 h-30" mode="aspectFill" />
+          </uv-form-item>
+          <uv-form-item label="数学" prop="name" borderBottom>
+            <uv-input v-model="scores.mathematics" border="none" placeholder="请输入" placeholderClass="text-30"
+              font-size="30rpx" :custom-style="customStyle" :readonly="form.examType === 'OHS'">
+            </uv-input>
+            <ie-image v-if="form.examType === 'OHS'" slot="right" src="/pagesSystem/static/image/icon/icon-lock.png"
+              custom-class="w-24 h-30" mode="aspectFill" />
+          </uv-form-item>
+          <uv-form-item label="外语" prop="name" :borderBottom="form.examType !== 'VHS'">
+            <uv-input v-model="scores.foreign" border="none" placeholder="请输入" placeholderClass="text-30"
+              font-size="30rpx" :custom-style="customStyle" :readonly="form.examType === 'OHS'">
+            </uv-input>
+            <ie-image v-if="form.examType === 'OHS'" slot="right" src="/pagesSystem/static/image/icon/icon-lock.png"
+              custom-class="w-24 h-30" mode="aspectFill" />
+          </uv-form-item>
+          <block v-if="['OHS', 'SVS'].includes(form.examType)">
+            <uv-form-item label="物理" prop="name" borderBottom>
+              <uv-input v-model="scores.physics" border="none" placeholder="请输入" placeholderClass="text-30"
+                font-size="30rpx" :custom-style="customStyle" :readonly="form.examType === 'OHS'">
+              </uv-input>
+              <ie-image v-if="form.examType === 'OHS'" slot="right" src="/pagesSystem/static/image/icon/icon-lock.png"
+                custom-class="w-24 h-30" mode="aspectFill" />
+            </uv-form-item>
+            <uv-form-item label="政治" prop="name">
+              <uv-input v-model="scores.political" border="none" placeholder="请输入" placeholderClass="text-30"
+                font-size="30rpx" :custom-style="customStyle" :readonly="form.examType === 'OHS'">
+              </uv-input>
+              <ie-image v-if="form.examType === 'OHS'" slot="right" src="/pagesSystem/static/image/icon/icon-lock.png"
+                custom-class="w-24 h-30" mode="aspectFill" />
+            </uv-form-item>
+          </block>
+        </content-card>
+
+        <content-card v-if="userStore.isLogin" title="学校信息">
+          <uv-form-item label="学校名称" prop="form.name" borderBottom>
+            <uv-input v-model="form.schoolName" border="none" placeholder="" placeholderClass="text-30"
+              font-size="30rpx" :custom-style="customStyle" readonly>
+            </uv-input>
+            <ie-image slot="right" src="/pagesSystem/static/image/icon/icon-lock.png" custom-class="w-24 h-30"
+              mode="aspectFill" />
+          </uv-form-item>
+          <uv-form-item label="所在班级" prop="form.name">
+            <ie-picker ref="pickerRef" v-model="form.classId" :list="classList" title="选择班级" placeholder="请选择所在班级"
+              :custom-style="customStyle" key-label="name" key-value="classId"></ie-picker>
+          </uv-form-item>
+        </content-card>
+      </uv-form>
+    </view>
+    <ie-safe-toolbar :height="84" :shadow="false">
+      <view class="px-30 py-16">
+        <ie-button @click="handleSubmit">确认保存</ie-button>
+      </view>
+    </ie-safe-toolbar>
+
+  </ie-page>
+  <uv-popup ref="popupRef" title="换绑手机号" mode="bottom" :round="16">
+    <view class="theme-ie popup-content pt-100 py-30 px-30">
+      <ie-input v-model="phoneForm.phone" type="number" :maxlength="11" placeholder="请输入新手机号" />
+      <ie-input custom-class="mt-28" type="number" :maxlength="6" v-model="phoneForm.code" placeholder="请输入验证码">
+        <ie-sms :phone="phoneForm.phone" :sms-api-type="EnumSmsApiType.NO_TOKEN" :beforeSend="handleBeforeSend"
+          @send="handleSendSuccess" />
+      </ie-input>
+      <view class="mt-80 mb-16">
+        <ie-button @click="handleChangePhone">确认更换</ie-button>
+      </view>
+    </view>
+  </uv-popup>
+</template>
+
+<script lang="ts" setup>
+import ContentCard from '@/pagesSystem/components/content-card.vue';
+import { updateUserInfo } from '@/api/modules/login';
+import { useUserStore } from '@/store/userStore';
+import { useSchool } from '@/composables/useSchool';
+import { BindCardInfo, UserInfo } from '@/types/user';
+import { EnumDictName, EnumSmsApiType } from '@/common/enum';
+import { validatePhone } from '@/hooks/useValidation';
+import { validateSms } from '@/api/modules/system';
+const userStore = useUserStore();
+const userInfo = computed(() => userStore.userInfo);
+const cardInfo = computed(() => userStore.card);
+const { classList, loadClassData } = useSchool();
+type SchoolInfo = {
+  schoolName: string;
+  classId: number | null;
+  className: string;
+  schoolId: number | null;
+}
+type UserProfile = Pick<UserInfo, 'nickName' | 'phonenumber' | 'location' | 'endYear' | 'examType'> & SchoolInfo;
+const form = ref<UserProfile>({
+  ...userInfo.value,
+  schoolName: cardInfo.value?.schoolName || '',
+  classId: cardInfo.value?.classId || null,
+  className: cardInfo.value?.className || '',
+  schoolId: cardInfo.value?.schoolId || null
+});
+const scores = ref({
+  ...userInfo.value.scores
+})
+const customStyle = {
+  paddingLeft: '26px'
+};
+
+const handleSubmit = async () => {
+  uni.$ie.showLoading();
+  const params = {
+    ...userInfo.value,
+    ...form.value,
+    scores: scores.value,
+  } as UserInfo;
+  await updateUserInfo(params);
+  uni.$ie.hideLoading();
+  uni.$ie.showToast('保存成功');
+}
+const popupRef = ref();
+const phoneForm = ref({
+  phone: '',
+  code: '',
+  uuid: ''
+});
+const handleBeforeSend = () => {
+  if (phoneForm.value.phone === userInfo.value.phonenumber) {
+    uni.$ie.showToast('新手机号不能与旧手机号相同');
+    return false;
+  }
+  return true;
+}
+const handleBindPhone = () => {
+  popupRef.value.open();
+}
+const handleSendSuccess = (_phone: string, _code: string, _uuid: string) => {
+  phoneForm.value.uuid = _uuid;
+}
+const handleChangePhone = async () => {
+  if (phoneForm.value.code.trim() === '') {
+    uni.$ie.showToast('请输入验证码');
+    return;
+  }
+  uni.$ie.showLoading();
+  const valid = await validateSms({
+    code: phoneForm.value.code,
+    uuid: phoneForm.value.uuid,
+    mobile: phoneForm.value.phone
+  });
+  uni.$ie.hideLoading();
+  if (!valid) {
+    uni.$ie.showToast('请输入正确的验证码');
+    return;
+  }
+  form.value.phonenumber = phoneForm.value.phone;
+  popupRef.value.close();
+}
+onLoad(() => {
+  loadClassData(cardInfo.value?.schoolId);
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 2 - 3
src/pagesSystem/pages/login/login.vue

@@ -59,14 +59,13 @@
 import ieCaptcha from '@/components/ie-sms/ie-captcha.vue';
 import { useUserStore } from '@/store/userStore';
 import { useTransferPage } from '@/hooks/useTransferPage';
-import { useValidation } from '@/hooks/useValidation';
+import { validatePhone } from '@/hooks/useValidation';
 import { verifyCard } from '@/api/modules/user';
 import { EnumSmsApiType } from '@/common/enum';
 import { mobileLogin } from '@/api/modules/login';
 import { MobileLoginRequestDTO, MobileLoginResponseDTO } from '@/types/user';
 const { transferBack, transferTo } = useTransferPage();
 const userStore = useUserStore();
-const { validatePhone, validateTelephone } = useValidation();
 
 const loginType = ref('phone');
 const phone = ref('');
@@ -177,7 +176,7 @@ const handleMobileLogin = async (params: MobileLoginRequestDTO) => {
       const { code, message } = res.data;
       if (code === 101) {
         // 账号不存在,需要注册
-        transferTo('/pagesSystem/pages/user-profile/user-profile', {
+        transferTo('/pagesSystem/pages/bind-profile/bind-profile', {
           data: params
         });
       }

+ 6 - 1
src/pagesSystem/pages/phone-verify/phone-verify.vue

@@ -21,6 +21,7 @@ import { verifyCard } from '@/api/modules/user';
 import { useTransferPage } from '@/hooks/useTransferPage';
 import { EnumSmsApiType } from '@/common/enum';
 import { validateSms } from '@/api/modules/system';
+import { validatePhone } from '@/hooks/useValidation';
 const { prevData, transferTo } = useTransferPage();
 const form = ref({
   phone: '',
@@ -38,6 +39,10 @@ const handleSubmit = async () => {
     uni.$ie.showToast('请输入手机号');
     return;
   }
+  if (!validatePhone(phone)) {
+    uni.$ie.showToast('请输入正确的手机号');
+    return;
+  }
   if (!uuid && code) {
     uni.$ie.showToast('请输入正确的验证码');
     return;
@@ -60,7 +65,7 @@ const handleSubmit = async () => {
       password: prevData.value.password
     };
     console.log(params)
-    transferTo('/pagesSystem/pages/user-profile/user-profile', {
+    transferTo('/pagesSystem/pages/bind-profile/bind-profile', {
       data: params
     });
   }).catch(() => {

BIN
src/pagesSystem/static/image/icon/icon-lock.png


+ 9 - 0
src/types/injectionSymbols.ts

@@ -13,3 +13,12 @@ export const OPEN_PRACTICE_DETAIL = Symbol('OPEN_PRACTICE_DETAIL') as InjectionK
  * 打开视频记录详情
  */
 export const OPEN_VIDEO_DETAIL = Symbol('OPEN_VIDEO_DETAIL') as InjectionKey<(id: number, name: string) => void>;
+
+
+export const NEXT_QUESTION = Symbol('NEXT_QUESTION') as InjectionKey<() => void>;
+
+export const PREV_QUESTION = Symbol('PREV_QUESTION') as InjectionKey<() => void>;
+
+export const NEXT_QUESTION_QUICKLY = Symbol('NEXT_QUESTION_QUICKLY') as InjectionKey<() => void>;
+
+export const PREV_QUESTION_QUICKLY = Symbol('PREV_QUESTION_QUICKLY') as InjectionKey<() => void>;

+ 43 - 0
src/types/study.ts

@@ -53,4 +53,47 @@ export interface StudyPlan {
   studentId: number;
   videoTime: number;
   status: number;
+}
+
+export interface Subject {
+  subjectId: number;
+  subjectName: string;
+}
+
+export interface Knowledge {
+  id: number;
+  name: string;
+  status: number;
+  questionCount: number;
+  children: Knowledge[];
+}
+
+export type KnowledgeNode = Pick<Knowledge, 'id' | 'name' | 'status' | 'questionCount'> & {
+  isExpanded: boolean;
+  isLeaf: boolean;
+  actualHeight: number;
+  children: KnowledgeNode[];
+}
+
+export interface QuestionState {
+  isDone?: boolean;
+  isMark?: boolean;
+  isNotKnow?: boolean;
+  isFavorite?: boolean;
+}
+
+export interface Question extends QuestionState {
+  id: number;
+  name: string;
+  type: number;
+  options: QuestionOption[];
+  answer: (string | number)[];
+  subQuestions: Question[];
+}
+
+export interface QuestionOption {
+  id: number;
+  no: string | number; // A, B, C, D
+  name: string;
+  isAnswer: boolean;
 }

+ 2 - 0
src/types/user.ts

@@ -150,7 +150,9 @@ export interface UserInfo {
 export interface VipCardInfo {
   campusId: number; // 校区ID
   classId: number; // 班级ID
+  className: string; // 班级名称
   schoolId: number; // 学校ID
+  schoolName: string; // 学校名称
   year: number; // 入学年份
   endYear: number; // 毕业年份
   outDate: string; // 到期时间