浏览代码

完成交卷逻辑

Michael Wang 3 年之前
父节点
当前提交
d7506ddc1b

+ 210 - 0
src/features/OnlineExam/ExamEnd/ExamEnd.vue

@@ -0,0 +1,210 @@
+<script setup lang="ts">
+import { onlineExamDataApi } from "@/api/onlinePractice";
+import { httpApp } from "@/plugins/axiosApp";
+import router from "@/router";
+import { store } from "@/store/store";
+import { Store } from "@/types/student-client";
+import { onMounted, onUnmounted } from "vue";
+import { useRoute } from "vue-router";
+
+const route = useRoute();
+const examId = +route.params.examId;
+const examRecordDataId = +route.params.examRecordDataId;
+
+let loading = $ref(true);
+let afterExamRemark = $ref("");
+let cheatingRemark = $ref("");
+let showObjectScore = $ref(false);
+let showCheatingRemark = $ref(false);
+
+let examResult: { isWarn: boolean; objectiveScore: number } | null = null;
+
+onMounted(async function () {
+  _hmt.push(["_trackEvent", "考试结束页面", "进入页面"]);
+  logger({ cnl: ["server"], pgn: "考试结束页面", act: "进入页面" });
+
+  try {
+    loading = true;
+    if (!store.exam.examType) {
+      const exam = (await onlineExamDataApi(examId)).data;
+      store.exam.examType = exam.examType;
+
+      if (exam.examType === "PRACTICE") {
+        void router.replace(
+          `/online-practice/exam/${examId}/detail?examRecordDataId=${examRecordDataId}&disableGoBack=true`
+        );
+        return;
+      }
+    }
+    const resp = await httpApp.get(
+      "/api/ecs_exam_work/exam/getExamPropertyFromCacheByStudentSession/" +
+        examId +
+        `/AFTER_EXAM_REMARK,IS_OBJ_SCORE_VIEW,SHOW_CHEATING_REMARK,CHEATING_REMARK`
+    );
+    afterExamRemark = resp.data.AFTER_EXAM_REMARK || "";
+    cheatingRemark = resp.data.CHEATING_REMARK || "";
+    showObjectScore = resp.data.IS_OBJ_SCORE_VIEW === "true";
+    showCheatingRemark = resp.data.SHOW_CHEATING_REMARK === "true";
+
+    loading = false;
+  } catch (error) {
+    logger({
+      cnl: ["server"],
+      pgn: "考试结束页面",
+      dtl: "获取考试设置错误,请在待考列表查看成绩!",
+    });
+    $message.error("获取考试设置错误,请在待考列表查看成绩!");
+    void router.push("/");
+    return;
+  }
+
+  if (showObjectScore) {
+    await getExamResult();
+  }
+});
+
+let examResultLoading = $ref(true);
+async function getExamResult() {
+  try {
+    const startTime = Date.now();
+    for (let i = 0; i < 20; i++) {
+      examResult = (
+        await httpApp.get(
+          "/api/ecs_oe_student/examControl/getEndExamInfo?examRecordDataId=" +
+            examRecordDataId,
+          { "axios-retry": { retries: 60 }, noErrorMessage: true }
+        )
+      ).data;
+      await new Promise((res) => setTimeout(res, 5 * 1000));
+      if (examResult) {
+        break;
+      }
+    }
+
+    logger({
+      cnl: ["server"],
+      pgn: "考试结束页面",
+      dtl: "等待时间: " + (Date.now() - startTime) / 1000,
+    });
+  } catch (error) {
+    logger({
+      cnl: ["server"],
+      pgn: "考试结束页面",
+      dtl: "获取考试设置错误,请在待考列表查看成绩!",
+    });
+    $message.info("获取考试设置错误,请在待考列表查看成绩!");
+  } finally {
+    examResultLoading = false;
+  }
+}
+
+const backTo = $computed(() => {
+  const examType = store.exam.examType;
+  if (examType === "PRACTICE") {
+    return "/online-practice";
+  } else if (examType === "ONLINE_HOMEWORK") {
+    return "/online-homework";
+  }
+
+  return "/online-exam";
+});
+
+onUnmounted(() => {
+  // 清除考试中间数据
+  store.exam = {} as Store["exam"];
+});
+</script>
+
+<template>
+  <div v-if="!loading" id="exam-end" class="container">
+    <div class="instructions">
+      <h1 class="tw-text-3xl tw-font-bold tw-text-center">考试已结束</h1>
+      <div class="tw-flex tw-items-center">
+        <img class="user-avatar" :src="store.user.photoPath" alt="无底照" />
+
+        <div v-if="showObjectScore" class="tw-text-center tw-flex-1">
+          <div v-if="examResultLoading" class="score-text">
+            考试结果计算中...
+          </div>
+          <div v-else>
+            <div v-if="examResult">
+              <div v-if="examResult.isWarn" class="qm-big-text score-text">
+                客观题得分: 成绩待审核
+              </div>
+              <div v-else class="score-text tw-flex tw-items-center">
+                客观题得分:
+                <span style="color: red; font-size: 40px">{{
+                  examResult.objectiveScore
+                }}</span>
+              </div>
+            </div>
+            <div v-else class="qm-big-text score-text" style="font-size: 20px">
+              请稍后在待考列表中查看客观题得分。
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div v-if="showObjectScore" class="tw-text-center tw-flex-1">
+        <div v-if="examResult">
+          <div v-if="showCheatingRemark && examResult.isWarn">
+            <div class="tw-text-xl">违纪提示:</div>
+            <div style="text-align: left; padding-bottom: 20px">
+              <p v-html="cheatingRemark"></p>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="tw-text-xl">考后说明:</div>
+      <div style="text-align: left; padding-bottom: 20px">
+        <p v-html="afterExamRemark"></p>
+      </div>
+
+      <router-link
+        class="qm-primary-button tw-inline-block tw-text-center tw-w-full"
+        :to="backTo"
+        ondragstart="return false;"
+      >
+        返回主页
+      </router-link>
+    </div>
+  </div>
+
+  <div v-else>正在等待数据返回...</div>
+</template>
+
+<style scoped>
+.container {
+  margin: 0 auto;
+  width: 80vw;
+  overflow: auto;
+}
+
+.instructions {
+  padding: 40px 20px;
+}
+
+.instructions > h1,
+.instructions > div {
+  padding-bottom: 30px;
+}
+
+.user-avatar {
+  display: inline-block;
+  width: 140px;
+  height: 140px;
+  object-fit: contain;
+}
+
+.score-text {
+  font-size: 24px;
+}
+</style>
+
+<style>
+#exam-end img {
+  max-width: 100%;
+  height: auto !important;
+}
+</style>

+ 70 - 0
src/features/OnlineExam/ExamEnd/SubmitPaper.vue

@@ -0,0 +1,70 @@
+<script setup lang="ts">
+import { httpApp } from "@/plugins/axiosApp";
+import router from "@/router";
+import { useTimers } from "@/setups/useTimers";
+import { store } from "@/store/store";
+import { onMounted, onUnmounted } from "vue";
+import { useRoute } from "vue-router";
+
+const route = useRoute();
+const examId = route.params.examId;
+const examRecordDataId = route.params.examRecordDataId;
+
+let spentTime = $ref(0);
+const { addInterval } = useTimers();
+
+addInterval(() => spentTime++, 1000);
+
+async function realSubmitPaper() {
+  store.increaseGlobalMaskCount("SubmitPaper");
+  store.spinMessage = "正在交卷中...";
+  try {
+    await httpApp.get("/api/ecs_oe_student/examControl/endExam", {
+      "axios-retry": { retries: 10, retryDelay: () => 10 * 1000 },
+      noErrorMessage: true,
+    });
+    logger({
+      cnl: ["server"],
+      act: "交卷成功",
+      ext: { spentTime, UA: navigator.userAgent },
+    });
+  } catch (e) {
+    $message.warning("交卷失败");
+    logger({ cnl: ["server"], act: "交卷失败", possibleError: e });
+  }
+
+  // 交卷失败也转向考试结束界面,失败原因包括网络故障等
+  void router.replace({
+    path: `/online-exam/exam/${examId}/examRecordData/${examRecordDataId}/end`,
+  });
+}
+
+onMounted(() => realSubmitPaper());
+
+onUnmounted(() => {
+  store.decreaseGlobalMaskCount("SubmitPaper");
+  store.spinMessage = "";
+  try {
+    const allCount = store.exam.compareResultMap.size;
+    const remainedCount = [...store.exam.compareResultMap].filter(
+      (v) => !v[1]
+    ).length;
+    logger({
+      cnl: ["server"],
+      act: "交卷前检测抓拍照片数量",
+      dtl: allCount - remainedCount - 1 ? "不完全检测" : "完全检测",
+      ext: { allCount, remainedCount },
+    });
+  } catch (error) {
+    logger({
+      cnl: ["server"],
+      act: "SubmitPaper onUnmounted",
+      possibleError: error,
+    });
+  }
+});
+</script>
+
+<template>
+  <div></div>
+</template>

+ 17 - 16
src/features/OnlineExam/Examing/ExamingHome.vue

@@ -26,6 +26,7 @@ import { useWXSocket } from "./setups/useWXSocket";
 import { answerAllQuestions } from "./setups/useAnswerQuestions";
 import { answerAllQuestions } from "./setups/useAnswerQuestions";
 import { useRealSubmitPaper } from "./setups/useSubmitPaper";
 import { useRealSubmitPaper } from "./setups/useSubmitPaper";
 import { Store } from "@/types/student-client";
 import { Store } from "@/types/student-client";
+import { closeMediaStream } from "@/utils/camera";
 
 
 const { addTimeout, addInterval } = useTimers();
 const { addTimeout, addInterval } = useTimers();
 
 
@@ -51,6 +52,7 @@ onBeforeUpdate(() => {
 watch(
 watch(
   () => [route.params, store.exam.examQuestionList],
   () => [route.params, store.exam.examQuestionList],
   () => {
   () => {
+    if (!store.exam.examQuestionList) return;
     const q = store.exam.examQuestionList.find(
     const q = store.exam.examQuestionList.find(
       (eq) => eq.order === +route.params.order
       (eq) => eq.order === +route.params.order
     );
     );
@@ -137,6 +139,10 @@ watch(
 //   },
 //   },
 
 
 onMounted(async () => {
 onMounted(async () => {
+  // 清除过时考试数据
+  store.exam = {} as Store["exam"];
+  store.exam.compareResultMap = new Map();
+
   logger({
   logger({
     cnl: ["server", "local"],
     cnl: ["server", "local"],
     pgn: "答题页面",
     pgn: "答题页面",
@@ -169,33 +175,28 @@ onMounted(async () => {
 let { snapId, doSnap, showSnapResult } = useFaceCompare();
 let { snapId, doSnap, showSnapResult } = useFaceCompare();
 let { showFaceId } = useFaceLive(doSnap);
 let { showFaceId } = useFaceLive(doSnap);
 
 
-const cmpResMap = new Map<string, boolean>();
 type CompareResult = { hasError: boolean; fileName: string };
 type CompareResult = { hasError: boolean; fileName: string };
 function onCompareResult({ hasError, fileName }: CompareResult) {
 function onCompareResult({ hasError, fileName }: CompareResult) {
   if (hasError) {
   if (hasError) {
     // 60秒后重试抓拍
     // 60秒后重试抓拍
     addInterval(doSnap, 60 * 1000);
     addInterval(doSnap, 60 * 1000);
   } else {
   } else {
-    cmpResMap.set(fileName, false);
-    void showSnapResult(fileName, examRecordDataId, cmpResMap);
+    store.exam.compareResultMap.set(fileName, false);
+    void showSnapResult(fileName, examRecordDataId);
   }
   }
 }
 }
 //#endregion 人脸抓拍与活体检测
 //#endregion 人脸抓拍与活体检测
 
 
 onUnmounted(() => {
 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,
-    },
-  });
-
-  // 清除考试中间数据
-  store.exam = {} as Store["exam"];
+  if (store.exam.faceCheckEnabled) {
+    // debug级别备选。因为初期上线,摄像头比较容易出错,所以保留此日志
+    logger({
+      cnl: ["server", "console"],
+      pgn: "答题页面",
+      act: "关闭sharedtream",
+    });
+    closeMediaStream();
+  }
 });
 });
 
 
 //#region 提交答案与交卷
 //#region 提交答案与交卷

+ 18 - 4
src/features/OnlineExam/Examing/FaceTracking.vue

@@ -2,7 +2,7 @@
 import * as faceapi from "face-api.js";
 import * as faceapi from "face-api.js";
 import { FACE_API_MODEL_PATH } from "@/constants/constants";
 import { FACE_API_MODEL_PATH } from "@/constants/constants";
 import { isThisMachineOwnByStudent } from "@/utils/utils";
 import { isThisMachineOwnByStudent } from "@/utils/utils";
-import { onMounted } from "vue";
+import { onMounted, onUnmounted } from "vue";
 import { useTimers } from "@/setups/useTimers";
 import { useTimers } from "@/setups/useTimers";
 import { store } from "@/store/store";
 import { store } from "@/store/store";
 import { throttle } from "lodash-es";
 import { throttle } from "lodash-es";
@@ -24,9 +24,19 @@ const { addTimeout, addInterval } = useTimers();
 // })();
 // })();
 
 
 onMounted(async () => {
 onMounted(async () => {
-  await faceapi.nets.tinyFaceDetector.load(FACE_API_MODEL_PATH);
-  // faceapi.nets.faceRecognitionNet.load(modelsPath);
-  await faceapi.loadFaceLandmarkModel(FACE_API_MODEL_PATH);
+  try {
+    await faceapi.nets.tinyFaceDetector.load(FACE_API_MODEL_PATH);
+    // faceapi.nets.faceRecognitionNet.load(modelsPath);
+    await faceapi.loadFaceLandmarkModel(FACE_API_MODEL_PATH);
+  } catch (error) {
+    logger({
+      cnl: ["server"],
+      key: "FaceTracking",
+      act: "loading faceapi error",
+      possibleError: error,
+    });
+    return;
+  }
   faceapi.tf.ENV.set("WEBGL_PACK", false);
   faceapi.tf.ENV.set("WEBGL_PACK", false);
 
 
   async function trackHead() {
   async function trackHead() {
@@ -398,6 +408,10 @@ async function detectFaces() {
     await detectFaces();
     await detectFaces();
   }, 20 * 1000);
   }, 20 * 1000);
 }
 }
+
+onUnmounted(() => {
+  clearTimeout(detectFacesTimeout);
+});
 </script>
 </script>
 
 
 <template>
 <template>

+ 0 - 51
src/features/OnlineExam/Examing/SubmitPaper.vue

@@ -1,51 +0,0 @@
-<script setup lang="ts">
-import { httpApp } from "@/plugins/axiosApp";
-import router from "@/router";
-import { onMounted } from "vue";
-import { useRoute } from "vue-router";
-
-const route = useRoute();
-const examId = route.params.examId;
-const examRecordDataId = route.params.examRecordDataId;
-
-const __submitPaperStartTime = Date.now();
-
-async function realSubmitPaper() {
-  // if (this.snapProcessingCount > 0) {
-  //   if (this.submitCount < 200) {
-  //     // 一分钟后,强制交卷
-  //     console.log("一分钟后,强制交卷");
-  //     setTimeout(() => realSubmitPaper(), 300);
-  //     return;
-  //   }
-  // }
-  try {
-    await httpApp.get("/api/ecs_oe_student/examControl/endExam", {
-      "axios-retry": { retries: 100, retryDelay: () => 10 * 1000 },
-      noErrorMessage: true,
-    });
-    logger({
-      cnl: ["server"],
-      act: "交卷成功",
-      ext: {
-        cost: Date.now() - __submitPaperStartTime,
-        UA: navigator.userAgent,
-      },
-    });
-  } catch (e) {
-    $message.error("交卷失败");
-    logger({ cnl: ["server"], act: "交卷失败", possibleError: e });
-  }
-
-  // 交卷失败也转向考试结束界面,失败原因包括网络故障等
-  void router.replace({
-    path: `/online-exam/exam/${examId}/examRecordData/${examRecordDataId}/end`,
-  });
-}
-
-onMounted(() => realSubmitPaper());
-</script>
-
-<template>
-  <div>交卷中</div>
-</template>

+ 6 - 5
src/features/OnlineExam/Examing/setups/useFaceCompare.ts

@@ -94,12 +94,13 @@ export function useFaceCompare() {
 
 
   async function showSnapResult(
   async function showSnapResult(
     fileName: string,
     fileName: string,
-    examRecordDataId: string | number,
-    cmpRes: Map<string, boolean>
+    examRecordDataId: string | number
   ) {
   ) {
     if (!fileName) return; // 交卷后提交照片会得不到照片名称
     if (!fileName) return; // 交卷后提交照片会得不到照片名称
 
 
     try {
     try {
+      // 给后台10秒的处理时间
+      await new Promise((res) => setTimeout(res, 10 * 1000));
       // 获取抓拍结果
       // 获取抓拍结果
       const snapRes =
       const snapRes =
         (
         (
@@ -117,11 +118,11 @@ export function useFaceCompare() {
         } else if (!snapRes.isPass) {
         } else if (!snapRes.isPass) {
           $message.error("请调整坐姿,诚信考试");
           $message.error("请调整坐姿,诚信考试");
         }
         }
-        cmpRes.set(fileName, true);
+        store.exam.compareResultMap.set(fileName, true);
       } else {
       } else {
         addTimeout(
         addTimeout(
-          showSnapResult.bind(null, fileName, examRecordDataId, cmpRes),
-          30 * 1000
+          showSnapResult.bind(null, fileName, examRecordDataId),
+          20 * 1000
         );
         );
       }
       }
     } catch (e) {
     } catch (e) {

+ 19 - 3
src/features/OnlineExam/Examing/setups/useSubmitPaper.tsx

@@ -53,15 +53,31 @@ export function useRealSubmitPaper(examId: number, examRecordDataId: number) {
     });
     });
   }
   }
 
 
-  function realSubmitPaper() {
+  // 多种条件都可能触发交卷,所以要有一个锁来避免重复进入
+  let sumbitLock = false;
+  async function realSubmitPaper() {
+    if (sumbitLock) {
+      logger({ cnl: ["server"], act: "交卷", stk: "竟然有锁!" });
+      return;
+    } else {
+      sumbitLock = true;
+    }
     store.increaseGlobalMaskCount("realSubmitPaper");
     store.increaseGlobalMaskCount("realSubmitPaper");
     store.spinMessage = "正在交卷,请耐心等待...";
     store.spinMessage = "正在交卷,请耐心等待...";
     logger({ cnl: ["server"], act: "正在交卷,请耐心等待..." });
     logger({ cnl: ["server"], act: "正在交卷,请耐心等待..." });
+    let delay = 0;
+    const oldSnapCount = store.exam.compareResultMap.size;
     if (store.exam.faceCheckEnabled) {
     if (store.exam.faceCheckEnabled) {
       logger({ cnl: ["server"], act: "交卷前抓拍" });
       logger({ cnl: ["server"], act: "交卷前抓拍" });
       doSnap();
       doSnap();
+      await new Promise((resolve) => setTimeout(resolve, 3 * 1000));
+      const newSnapCount = store.exam.compareResultMap.size;
+      if (newSnapCount - oldSnapCount === 0) {
+        // 给抓拍照片多2秒处理时间
+        delay = 8;
+        logger({ cnl: ["server"], act: "交卷前抓拍", dtl: "3秒内未上传完毕" });
+      }
     }
     }
-    // 给抓拍照片多5秒处理时间
     // 和下行注释的sleep语句不是一样的。sleep之后还可以执行。加上clearTimeout则可拒绝。
     // 和下行注释的sleep语句不是一样的。sleep之后还可以执行。加上clearTimeout则可拒绝。
     // await new Promise(resolve => setTimeout(() => resolve(), delay * 1000));
     // await new Promise(resolve => setTimeout(() => resolve(), delay * 1000));
     addTimeout(() => {
     addTimeout(() => {
@@ -71,7 +87,7 @@ export function useRealSubmitPaper(examId: number, examRecordDataId: number) {
         name: "SubmitPaper",
         name: "SubmitPaper",
         params: { examId, examRecordDataId },
         params: { examId, examRecordDataId },
       });
       });
-    }, 5 * 1000);
+    }, delay * 1000);
   }
   }
 
 
   return { userSubmitPaper, realSubmitPaper };
   return { userSubmitPaper, realSubmitPaper };

+ 7 - 1
src/router/index.ts

@@ -3,7 +3,8 @@ import UserLogin from "@/features/UserLogin/UserLogin.vue";
 import MainLayout from "@/components/MainLayout/MainLayout.vue";
 import MainLayout from "@/components/MainLayout/MainLayout.vue";
 import OnlineExam from "@/features/OnlineExam/OnlineExamHome.vue";
 import OnlineExam from "@/features/OnlineExam/OnlineExamHome.vue";
 import ExamingHome from "@/features/OnlineExam/Examing/ExamingHome.vue";
 import ExamingHome from "@/features/OnlineExam/Examing/ExamingHome.vue";
-import SubmitPaper from "@/features/OnlineExam/Examing/SubmitPaper.vue";
+import SubmitPaper from "@/features/OnlineExam/ExamEnd/SubmitPaper.vue";
+import ExamEnd from "@/features/OnlineExam/ExamEnd/ExamEnd.vue";
 import OnlineExamOverview from "@/features/OnlineExam/OnlineExamOverview/OnlineExamOverview.vue";
 import OnlineExamOverview from "@/features/OnlineExam/OnlineExamOverview/OnlineExamOverview.vue";
 import WelcomePage from "@/features/WelcomePage/WelcomePage.vue";
 import WelcomePage from "@/features/WelcomePage/WelcomePage.vue";
 import ChangePassword from "@/features/ChangePassword/ChangePassword.vue";
 import ChangePassword from "@/features/ChangePassword/ChangePassword.vue";
@@ -84,6 +85,11 @@ const routes: RouteRecordRaw[] = [
     name: "SubmitPaper",
     name: "SubmitPaper",
     component: SubmitPaper,
     component: SubmitPaper,
   },
   },
+  {
+    path: "/online-exam/exam/:examId/examRecordData/:examRecordDataId/end",
+    name: "ExamEnd",
+    component: ExamEnd,
+  },
   {
   {
     path: "/:pathMatch(.*)*",
     path: "/:pathMatch(.*)*",
     name: "NotFound",
     name: "NotFound",

+ 1 - 1
src/styles/cssvar.css

@@ -32,7 +32,7 @@
 
 
   --app-line-height: 20px;
   --app-line-height: 20px;
 
 
-  --app-min-width: 1280px;
+  --app-min-width: 800px;
 
 
   --app-font-family: "Helvetica Neue", Helvetica, "PingFang SC",
   --app-font-family: "Helvetica Neue", Helvetica, "PingFang SC",
     "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;
     "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;

+ 1 - 1
src/styles/global.css

@@ -60,7 +60,7 @@ body {
   height: 36px;
   height: 36px;
   font-size: var(--app-font-size);
   font-size: var(--app-font-size);
   color: var(--app-color-white);
   color: var(--app-color-white);
-  background-color: var(--app-color-primary);
+  background-color: var(--app-color-success);
   border-radius: var(--app-border-radius);
   border-radius: var(--app-border-radius);
   padding: 0 30px;
   padding: 0 30px;
   line-height: 36px;
   line-height: 36px;

+ 1 - 0
src/types/student-client.d.ts

@@ -152,6 +152,7 @@ export type Store = {
     isDoingFaceLiveness: boolean;
     isDoingFaceLiveness: boolean;
     /** 抓拍间隔(秒) */
     /** 抓拍间隔(秒) */
     SNAPSHOT_INTERVAL: number;
     SNAPSHOT_INTERVAL: number;
+    compareResultMap: Map<string, boolean>;
     /** 考试冻结(秒) */
     /** 考试冻结(秒) */
     freezeTime: number;
     freezeTime: number;
     /** 考试时长(秒) */
     /** 考试时长(秒) */