소스 검색

重构ExamingHome,提取hooks

Michael Wang 3 년 전
부모
커밋
2b32f612dd

+ 0 - 1
.eslintrc.js

@@ -43,7 +43,6 @@ module.exports = {
   },
   ignorePatterns: [
     // FIXME: ignore lang="tsx" don't know how to fix
-    "ExamingHome.vue",
     "RemainTime.vue",
     ".eslintrc.js",
     "vite.config.ts",

+ 34 - 388
src/features/OnlineExam/Examing/ExamingHome.vue

@@ -1,4 +1,4 @@
-<script setup lang="tsx">
+<script setup lang="ts">
 import RemainTime from "./RemainTime.vue";
 import OverallProgress from "./OverallProgress.vue";
 import QuestionFilters from "./QuestionFilters.vue";
@@ -9,26 +9,24 @@ import FaceTracking from "./FaceTracking.vue";
 import FaceId from "./FaceId.vue";
 // import FaceMotion from "./FaceMotion/FaceMotion";
 import FaceRecognition from "../FaceRecognition.vue";
-import { STRICT_CHECK_HOSTS, WEBSOCKET_FOR_AUDIO } from "@/constants/constants";
+import { STRICT_CHECK_HOSTS } from "@/constants/constants";
 import { httpApp } from "@/plugins/axiosApp";
 import { useTimers } from "@/setups/useTimers";
 import { checkMainExe } from "@/utils/nativeMethods";
 import { showLogout } from "@/utils/utils";
-import { onBeforeUpdate, onMounted, watch } from "vue";
+import { onBeforeUpdate, onMounted, onUnmounted, watch } from "vue";
 import { useRoute } from "vue-router";
 import { store } from "@/store/store";
 import { useRemoteAppChecker } from "@/features/UserLogin/useRemoteAppChecker";
-import { ExamQuestion, PaperStruct, Store } from "@/types/student-client";
-import router from "@/router";
-import { useWebSocket } from "@/setups/useWebSocket";
 import { useScreenTop } from "./setups/useScreenTop";
 import { useFaceLive } from "./setups/useFaceLive";
-import { dimensionLog } from "@/utils/logger";
 import { useFaceCompare } from "./setups/useFaceCompare";
+import { initExamData } from "./setups/useInitExamData";
+import { useWXSocket } from "./setups/useWXSocket";
+import { answerAllQuestions } from "./setups/useAnswerQuestions";
+import { useRealSubmitPaper } from "./setups/useSubmitPaper";
 
-const { startWS } = useWebSocket();
-
-type PRACTICE_TYPE = "IN_PRACTICE" | "NO_ANSWER";
+const { addTimeout, addInterval } = useTimers();
 
 let loading = $ref(true);
 const route = useRoute();
@@ -38,8 +36,11 @@ store.exam.examId = examId;
 store.exam.examRecordDataId = examRecordDataId;
 
 useScreenTop(examRecordDataId);
-
-let courseName = $ref("");
+useWXSocket();
+const { userSubmitPaper, realSubmitPaper } = useRealSubmitPaper(
+  examId,
+  examRecordDataId
+);
 
 onBeforeUpdate(() => {
   _hmt.push(["_trackEvent", "答题页面", "题目切换"]);
@@ -148,10 +149,6 @@ watch(
 //   // }
 // },
 
-let pageLoadTimeout = $ref(false);
-const { addTimeout, addInterval } = useTimers();
-addTimeout(() => (pageLoadTimeout = true), 30 * 1000);
-
 // 10秒检查是否有更改需要提交答案
 addInterval(() => answerAllQuestions(), 5 * 1000);
 
@@ -173,7 +170,7 @@ onMounted(async () => {
   });
 
   try {
-    await initData();
+    await initExamData(examId, examRecordDataId);
     loading = false;
   } catch (error) {
     logger({
@@ -214,245 +211,6 @@ onMounted(async () => {
 //     "resetExamQuestionDirty",
 //     "updatePicture",
 //   ]),
-async function initData() {
-  logger({ cnl: ["server", "local"], pgn: "答题页面", act: "before initData" });
-  const [
-    { data: weixinAnswerEnabled },
-    { data: faceCheckEnabled },
-    { data: faceLivenessEnabled },
-    { data: examProp },
-    { data: exam },
-    { data: paperStruct },
-    { data: examQuestionListOrig },
-    { data: _courseName },
-  ] = await Promise.all([
-    httpApp.get<boolean>(
-      "/api/ecs_exam_work/exam/weixinAnswerEnabled/" + examId,
-      {
-        "axios-retry": { retries: 4 },
-        noErrorMessage: true,
-      }
-    ),
-    httpApp.get<boolean>("/api/ecs_exam_work/exam/faceCheckEnabled/" + examId, {
-      "axios-retry": { retries: 4 },
-      noErrorMessage: true,
-    }),
-    httpApp.get<boolean>(
-      "/api/ecs_exam_work/exam/identificationOfLivingEnabled/" + examId,
-      { "axios-retry": { retries: 4 }, noErrorMessage: true }
-    ),
-    // 实际上后台都是字符串 {PRACTICE_TYPE: string | null; FREEZE_TIME: string; SNAPSHOT_INTERVAL: string;  }
-    httpApp.get<{
-      PRACTICE_TYPE: string | null;
-      FREEZE_TIME: number | null;
-      SNAPSHOT_INTERVAL: number;
-    }>(
-      "/api/ecs_exam_work/exam/getExamPropertyFromCacheByStudentSession/" +
-        examId +
-        `/SNAPSHOT_INTERVAL,PRACTICE_TYPE,FREEZE_TIME`,
-      { "axios-retry": { retries: 4 }, noErrorMessage: true }
-    ),
-    httpApp.get<Store["exam"]>("/api/ecs_exam_work/exam/" + examId, {
-      "axios-retry": { retries: 4 },
-      noErrorMessage: true,
-    }),
-    httpApp.get<PaperStruct>(
-      "/api/ecs_oe_student/examRecordPaperStruct/getExamRecordPaperStruct?examRecordDataId=" +
-        examRecordDataId,
-      { "axios-retry": { retries: 4 }, noErrorMessage: true }
-    ),
-    httpApp.get<ExamQuestion[]>(
-      "/api/ecs_oe_student/examQuestion/findExamQuestionList",
-      { "axios-retry": { retries: 4 }, noErrorMessage: true }
-    ),
-    httpApp.get<string>(
-      "/api/ecs_oe_student/examControl/courseName/" + examRecordDataId,
-      { "axios-retry": { retries: 4 }, noErrorMessage: true }
-    ),
-  ]);
-  courseName = _courseName;
-
-  let examQuestionList = examQuestionListOrig;
-
-  logger({
-    cnl: ["server", "local"],
-    pgn: "答题页面",
-    dtl: `end${typeof Object.fromEntries === "function" ? " " : " "}initData`,
-  });
-
-  dimensionLog("答题页面");
-
-  if (exam.examType === "PRACTICE") {
-    exam.practiceType = examProp.PRACTICE_TYPE as PRACTICE_TYPE;
-  }
-
-  exam.freezeTime = JSON.parse("" + examProp.FREEZE_TIME);
-  examProp.SNAPSHOT_INTERVAL = JSON.parse("" + examProp.SNAPSHOT_INTERVAL);
-
-  exam.WEIXIN_ANSWER_ENABLED = weixinAnswerEnabled;
-
-  store.exam.faceCheckEnabled = faceCheckEnabled;
-  store.exam.faceLivenessEnabled = faceLivenessEnabled;
-
-  logger({
-    cnl: ["server", "local"],
-    pgn: "答题页面",
-    ext: {
-      examRecordDataId: examRecordDataId,
-      faceCheckEnabled: faceCheckEnabled,
-      faceLivenessEnabled: faceLivenessEnabled,
-      WEIXIN_ANSWER_ENABLED: exam.WEIXIN_ANSWER_ENABLED,
-      SNAPSHOT_INTERVAL: examProp.SNAPSHOT_INTERVAL,
-      PRACTICE_TYPE: examProp.PRACTICE_TYPE,
-      FREEZE_TIME: examProp.FREEZE_TIME,
-    },
-  });
-
-  // parentQuestionBody
-  //     questionUnitWrapperList
-  //         questionBody  => from examQuestionList
-  //         questionUnitList =>
-  //         studentAnswer
-  //         rightAnswer
-
-  // init subNumber
-  let questionId: string | null = null;
-  let i = 1;
-
-  examQuestionList = examQuestionList.map((eq) => {
-    if (questionId == eq.questionId) {
-      eq.subNumber = i++;
-    } else {
-      i = 1;
-      questionId = eq.questionId;
-      eq.subNumber = i++;
-    }
-    return eq;
-  });
-
-  let groupOrder = 1;
-  let mainNumber = 0;
-  examQuestionList = examQuestionList.map((eq) => {
-    if (mainNumber == eq.mainNumber) {
-      eq.inGroupOrder = groupOrder++;
-    } else {
-      mainNumber = eq.mainNumber;
-      groupOrder = 1;
-      eq.inGroupOrder = groupOrder++;
-    }
-
-    const questionWrapperList =
-      paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1]
-        .questionWrapperList;
-    const groupName =
-      paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1].groupName;
-    const groupTotal = questionWrapperList.reduce(
-      (accumulator, questionWrapper) =>
-        accumulator + questionWrapper.questionUnitWrapperList.length,
-      0
-    );
-
-    eq.groupName = groupName;
-    eq.groupTotal = groupTotal;
-    return eq;
-  });
-
-  store.exam.examQuestionList = examQuestionList.map((eq) => {
-    const paperStructQuestion = paperStruct.defaultPaper.questionGroupList[
-      eq.mainNumber - 1
-    ].questionWrapperList.find((q) => q.questionId === eq.questionId);
-    return Object.assign(eq, {
-      limitedPlayTimes: paperStructQuestion!.limitedPlayTimes,
-    });
-  });
-
-  Object.assign(store.exam, exam);
-  store.exam.paperStruct = paperStruct;
-  // TODO: 此处类型待优化
-  store.exam.allAudioPlayTimes =
-    JSON.parse(
-      store.exam.examQuestionList[0].audioPlayTimes as unknown as string
-    ) || [];
-
-  // this.updateExamState({
-  //   exam: exam,
-  //   paperStruct: paperStruct,
-  //   examQuestionList: examQuestionList,
-  //   allAudioPlayTimes: JSON.parse(examQuestionList[0].audioPlayTimes) || [],
-  //   questionAnswerFileUrl: [],
-  //   pictureAnswer: {},
-  // });
-  // console.log(examQuestionList);
-  // console.log(examQuestionList.find(v => v.answerType === "SINGLE_AUDIO"));
-
-  if (exam.WEIXIN_ANSWER_ENABLED) {
-    // init data
-    store.exam.questionAnswerFileUrl = [];
-    startWS(
-      WEBSOCKET_FOR_AUDIO + `?key=${store.user.key}&token=${store.user.token}`,
-      onAudioAnswer,
-      "微信小程序作答socket"
-    );
-  }
-}
-
-function onAudioAnswer(event: MessageEvent<string>) {
-  let res: {
-    eventType: string;
-    isSuccess: boolean;
-    errorMessage: string;
-    data: { order: number; fileUrl: string; transferFileType: string };
-  };
-  try {
-    res = JSON.parse(event.data).content;
-  } catch (error) {
-    logger({ cnl: ["server"], act: "JSON.parse出错", possibleError: error });
-    return;
-  }
-  if (!res) {
-    logger({
-      cnl: ["server"],
-      act: "onAudioAnswer",
-      dtl: "ws message format error",
-      ext: { event: JSON.stringify(event) },
-    });
-    return;
-  }
-  if (res.eventType && res.eventType !== "HEARTBEAT" && !res.isSuccess) {
-    $message.error(res.errorMessage, { duration: 10, closable: true });
-    logger({
-      cnl: ["server"],
-      act: "onAudioAnswer",
-      dtl: "error from server",
-      stk: res.errorMessage,
-    });
-    return;
-  }
-  switch (res.eventType) {
-    case "HEARTBEAT":
-      logger({
-        cnl: ["server"],
-        lvl: "debug",
-        act: "ws heartbeat response from server",
-      });
-      break;
-    case "SCAN_QR_CODE":
-      logger({ cnl: ["server"], act: "二维码被扫描" });
-      store.setQuestionQrCodeScanned({ order: res.data.order });
-      break;
-    case "GET_FILE_ANSWER":
-      logger({ cnl: ["server"], act: "获得音频地址" });
-      store.setQuestionFileAnswerUrl(res.data);
-      break;
-    case "SYSTEM_ERROR":
-      logger({
-        cnl: ["server"],
-        act: "ws get error",
-        ejn: JSON.stringify(res),
-      });
-      break;
-  }
-}
 
 let { snapId, doSnap, showSnapResult } = useFaceCompare();
 let { showFaceId } = useFaceLive(doSnap);
@@ -465,10 +223,23 @@ function onCompareResult({ hasError, fileName }: CompareResult) {
     addInterval(doSnap, 60 * 1000);
   } else {
     cmpResMap.set(fileName, false);
-    showSnapResult(fileName, examRecordDataId, cmpResMap);
+    void showSnapResult(fileName, examRecordDataId, cmpResMap);
   }
 }
 
+onUnmounted(() => {
+  const allFinished = [...cmpResMap].every((v) => v[1]);
+  const remained = [...cmpResMap].filter((v) => !v[1]);
+  logger({
+    cnl: ["server"],
+    act: "交卷前检测抓拍照片数量",
+    dtl: allFinished ? "完全检测" : "不完全检测",
+    ext: {
+      remainCount: remained.length,
+    },
+  });
+});
+
 // async function updateQuestion(next) {
 //   // 初始化套题的答案,为回填部分选项做准备
 //   // for (let q of this.examQuestionList) {
@@ -484,131 +255,6 @@ function onCompareResult({ hasError, fileName }: CompareResult) {
 //   if (!this.exam) return;
 // }
 
-function resetExamQuestionDirty() {
-  store.exam.examQuestionList = store.exam.examQuestionList.map((eq) => {
-    return Object.assign({}, eq, { dirty: false });
-  });
-}
-
-type Answer = {
-  order: number;
-  studentAnswer: string;
-  audioPlayTimes: { audioName: string; times: number }[];
-  isSign: boolean;
-};
-async function answerAllQuestions(ignoreDirty?: boolean): Promise<boolean> {
-  const answers: Answer[] = store.exam.examQuestionList
-    .filter((eq) => (ignoreDirty ? true : eq.dirty))
-    .filter((eq) => eq.getQuestionContent)
-    .map((eq) => {
-      return Object.assign(
-        {
-          order: eq.order,
-          studentAnswer: eq.studentAnswer,
-        },
-        eq.audioPlayTimes && { audioPlayTimes: eq.audioPlayTimes },
-        eq.isSign && { isSign: eq.isSign }
-      ) as Answer;
-    });
-  if (answers.length > 0) {
-    try {
-      await httpApp.post(
-        "/api/ecs_oe_student/examQuestion/submitQuestionAnswer",
-        answers
-      );
-      resetExamQuestionDirty();
-    } catch (error) {
-      logger({
-        cnl: ["server", "local"],
-        pgu: "AUTO",
-        act: "提交答案失败",
-        possibleError: error,
-      });
-      $message.error("提交答案失败");
-      return false;
-    }
-  }
-  // 提交成功,返回true,供最后提交时判断。自动提交失败,不暂停。
-  return true;
-}
-
-async function submitPaper() {
-  logger({ cnl: ["server", "local", "console"], act: "学生点击交卷" });
-  try {
-    // 交卷前强制提交所有答案
-    const ret = await answerAllQuestions(true);
-    if (!ret) {
-      // 提交答案失败,停止交卷逻辑。
-      return;
-    }
-  } catch (error) {
-    return;
-  }
-
-  if (
-    store.exam.freezeTime &&
-    store.exam.remainTime >
-      (store.exam.duration - store.exam.freezeTime) * 60 * 1000
-  ) {
-    $message.info(`考试开始${store.exam.freezeTime}分钟后才允许交卷。`);
-    return;
-  }
-
-  const answered = store.exam.examQuestionList.filter(
-    (q) => q.studentAnswer !== null
-  ).length;
-  const unanswered = store.exam.examQuestionList.filter(
-    (q) => q.studentAnswer === null
-  ).length;
-  const signed = store.exam.examQuestionList.filter((q) => q.isSign).length;
-  const showConfirmTime = Date.now();
-  $dialog.info({
-    title: "确认交卷",
-    content: () => (
-      <div>
-        <p>已答题目:{answered}</p>
-        <p>未答题目:{unanswered}</p>
-        <p>标记题目:{signed}</p>
-      </div>
-    ),
-    positiveText: "确定",
-    onPositiveClick: () => {
-      void realSubmitPaper();
-    },
-  });
-}
-
-function realSubmitPaper() {
-  store.increaseGlobalMaskCount("realSubmitPaper");
-  store.spinMessage = "正在交卷,请耐心等待...";
-  logger({ cnl: ["server"], act: "正在交卷,请耐心等待..." });
-  if (store.exam.faceCheckEnabled) {
-    logger({ cnl: ["server"], act: "交卷前抓拍" });
-    doSnap();
-  }
-  // 给抓拍照片多5秒处理时间
-  // 和下行注释的sleep语句不是一样的。sleep之后还可以执行。加上clearTimeout则可拒绝。
-  // await new Promise(resolve => setTimeout(() => resolve(), delay * 1000));
-  addTimeout(() => {
-    store.decreaseGlobalMaskCount("realSubmitPaper");
-    store.spinMessage = "";
-    const allFinished = [...cmpResMap].every((v) => v[1]);
-    const remained = [...cmpResMap].filter((v) => !v[1]);
-    logger({
-      cnl: ["server"],
-      act: "交卷前检测抓拍照片数量",
-      dtl: allFinished ? "完全检测" : "不完全检测",
-      ext: {
-        remainCount: remained.length,
-      },
-    });
-    void router.push({
-      name: "SubmitPaper",
-      params: { examId, examRecordDataId },
-    });
-  }, 5 * 1000);
-}
-
 function shouldSubmitPaper() {
   logger({ cnl: ["server"], act: "时间到自动交卷" });
   void realSubmitPaper();
@@ -620,6 +266,9 @@ function shouldSubmitPaper() {
 //   )
 // );
 
+let pageLoadTimeout = $ref(false);
+addTimeout(() => (pageLoadTimeout = true), 30 * 1000);
+
 function reloadPage() {
   logger({
     cnl: ["server", "local"],
@@ -634,9 +283,6 @@ const {
   disableLoginBtnBecauseRemoteApp: disableExamingBecauseRemoteApp,
   checkRemoteAppTxt: checkRemoteApp,
 } = useRemoteAppChecker();
-console.log({
-  disableExamingBecauseRemoteApp: disableExamingBecauseRemoteApp.value,
-});
 
 function checkRemoteAppClicked() {
   logger({ cnl: ["server"], pgu: "AUTO", act: "点击确认已关闭远程桌面软件" });
@@ -652,7 +298,7 @@ addInterval(() => checkRemoteApp(), 3 * 60 * 1000);
     <div class="header">
       <RemainTime @onEndtime="shouldSubmitPaper"></RemainTime>
       <div style="display: flex; flex-direction: column">
-        <div style="margin-bottom: 12px">{{ courseName }}</div>
+        <div style="margin-bottom: 12px">{{ store.exam.courseName }}</div>
         <OverallProgress></OverallProgress>
       </div>
       <div>
@@ -660,7 +306,7 @@ addInterval(() => checkRemoteApp(), 3 * 60 * 1000);
         {{ store.user.studentCodeList.join(",") }}
       </div>
       <QuestionFilters></QuestionFilters>
-      <n-button type="success" @click="submitPaper">交卷</n-button>
+      <n-button type="success" @click="userSubmitPaper">交卷</n-button>
     </div>
     <div id="examing-home-question" class="main">
       <!-- <QuestionView :examQuestion="examQuestion()"></QuestionView> -->
@@ -677,7 +323,7 @@ addInterval(() => checkRemoteApp(), 3 * 60 * 1000);
           :showRecognizeButton="false"
           :examRecordDataId="examRecordDataId"
           :snapId="snapId"
-          @on-async-recognize-result="onCompareResult"
+          @onAsyncRecognizeResult="onCompareResult"
         />
       </div>
     </div>

+ 53 - 0
src/features/OnlineExam/Examing/setups/useAnswerQuestions.ts

@@ -0,0 +1,53 @@
+import { httpApp } from "@/plugins/axiosApp";
+import { store } from "@/store/store";
+
+function resetExamQuestionDirty() {
+  store.exam.examQuestionList = store.exam.examQuestionList.map((eq) => {
+    return Object.assign({}, eq, { dirty: false });
+  });
+}
+
+type Answer = {
+  order: number;
+  studentAnswer: string;
+  audioPlayTimes: { audioName: string; times: number }[];
+  isSign: boolean;
+};
+
+export async function answerAllQuestions(
+  ignoreDirty?: boolean
+): Promise<boolean> {
+  const answers: Answer[] = store.exam.examQuestionList
+    .filter((eq) => (ignoreDirty ? true : eq.dirty))
+    .filter((eq) => eq.getQuestionContent)
+    .map((eq) => {
+      return Object.assign(
+        {
+          order: eq.order,
+          studentAnswer: eq.studentAnswer,
+        },
+        eq.audioPlayTimes && { audioPlayTimes: eq.audioPlayTimes },
+        eq.isSign && { isSign: eq.isSign }
+      ) as Answer;
+    });
+  if (answers.length > 0) {
+    try {
+      await httpApp.post(
+        "/api/ecs_oe_student/examQuestion/submitQuestionAnswer",
+        answers
+      );
+      resetExamQuestionDirty();
+    } catch (error) {
+      logger({
+        cnl: ["server", "local"],
+        pgu: "AUTO",
+        act: "提交答案失败",
+        possibleError: error,
+      });
+      $message.error("提交答案失败");
+      return false;
+    }
+  }
+  // 提交成功,返回true,供最后提交时判断。自动提交失败,不暂停。
+  return true;
+}

+ 1 - 1
src/features/OnlineExam/Examing/setups/useFaceCompare.ts

@@ -120,7 +120,7 @@ export function useFaceCompare() {
         cmpRes.set(fileName, true);
       } else {
         addTimeout(
-          showSnapResult.bind(null, fileName, examRecordDataId),
+          showSnapResult.bind(null, fileName, examRecordDataId, cmpRes),
           30 * 1000
         );
       }

+ 178 - 0
src/features/OnlineExam/Examing/setups/useInitExamData.ts

@@ -0,0 +1,178 @@
+import { httpApp } from "@/plugins/axiosApp";
+import { store } from "@/store/store";
+import { ExamQuestion, PaperStruct, Store } from "@/types/student-client";
+import { dimensionLog } from "@/utils/logger";
+
+type PRACTICE_TYPE = "IN_PRACTICE" | "NO_ANSWER";
+
+export async function initExamData(examId: number, examRecordDataId: number) {
+  logger({ cnl: ["server", "local"], pgn: "答题页面", act: "before initData" });
+  const [
+    { data: weixinAnswerEnabled },
+    { data: faceCheckEnabled },
+    { data: faceLivenessEnabled },
+    { data: examProp },
+    { data: exam },
+    { data: paperStruct },
+    { data: examQuestionListOrig },
+    { data: _courseName },
+  ] = await Promise.all([
+    httpApp.get<boolean>(
+      "/api/ecs_exam_work/exam/weixinAnswerEnabled/" + examId,
+      {
+        "axios-retry": { retries: 4 },
+        noErrorMessage: true,
+      }
+    ),
+    httpApp.get<boolean>("/api/ecs_exam_work/exam/faceCheckEnabled/" + examId, {
+      "axios-retry": { retries: 4 },
+      noErrorMessage: true,
+    }),
+    httpApp.get<boolean>(
+      "/api/ecs_exam_work/exam/identificationOfLivingEnabled/" + examId,
+      { "axios-retry": { retries: 4 }, noErrorMessage: true }
+    ),
+    // 实际上后台都是字符串 {PRACTICE_TYPE: string | null; FREEZE_TIME: string; SNAPSHOT_INTERVAL: string;  }
+    httpApp.get<{
+      PRACTICE_TYPE: string | null;
+      FREEZE_TIME: number | null;
+      SNAPSHOT_INTERVAL: number;
+    }>(
+      "/api/ecs_exam_work/exam/getExamPropertyFromCacheByStudentSession/" +
+        examId +
+        `/SNAPSHOT_INTERVAL,PRACTICE_TYPE,FREEZE_TIME`,
+      { "axios-retry": { retries: 4 }, noErrorMessage: true }
+    ),
+    httpApp.get<Store["exam"]>("/api/ecs_exam_work/exam/" + examId, {
+      "axios-retry": { retries: 4 },
+      noErrorMessage: true,
+    }),
+    httpApp.get<PaperStruct>(
+      "/api/ecs_oe_student/examRecordPaperStruct/getExamRecordPaperStruct?examRecordDataId=" +
+        examRecordDataId,
+      { "axios-retry": { retries: 4 }, noErrorMessage: true }
+    ),
+    httpApp.get<ExamQuestion[]>(
+      "/api/ecs_oe_student/examQuestion/findExamQuestionList",
+      { "axios-retry": { retries: 4 }, noErrorMessage: true }
+    ),
+    httpApp.get<string>(
+      "/api/ecs_oe_student/examControl/courseName/" + examRecordDataId,
+      { "axios-retry": { retries: 4 }, noErrorMessage: true }
+    ),
+  ]);
+  store.exam.courseName = _courseName;
+
+  let examQuestionList = examQuestionListOrig;
+
+  logger({
+    cnl: ["server", "local"],
+    pgn: "答题页面",
+    dtl: `end${typeof Object.fromEntries === "function" ? " " : " "}initData`,
+  });
+
+  dimensionLog("答题页面");
+
+  if (exam.examType === "PRACTICE") {
+    exam.practiceType = examProp.PRACTICE_TYPE as PRACTICE_TYPE;
+  }
+
+  exam.freezeTime = JSON.parse("" + examProp.FREEZE_TIME);
+  examProp.SNAPSHOT_INTERVAL = JSON.parse("" + examProp.SNAPSHOT_INTERVAL);
+
+  exam.WEIXIN_ANSWER_ENABLED = weixinAnswerEnabled;
+
+  store.exam.faceCheckEnabled = faceCheckEnabled;
+  store.exam.faceLivenessEnabled = faceLivenessEnabled;
+
+  logger({
+    cnl: ["server", "local"],
+    pgn: "答题页面",
+    ext: {
+      examRecordDataId: examRecordDataId,
+      faceCheckEnabled: faceCheckEnabled,
+      faceLivenessEnabled: faceLivenessEnabled,
+      WEIXIN_ANSWER_ENABLED: exam.WEIXIN_ANSWER_ENABLED,
+      SNAPSHOT_INTERVAL: examProp.SNAPSHOT_INTERVAL,
+      PRACTICE_TYPE: examProp.PRACTICE_TYPE,
+      FREEZE_TIME: examProp.FREEZE_TIME,
+    },
+  });
+
+  // parentQuestionBody
+  //     questionUnitWrapperList
+  //         questionBody  => from examQuestionList
+  //         questionUnitList =>
+  //         studentAnswer
+  //         rightAnswer
+
+  // init subNumber
+  let questionId: string | null = null;
+  let i = 1;
+
+  examQuestionList = examQuestionList.map((eq) => {
+    if (questionId == eq.questionId) {
+      eq.subNumber = i++;
+    } else {
+      i = 1;
+      questionId = eq.questionId;
+      eq.subNumber = i++;
+    }
+    return eq;
+  });
+
+  let groupOrder = 1;
+  let mainNumber = 0;
+  examQuestionList = examQuestionList.map((eq) => {
+    if (mainNumber == eq.mainNumber) {
+      eq.inGroupOrder = groupOrder++;
+    } else {
+      mainNumber = eq.mainNumber;
+      groupOrder = 1;
+      eq.inGroupOrder = groupOrder++;
+    }
+
+    const questionWrapperList =
+      paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1]
+        .questionWrapperList;
+    const groupName =
+      paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1].groupName;
+    const groupTotal = questionWrapperList.reduce(
+      (accumulator, questionWrapper) =>
+        accumulator + questionWrapper.questionUnitWrapperList.length,
+      0
+    );
+
+    eq.groupName = groupName;
+    eq.groupTotal = groupTotal;
+    return eq;
+  });
+
+  store.exam.examQuestionList = examQuestionList.map((eq) => {
+    const paperStructQuestion = paperStruct.defaultPaper.questionGroupList[
+      eq.mainNumber - 1
+    ].questionWrapperList.find((q) => q.questionId === eq.questionId);
+    return Object.assign(eq, {
+      limitedPlayTimes: paperStructQuestion!.limitedPlayTimes,
+    });
+  });
+
+  Object.assign(store.exam, exam);
+  store.exam.paperStruct = paperStruct;
+  // TODO: 此处类型待优化
+  store.exam.allAudioPlayTimes =
+    JSON.parse(
+      store.exam.examQuestionList[0].audioPlayTimes as unknown as string
+    ) || [];
+
+  // this.updateExamState({
+  //   exam: exam,
+  //   paperStruct: paperStruct,
+  //   examQuestionList: examQuestionList,
+  //   allAudioPlayTimes: JSON.parse(examQuestionList[0].audioPlayTimes) || [],
+  //   questionAnswerFileUrl: [],
+  //   pictureAnswer: {},
+  // });
+  // console.log(examQuestionList);
+  // console.log(examQuestionList.find(v => v.answerType === "SINGLE_AUDIO"));
+}

+ 78 - 0
src/features/OnlineExam/Examing/setups/useSubmitPaper.tsx

@@ -0,0 +1,78 @@
+import router from "@/router";
+import { useTimers } from "@/setups/useTimers";
+import { store } from "@/store/store";
+import { answerAllQuestions } from "./useAnswerQuestions";
+import { useFaceCompare } from "./useFaceCompare";
+
+export function useRealSubmitPaper(examId: number, examRecordDataId: number) {
+  const { addTimeout } = useTimers();
+  const { doSnap } = useFaceCompare();
+
+  async function userSubmitPaper() {
+    logger({ cnl: ["server", "local", "console"], act: "学生点击交卷" });
+    try {
+      // 交卷前强制提交所有答案
+      const ret = await answerAllQuestions(true);
+      if (!ret) {
+        // 提交答案失败,停止交卷逻辑。
+        return;
+      }
+    } catch (error) {
+      return;
+    }
+
+    if (
+      store.exam.freezeTime &&
+      store.exam.remainTime >
+        (store.exam.duration - store.exam.freezeTime) * 60 * 1000
+    ) {
+      $message.info(`考试开始${store.exam.freezeTime}分钟后才允许交卷。`);
+      return;
+    }
+
+    const answered = store.exam.examQuestionList.filter(
+      (q) => q.studentAnswer !== null
+    ).length;
+    const unanswered = store.exam.examQuestionList.filter(
+      (q) => q.studentAnswer === null
+    ).length;
+    const signed = store.exam.examQuestionList.filter((q) => q.isSign).length;
+    $dialog.info({
+      title: "确认交卷",
+      content: () => (
+        <div>
+          <p>已答题目:{answered}</p>
+          <p>未答题目:{unanswered}</p>
+          <p>标记题目:{signed}</p>
+        </div>
+      ),
+      positiveText: "确定",
+      onPositiveClick: () => {
+        void realSubmitPaper();
+      },
+    });
+  }
+
+  function realSubmitPaper() {
+    store.increaseGlobalMaskCount("realSubmitPaper");
+    store.spinMessage = "正在交卷,请耐心等待...";
+    logger({ cnl: ["server"], act: "正在交卷,请耐心等待..." });
+    if (store.exam.faceCheckEnabled) {
+      logger({ cnl: ["server"], act: "交卷前抓拍" });
+      doSnap();
+    }
+    // 给抓拍照片多5秒处理时间
+    // 和下行注释的sleep语句不是一样的。sleep之后还可以执行。加上clearTimeout则可拒绝。
+    // await new Promise(resolve => setTimeout(() => resolve(), delay * 1000));
+    addTimeout(() => {
+      store.decreaseGlobalMaskCount("realSubmitPaper");
+      store.spinMessage = "";
+      void router.push({
+        name: "SubmitPaper",
+        params: { examId, examRecordDataId },
+      });
+    }, 5 * 1000);
+  }
+
+  return { userSubmitPaper, realSubmitPaper };
+}

+ 87 - 0
src/features/OnlineExam/Examing/setups/useWXSocket.ts

@@ -0,0 +1,87 @@
+import { WEBSOCKET_FOR_AUDIO } from "@/constants/constants";
+import { useWebSocket } from "@/setups/useWebSocket";
+import { store } from "@/store/store";
+import { watch } from "vue";
+
+function onAudioAnswer(event: MessageEvent<string>) {
+  let res: {
+    eventType: string;
+    isSuccess: boolean;
+    errorMessage: string;
+    data: { order: number; fileUrl: string; transferFileType: string };
+  };
+  try {
+    res = JSON.parse(event.data).content;
+  } catch (error) {
+    logger({
+      cnl: ["server"],
+      act: "JSON.parse出错",
+      possibleError: error,
+    });
+    return;
+  }
+  if (!res) {
+    logger({
+      cnl: ["server"],
+      act: "onAudioAnswer",
+      dtl: "ws message format error",
+      ext: { event: JSON.stringify(event) },
+    });
+    return;
+  }
+  if (res.eventType && res.eventType !== "HEARTBEAT" && !res.isSuccess) {
+    $message.error(res.errorMessage, { duration: 10, closable: true });
+    logger({
+      cnl: ["server"],
+      act: "onAudioAnswer",
+      dtl: "error from server",
+      stk: res.errorMessage,
+    });
+    return;
+  }
+  switch (res.eventType) {
+    case "HEARTBEAT":
+      logger({
+        cnl: ["server"],
+        lvl: "debug",
+        act: "ws heartbeat response from server",
+      });
+      break;
+    case "SCAN_QR_CODE":
+      logger({ cnl: ["server"], act: "二维码被扫描" });
+      store.setQuestionQrCodeScanned({ order: res.data.order });
+      break;
+    case "GET_FILE_ANSWER":
+      logger({ cnl: ["server"], act: "获得音频地址" });
+      store.setQuestionFileAnswerUrl(res.data);
+      break;
+    case "SYSTEM_ERROR":
+      logger({
+        cnl: ["server"],
+        act: "ws get error",
+        ejn: JSON.stringify(res),
+      });
+      break;
+  }
+}
+
+export function useWXSocket() {
+  const { startWS } = useWebSocket();
+
+  watch(
+    () => store.exam.WEIXIN_ANSWER_ENABLED,
+    () => {
+      if (!store.exam.WEIXIN_ANSWER_ENABLED) return;
+
+      // init data
+      store.exam.questionAnswerFileUrl = [];
+      startWS(
+        WEBSOCKET_FOR_AUDIO +
+          `?key=${store.user.key}&token=${store.user.token}`,
+        onAudioAnswer,
+        "微信小程序作答socket"
+      );
+    },
+    { immediate: true }
+  );
+}

+ 10 - 1
vite.config.ts

@@ -64,7 +64,16 @@ export default defineConfig({
   },
   resolve: {
     alias: [{ find: "@", replacement: path.resolve(__dirname, "./src") }],
-    extensions: [".js", ".mjs", ".ts", ".vue", ".json", ".scss", ".css"],
+    extensions: [
+      ".js",
+      ".mjs",
+      ".ts",
+      ".tsx",
+      ".vue",
+      ".json",
+      ".scss",
+      ".css",
+    ],
   },
   build: {
     ssr: false,