Browse Source

补充定时抓拍逻辑

Michael Wang 3 years ago
parent
commit
a5f2e8de56

+ 3 - 0
.eslintrc.js

@@ -42,6 +42,9 @@ module.exports = {
     "vue/no-v-html": "off",
     "vue/no-v-html": "off",
   },
   },
   ignorePatterns: [
   ignorePatterns: [
+    // FIXME: ignore lang="tsx" don't know how to fix
+    "ExamingHome.vue",
+    "RemainTime.vue",
     ".eslintrc.js",
     ".eslintrc.js",
     "vite.config.ts",
     "vite.config.ts",
     "vitest.config.ts",
     "vitest.config.ts",

+ 91 - 182
src/features/OnlineExam/Examing/ExamingHome.vue

@@ -14,7 +14,7 @@ import { httpApp } from "@/plugins/axiosApp";
 import { useTimers } from "@/setups/useTimers";
 import { useTimers } from "@/setups/useTimers";
 import { checkMainExe } from "@/utils/nativeMethods";
 import { checkMainExe } from "@/utils/nativeMethods";
 import { showLogout } from "@/utils/utils";
 import { showLogout } from "@/utils/utils";
-import { defineComponent, onBeforeUpdate, onMounted, watch } from "vue";
+import { onBeforeUpdate, onMounted, watch } from "vue";
 import { useRoute } from "vue-router";
 import { useRoute } from "vue-router";
 import { store } from "@/store/store";
 import { store } from "@/store/store";
 import { useRemoteAppChecker } from "@/features/UserLogin/useRemoteAppChecker";
 import { useRemoteAppChecker } from "@/features/UserLogin/useRemoteAppChecker";
@@ -23,6 +23,8 @@ import router from "@/router";
 import { useWebSocket } from "@/setups/useWebSocket";
 import { useWebSocket } from "@/setups/useWebSocket";
 import { useScreenTop } from "./setups/useScreenTop";
 import { useScreenTop } from "./setups/useScreenTop";
 import { useFaceLive } from "./setups/useFaceLive";
 import { useFaceLive } from "./setups/useFaceLive";
+import { dimensionLog } from "@/utils/logger";
+import { useFaceCompare } from "./setups/useFaceCompare";
 
 
 const { startWS } = useWebSocket();
 const { startWS } = useWebSocket();
 
 
@@ -48,10 +50,7 @@ watch(
   () => store.exam.isExceededSwitchCount,
   () => store.exam.isExceededSwitchCount,
   () => {
   () => {
     logger({ cnl: ["server"], act: "切屏超出次数自动交卷" });
     logger({ cnl: ["server"], act: "切屏超出次数自动交卷" });
-    void router.push({
-      name: "SubmitPaper",
-      params: { examId, examRecordDataId },
-    });
+    void realSubmitPaper();
   }
   }
 );
 );
 
 
@@ -270,10 +269,7 @@ async function initData() {
     ),
     ),
     httpApp.get<ExamQuestion[]>(
     httpApp.get<ExamQuestion[]>(
       "/api/ecs_oe_student/examQuestion/findExamQuestionList",
       "/api/ecs_oe_student/examQuestion/findExamQuestionList",
-      {
-        "axios-retry": { retries: 4 },
-        noErrorMessage: true,
-      }
+      { "axios-retry": { retries: 4 }, noErrorMessage: true }
     ),
     ),
     httpApp.get<string>(
     httpApp.get<string>(
       "/api/ecs_oe_student/examControl/courseName/" + examRecordDataId,
       "/api/ecs_oe_student/examControl/courseName/" + examRecordDataId,
@@ -289,34 +285,8 @@ async function initData() {
     pgn: "答题页面",
     pgn: "答题页面",
     dtl: `end${typeof Object.fromEntries === "function" ? " " : " "}initData`,
     dtl: `end${typeof Object.fromEntries === "function" ? " " : " "}initData`,
   });
   });
-  logger({
-    cnl: ["server", "local"],
-    act: "答题页面dimension",
-    ext: {
-      scrollX: window.scrollX,
-      scrollY: window.scrollY,
-      width: window.screen.width,
-      height: window.screen.height,
-      screenX: window.screen.availWidth,
-      screenY: window.screen.availHeight,
-      clientWidth: document.documentElement.clientWidth,
-      clientHeight: document.documentElement.clientHeight,
-      windowInnerWidth: window.innerWidth,
-      windowInnerHeight: window.innerHeight,
-      windowOuterWidth: window.outerWidth,
-      windowOuterHeight: window.outerHeight,
-      // 是否全屏
-      equal1:
-        "dimesion1" +
-        (window.screen.width === window.outerWidth &&
-          window.screen.height === window.outerHeight),
-      // 是否打开了调试窗口
-      equal2:
-        "dimesion2" +
-        (window.innerWidth === window.outerWidth &&
-          window.innerHeight === window.outerHeight),
-    },
-  });
+
+  dimensionLog("答题页面");
 
 
   if (exam.examType === "PRACTICE") {
   if (exam.examType === "PRACTICE") {
     exam.practiceType = examProp.PRACTICE_TYPE as PRACTICE_TYPE;
     exam.practiceType = examProp.PRACTICE_TYPE as PRACTICE_TYPE;
@@ -330,75 +300,6 @@ async function initData() {
   store.exam.faceCheckEnabled = faceCheckEnabled;
   store.exam.faceCheckEnabled = faceCheckEnabled;
   store.exam.faceLivenessEnabled = faceLivenessEnabled;
   store.exam.faceLivenessEnabled = faceLivenessEnabled;
 
 
-  // if (faceCheckEnabled) {
-  //   let initSnapshotTrialTimes = 0;
-  //   this.initSnapInterval = setInterval(() => {
-  //     const video = document.getElementById("video");
-  //     const videoStartFailed =
-  //       !video || video.readyState !== 4 || !video.srcObject.active;
-  //     if (videoStartFailed && initSnapshotTrialTimes < 5) {
-  //       initSnapshotTrialTimes++;
-  //       this.logger({
-  //         action: "答题页面",
-  //         detail:
-  //           "进入考试后60秒内抓拍-" + `(第${initSnapshotTrialTimes}次尝试)`,
-  //       });
-  //     } else {
-  //       // 超过6次后,强行抓拍,如果抓拍不成功,则会因抓拍不成功而退出。
-  //       clearInterval(this.initSnapInterval);
-
-  //       if (videoStartFailed) {
-  //         this.logger({
-  //           action: "答题页面",
-  //           detail: "摄像头没有正常启用-进入考试抓拍",
-  //         });
-  //         this.$Message.error({
-  //           content: "摄像头没有正常启用",
-  //           duration: 5,
-  //           closable: true,
-  //         });
-  //         window._hmt.push([
-  //           "_trackEvent",
-  //           "摄像头框",
-  //           "摄像头状态",
-  //           "摄像头没有正常启用-进入考试抓拍",
-  //         ]);
-
-  //         this.logout("?LogoutReason=" + "摄像头没有正常启用-退出");
-  //       } else {
-  //         this.logger({
-  //           action: "答题页面",
-  //           detail:
-  //             "进入考试后60秒内抓拍-" +
-  //             `(第${initSnapshotTrialTimes + 1}次尝试成功)`,
-  //         });
-  //         this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
-  //       }
-  //     }
-  //   }, 10 * 1000);
-
-  //   //       let initSnapshotTrialTimes = 0;
-  //   // const initSnapshot = setTimeout(() => {
-  //   //   if (this.exam || initSnapshotTrialTimes < 6) {
-  //   //     this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
-  //   //   } else {
-  //   //     setTimeout(() => initSnapshot(), 5 * 1000);
-  //   //   }
-  //   // }, 5 * 1000);
-
-  //   if (examProp.SNAPSHOT_INTERVAL) {
-  //     // 考务设置抓拍间隔
-  //     this.snapInterval = setInterval(() => {
-  //       this.logger({
-  //         action: "答题页面",
-  //         detail: "定时抓拍",
-  //         SNAPSHOT_INTERVAL: examProp.SNAPSHOT_INTERVAL,
-  //       });
-  //       this.toggleSnapNow();
-  //     },  examProp.SNAPSHOT_INTERVAL * 60 * 1000);
-  //   }
-  // }
-
   logger({
   logger({
     cnl: ["server", "local"],
     cnl: ["server", "local"],
     pgn: "答题页面",
     pgn: "答题页面",
@@ -490,65 +391,6 @@ async function initData() {
   // console.log(examQuestionList);
   // console.log(examQuestionList);
   // console.log(examQuestionList.find(v => v.answerType === "SINGLE_AUDIO"));
   // console.log(examQuestionList.find(v => v.answerType === "SINGLE_AUDIO"));
 
 
-  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":
-        console.log("get file url", res);
-        logger({ cnl: ["server"], act: "获得音频地址" });
-        store.setQuestionFileAnswerUrl(res.data);
-        break;
-      case "SYSTEM_ERROR":
-        console.log("ws get error", res);
-        logger({
-          cnl: ["server"],
-          act: "ws get error",
-          ejn: JSON.stringify(res),
-        });
-        break;
-    }
-  }
   if (exam.WEIXIN_ANSWER_ENABLED) {
   if (exam.WEIXIN_ANSWER_ENABLED) {
     // init data
     // init data
     store.exam.questionAnswerFileUrl = [];
     store.exam.questionAnswerFileUrl = [];
@@ -560,7 +402,81 @@ async function initData() {
   }
   }
 }
 }
 
 
-let { showFaceId } = useFaceLive();
+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);
+
+function onCompareResult({
+  hasError,
+  fileName,
+}: {
+  hasError: boolean;
+  fileName: string;
+}) {
+  if (hasError) {
+    // 60秒后重试抓拍
+    addInterval(doSnap, 60 * 1000);
+  } else {
+    showSnapResult(fileName, examRecordDataId);
+  }
+}
 
 
 // async function updateQuestion(next) {
 // async function updateQuestion(next) {
 //   // 初始化套题的答案,为回填部分选项做准备
 //   // 初始化套题的答案,为回填部分选项做准备
@@ -666,27 +582,20 @@ async function submitPaper() {
     ),
     ),
     positiveText: "确定",
     positiveText: "确定",
     onPositiveClick: () => {
     onPositiveClick: () => {
-      void realSubmitPaper(showConfirmTime);
+      void realSubmitPaper();
     },
     },
   });
   });
 }
 }
 
 
-function realSubmitPaper(showConfirmTime = 0) {
+function realSubmitPaper() {
   store.increaseGlobalMaskCount("realSubmitPaper");
   store.increaseGlobalMaskCount("realSubmitPaper");
   store.spinMessage = "正在交卷,请耐心等待...";
   store.spinMessage = "正在交卷,请耐心等待...";
   logger({ cnl: ["server"], act: "正在交卷,请耐心等待..." });
   logger({ cnl: ["server"], act: "正在交卷,请耐心等待..." });
   if (store.exam.faceCheckEnabled) {
   if (store.exam.faceCheckEnabled) {
     logger({ cnl: ["server"], act: "交卷前抓拍" });
     logger({ cnl: ["server"], act: "交卷前抓拍" });
-    // this.toggleSnapNow();
-  }
-  // 确保抓拍指令在交卷前执行,同时确保5秒间隔提交答案的指令执行了
-  let delay = 5 - (Date.now() - showConfirmTime) / 1000;
-  if (delay < 0) {
-    // 如果用户已经看确认框超过5秒,或者不是由确认框进来的,不延迟
-    delay = 0;
+    doSnap();
   }
   }
-  // 给抓拍照片多一秒处理时间
-  delay = delay + 1;
+  // 给抓拍照片多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(() => {
@@ -696,15 +605,12 @@ function realSubmitPaper(showConfirmTime = 0) {
       name: "SubmitPaper",
       name: "SubmitPaper",
       params: { examId, examRecordDataId },
       params: { examId, examRecordDataId },
     });
     });
-  }, delay * 1000);
+  }, 5 * 1000);
 }
 }
 
 
 function shouldSubmitPaper() {
 function shouldSubmitPaper() {
   logger({ cnl: ["server"], act: "时间到自动交卷" });
   logger({ cnl: ["server"], act: "时间到自动交卷" });
-  void router.push({
-    name: "SubmitPaper",
-    params: { examId, examRecordDataId },
-  });
+  void realSubmitPaper();
 }
 }
 
 
 // const examQuestion = $computed(() =>
 // const examQuestion = $computed(() =>
@@ -768,6 +674,9 @@ addInterval(() => checkRemoteApp(), 3 * 60 * 1000);
           width="400"
           width="400"
           height="300"
           height="300"
           :showRecognizeButton="false"
           :showRecognizeButton="false"
+          :examRecordDataId="examRecordDataId"
+          :snapId="snapId"
+          @on-async-recognize-result="onCompareResult"
         />
         />
       </div>
       </div>
     </div>
     </div>

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

@@ -41,7 +41,11 @@ onMounted(async () => {
       });
       });
       return;
       return;
     }
     }
-    logger({ cnl: ["server", "console"], act: "start tracking ... " });
+    logger({
+      cnl: ["server", "console"],
+      key: "FaceTracking",
+      act: "start tracking ... ",
+    });
     await detectTest();
     await detectTest();
 
 
     await detectFaces();
     await detectFaces();
@@ -105,6 +109,7 @@ async function detectTest() {
       _hmt.push(["_trackEvent", "答题页面", "启动检测耗时过长:停止实时"]);
       _hmt.push(["_trackEvent", "答题页面", "启动检测耗时过长:停止实时"]);
       logger({
       logger({
         cnl: ["server"],
         cnl: ["server"],
+        key: "FaceTracking",
         act: "实时人脸检测",
         act: "实时人脸检测",
         dtl: "启动检测耗时过长:停止实时",
         dtl: "启动检测耗时过长:停止实时",
         ext: {
         ext: {
@@ -119,6 +124,7 @@ async function detectTest() {
     _hmt.push(["_trackEvent", "答题页面", "启动检测错误:停止实时"]);
     _hmt.push(["_trackEvent", "答题页面", "启动检测错误:停止实时"]);
     logger({
     logger({
       cnl: ["server"],
       cnl: ["server"],
+      key: "FaceTracking",
       act: "启动检测错误:停止实时",
       act: "启动检测错误:停止实时",
       possibleError: error,
       possibleError: error,
     });
     });
@@ -129,7 +135,11 @@ async function detectTest() {
     for (let n = 0; n < detectTimes; n++) {
     for (let n = 0; n < detectTimes; n++) {
       await new Promise((resolve) => setTimeout(resolve, 3 * 1000));
       await new Promise((resolve) => setTimeout(resolve, 3 * 1000));
       if (store.exam.isDoingFaceLiveness) {
       if (store.exam.isDoingFaceLiveness) {
-        logger({ cnl: ["server", "console"], act: "正在活检,暂停实时人脸" });
+        logger({
+          cnl: ["server", "console"],
+          key: "FaceTracking",
+          act: "正在活检,暂停实时人脸",
+        });
         await new Promise((resolve) => setTimeout(resolve, 120 * 1000));
         await new Promise((resolve) => setTimeout(resolve, 120 * 1000));
       }
       }
       const inputSize = inputSizeList[idx];
       const inputSize = inputSizeList[idx];
@@ -153,6 +163,7 @@ async function detectTest() {
           _hmt.push(["_trackEvent", "答题页面", "单次检测耗时过长:停止实时"]);
           _hmt.push(["_trackEvent", "答题页面", "单次检测耗时过长:停止实时"]);
           logger({
           logger({
             cnl: ["server"],
             cnl: ["server"],
+            key: "FaceTracking",
             act: "单次检测耗时过长:停止实时",
             act: "单次检测耗时过长:停止实时",
             ext: {
             ext: {
               cost: detectStartTime - detectEndTime,
               cost: detectStartTime - detectEndTime,
@@ -205,6 +216,7 @@ async function detectTest() {
   bestInputSize = inputSizeList[idx];
   bestInputSize = inputSizeList[idx];
   logger({
   logger({
     cnl: ["server", "local"],
     cnl: ["server", "local"],
+    key: "FaceTracking",
     pgn: "实时人脸检测",
     pgn: "实时人脸检测",
     dtl: "最好的 inputSize 为:" + bestInputSize,
     dtl: "最好的 inputSize 为:" + bestInputSize,
     ext: { succRate },
     ext: { succRate },
@@ -248,6 +260,7 @@ async function detectFaces() {
   ) {
   ) {
     logger({
     logger({
       cnl: ["server"],
       cnl: ["server"],
+      key: "FaceTracking",
       act: "关闭实时人脸检测,因为耗时过长",
       act: "关闭实时人脸检测,因为耗时过长",
       ext: { multipleTimeUsage },
       ext: { multipleTimeUsage },
     });
     });
@@ -257,6 +270,7 @@ async function detectFaces() {
   if (store.exam.isDoingFaceLiveness) {
   if (store.exam.isDoingFaceLiveness) {
     logger({
     logger({
       cnl: ["server"],
       cnl: ["server"],
+      key: "FaceTracking",
       pgn: "实时人脸检测",
       pgn: "实时人脸检测",
       act: "正在活检,暂停实时人脸",
       act: "正在活检,暂停实时人脸",
     });
     });
@@ -278,7 +292,12 @@ async function detectFaces() {
       .detectAllFaces(videoEl, options);
       .detectAllFaces(videoEl, options);
   } catch (e) {
   } catch (e) {
     _hmt.push(["_trackEvent", "答题页面", "实时人脸检测失败"]);
     _hmt.push(["_trackEvent", "答题页面", "实时人脸检测失败"]);
-    logger({ cnl: ["server"], act: "实时人脸检测失败", possibleError: e });
+    logger({
+      cnl: ["server"],
+      key: "FaceTracking",
+      act: "实时人脸检测失败",
+      possibleError: e,
+    });
     throw e;
     throw e;
   }
   }
 
 
@@ -302,6 +321,7 @@ async function detectFaces() {
   const detectEndTime = performance.now();
   const detectEndTime = performance.now();
   logger({
   logger({
     cnl: ["server", "console"],
     cnl: ["server", "console"],
+    key: "FaceTracking",
     pgn: "实时人脸检测",
     pgn: "实时人脸检测",
     act: "做完一次人脸检测,准备统计...",
     act: "做完一次人脸检测,准备统计...",
     ext: {
     ext: {
@@ -324,6 +344,7 @@ async function detectFaces() {
     const roundAvg = Math.round(avg / 100) * 100;
     const roundAvg = Math.round(avg / 100) * 100;
     logger({
     logger({
       cnl: ["server"],
       cnl: ["server"],
+      key: "FaceTracking",
       pgn: "实时人脸检测",
       pgn: "实时人脸检测",
       ext: {
       ext: {
         detectTimeArray,
         detectTimeArray,
@@ -362,7 +383,12 @@ async function detectFaces() {
   }
   }
 
 
   clearTimeout(detectFacesTimeout);
   clearTimeout(detectFacesTimeout);
-  logger({ cnl: ["server"], lvl: "debug", act: "准备下次人脸检测" });
+  logger({
+    cnl: ["server"],
+    key: "FaceTracking",
+    lvl: "debug",
+    act: "准备下次人脸检测",
+  });
   detectFacesTimeout = addTimeout(async () => {
   detectFacesTimeout = addTimeout(async () => {
     if (failTimes >= 5) {
     if (failTimes >= 5) {
       $message.warning("请保持正确坐姿,确保脸部在摄像头内,背景无强光。");
       $message.warning("请保持正确坐姿,确保脸部在摄像头内,背景无强光。");

+ 4 - 4
src/features/OnlineExam/Examing/RemainTime.vue

@@ -31,7 +31,7 @@ const updateRemainTimeId = addInterval(() => {
       remainTime = remainTime - 1000;
       remainTime = remainTime - 1000;
     }
     }
     // 剩余时间永远不应该为负数,否则会显示 "23:59:59"
     // 剩余时间永远不应该为负数,否则会显示 "23:59:59"
-    if (remainTime < 0) {
+    if (remainTime <= 0) {
       remainTime = 0;
       remainTime = 0;
       emit("on-endtime");
       emit("on-endtime");
     }
     }
@@ -74,9 +74,7 @@ async function getRemainTimeFromServer() {
           retryDelay: () => 10 * 1000,
           retryDelay: () => 10 * 1000,
         },
         },
         noErrorMessage: true,
         noErrorMessage: true,
-        cancelToken: new CancelToken(function executor(c) {
-          cancelHeartBeat = c;
-        }),
+        cancelToken: new CancelToken((c) => (cancelHeartBeat = c)),
       }
       }
     );
     );
     const rt: number = res.data;
     const rt: number = res.data;
@@ -185,6 +183,8 @@ watch(
 <style scoped>
 <style scoped>
 .remain-time {
 .remain-time {
   font-size: 25px;
   font-size: 25px;
+  width: 100%;
+  text-align: center;
 }
 }
 
 
 .enhanced-remain-time {
 .enhanced-remain-time {

+ 133 - 0
src/features/OnlineExam/Examing/setups/useFaceCompare.ts

@@ -0,0 +1,133 @@
+import { httpApp } from "@/plugins/axiosApp";
+import { useTimers } from "@/setups/useTimers";
+import { store } from "@/store/store";
+import { showLogout } from "@/utils/utils";
+import axios, { Canceler } from "axios";
+import { onUnmounted, watch } from "vue";
+
+/** 人脸后台Face++比对 */
+export function useFaceCompare() {
+  const { addInterval, addTimeout } = useTimers();
+
+  let initSnapInterval: number;
+  let initSnapshotTrialTimes = 0;
+
+  let snapId = $ref(0);
+
+  watch(
+    () => store.exam.faceCheckEnabled,
+    () => {
+      if (!store.exam.faceCheckEnabled) return;
+
+      initSnapInterval = addInterval(() => {
+        logger({
+          cnl: ["server"],
+          pgn: "答题页面",
+          key: "首次抓拍尝试",
+          act: "获取摄像头状态",
+        });
+        const video = <HTMLVideoElement>document.getElementById("video");
+        const videoStartFailed = !video || video.readyState !== 4;
+        initSnapshotTrialTimes++;
+        if (videoStartFailed && initSnapshotTrialTimes <= 5) {
+          logger({
+            cnl: ["server"],
+            pgn: "答题页面",
+            key: "首次抓拍尝试",
+            dtl: `进入考试后60秒内-(第${initSnapshotTrialTimes}次尝试)`,
+          });
+        } else {
+          // 超过6次后,强行抓拍,如果抓拍不成功,则会因抓拍不成功而退出。
+          clearInterval(initSnapInterval);
+          if (videoStartFailed) {
+            logger({
+              cnl: ["server"],
+              pgn: "答题页面",
+              key: "首次抓拍尝试",
+              dtl: "摄像头没有正常启用-进入考试抓拍",
+            });
+            $message.error("摄像头没有正常启用");
+            showLogout("摄像头没有正常启用-退出");
+          } else {
+            logger({
+              cnl: ["server"],
+              pgn: "答题页面",
+              key: "首次抓拍尝试",
+              dtl: `进入考试后60秒内-(第${initSnapshotTrialTimes}次尝试成功)`,
+            });
+            doSnap(); // 开启抓拍才在进入考试时抓拍一张
+          }
+        }
+      }, 10 * 1000);
+    },
+    { immediate: true }
+  );
+
+  watch(
+    () => store.exam.SNAPSHOT_INTERVAL,
+    () => {
+      if (store.exam.SNAPSHOT_INTERVAL) {
+        // 考务设置抓拍间隔
+        addInterval(() => {
+          logger({
+            cnl: ["server"],
+            pgn: "答题页面",
+            dtl: "定时抓拍",
+            ext: { SNAPSHOT_INTERVAL: store.exam.SNAPSHOT_INTERVAL },
+          });
+          doSnap();
+        }, store.exam.SNAPSHOT_INTERVAL * 60 * 1000);
+      }
+    },
+    { immediate: true }
+  );
+
+  function doSnap() {
+    snapId = Date.now();
+  }
+
+  // 离开此页面时,可能还有心跳请求未返回
+  onUnmounted(() => cancelShow && cancelShow());
+
+  const CancelToken = axios.CancelToken;
+  let cancelShow: Canceler;
+
+  async function showSnapResult(
+    fileName: string,
+    examRecordDataId: string | number
+  ) {
+    if (!fileName) return; // 交卷后提交照片会得不到照片名称
+
+    try {
+      // 获取抓拍结果
+      const snapRes =
+        (
+          await httpApp.get(
+            "/api/ecs_oe_student_face/examCaptureQueue/getExamCaptureResult?fileName=" +
+              fileName +
+              "&examRecordDataId=" +
+              examRecordDataId,
+            { cancelToken: new CancelToken((c) => (cancelShow = c)) }
+          )
+        ).data || {};
+      if (snapRes.isCompleted) {
+        if (snapRes.isStranger) {
+          $message.error("请独立完成考试");
+        } else if (!snapRes.isPass) {
+          $message.error("请调整坐姿,诚信考试");
+        }
+      } else {
+        addTimeout(
+          showSnapResult.bind(null, fileName, examRecordDataId),
+          30 * 1000
+        );
+      }
+    } catch (e) {
+      // $message.error(e instanceof Error ? e.message : "抓拍查询错误");
+      logger({ cnl: ["server"], act: "抓拍查询错误", possibleError: e });
+      throw e;
+    }
+  }
+
+  return { snapId: $$(snapId), doSnap, showSnapResult };
+}

+ 3 - 3
src/features/OnlineExam/Examing/setups/useFaceLive.ts

@@ -3,9 +3,9 @@ import { useTimers } from "@/setups/useTimers";
 import { store } from "@/store/store";
 import { store } from "@/store/store";
 import { watch } from "vue";
 import { watch } from "vue";
 
 
-const { addTimeout } = useTimers();
+export function useFaceLive(doSnap: () => void) {
+  const { addTimeout } = useTimers();
 
 
-export function useFaceLive() {
   let showFaceId = $ref(false);
   let showFaceId = $ref(false);
 
 
   watch(() => store.exam.faceLivenessEnabled, initData, { immediate: true });
   watch(() => store.exam.faceLivenessEnabled, initData, { immediate: true });
@@ -43,7 +43,7 @@ export function useFaceLive() {
 
 
       addTimeout(() => {
       addTimeout(() => {
         logger({ cnl: ["server"], act: "活体检测前抓拍" });
         logger({ cnl: ["server"], act: "活体检测前抓拍" });
-        // FIXME: 添加抓拍事件
+        doSnap();
         // this.toggleSnapNow();
         // this.toggleSnapNow();
         $message.info("30秒后开始指定动作检测");
         $message.info("30秒后开始指定动作检测");
       }, faceVerifyMinute * 60 * 1000 - 30 * 1000); // 活体检测提醒
       }, faceVerifyMinute * 60 * 1000 - 30 * 1000); // 活体检测提醒

+ 234 - 315
src/features/OnlineExam/FaceRecognition.vue

@@ -5,14 +5,14 @@ import { getMediaStream } from "@/utils/camera";
 import { httpApp } from "@/plugins/axiosApp";
 import { httpApp } from "@/plugins/axiosApp";
 import { showLogout } from "@/utils/utils";
 import { showLogout } from "@/utils/utils";
 import { getCapturePhotoYunSign, saveCapturePhoto } from "@/api/login";
 import { getCapturePhotoYunSign, saveCapturePhoto } from "@/api/login";
+import { execLocal, fileExists } from "@/utils/nativeMethods";
 
 
-// FIXME: 开启异步抓拍
 /**
 /**
  * 上层通过showRecognizeButton来控制是否是同步比对
  * 上层通过showRecognizeButton来控制是否是同步比对
  *
  *
  * 同步比对通过onRecognizeResult得到人脸比对结果
  * 同步比对通过onRecognizeResult得到人脸比对结果
  *
  *
- * 异步比对通过snapNow来控制是否该进行比对,什么时候进行,以什么频率频率进行,均由上层控制
+ * 异步比对通过snapId来控制是否该进行比对,什么时候进行,以什么频率频率进行,错误处理,均由上层控制
  * 异步比对同时传递一个snapId(time),供上层识别和计数
  * 异步比对同时传递一个snapId(time),供上层识别和计数
  * 可能存在多个异步比对的任务同时进行
  * 可能存在多个异步比对的任务同时进行
  */
  */
@@ -22,14 +22,14 @@ import { getCapturePhotoYunSign, saveCapturePhoto } from "@/api/login";
 const {
 const {
   width = 400,
   width = 400,
   height = 300,
   height = 300,
-  snapNow = false,
-  // snapId = 0,
+  snapId = 0,
+  examRecordDataId = -1,
 } = defineProps<{
 } = defineProps<{
   width: string;
   width: string;
   height: string;
   height: string;
   showRecognizeButton: boolean;
   showRecognizeButton: boolean;
-  snapNow?: boolean;
-  // snapId: number;
+  snapId?: number;
+  examRecordDataId?: number;
 }>();
 }>();
 
 
 const emit = defineEmits<{
 const emit = defineEmits<{
@@ -37,14 +37,18 @@ const emit = defineEmits<{
     e: "on-recognize-result",
     e: "on-recognize-result",
     v: { isPassed: boolean; isStranger: boolean }
     v: { isPassed: boolean; isStranger: boolean }
   ): void;
   ): void;
+  (
+    e: "on-async-recognize-result",
+    v: { hasError: boolean; fileName: string }
+  ): void;
 }>();
 }>();
 
 
 let snapBtnDisabled = $ref(true);
 let snapBtnDisabled = $ref(true);
 let btnText = $ref("开始识别");
 let btnText = $ref("开始识别");
 
 
 watchEffect(() => {
 watchEffect(() => {
-  if (snapNow) {
-    // snapAsync(snapId)
+  if (snapId) {
+    void snapAsync();
   }
   }
 });
 });
 
 
@@ -61,7 +65,6 @@ async function openCamera() {
   try {
   try {
     await video.play();
     await video.play();
   } catch (error) {
   } catch (error) {
-    console.log(error);
     if (error instanceof Error) {
     if (error instanceof Error) {
       if (error.name == "AbortError") {
       if (error.name == "AbortError") {
         logger({
         logger({
@@ -108,90 +111,20 @@ async function openCamera() {
   });
   });
 }
 }
 
 
-// async function snapAsync() {
-//   try {
-//     logger({ cnl: ["server"], act: "定时抓拍开始" });
-//     const examRecordDataId = this.$route.params.examRecordDataId;
-//     const captureBlob = await getSnapShot({ compareSync: false });
-//     logger({ cnl: ["server"], act: "抓拍照片的大小:" + captureBlob.size });
-//     void videoStartPlay();
-//     console.log("抓拍照片的大小:" + captureBlob.size);
-//     if (captureBlob.size < 48 * 48 || captureBlob.size >= 2 * 1024 * 1024) {
-//       // 经查以前记录,不完整图片均为8192大小。此处设置小于10KB的图片为未抓拍成功
-//       // 检查百度统计的记录后,这里的图片大小可能小于8192,也可能是有效的数据,所以降低图片大小的要求为face++的要求
-//       logger({
-//         cnl: ["server"],
-//         act: "摄像头异常",
-//         dtl: "定时抓拍照片大小异常",
-//         ext: { blobSize: captureBlob.size },
-//       });
-//       throw new Error("定时抓拍照片大小异常");
-//     }
-//     const startTime = Date.now();
-//     const [captureFilePath, signIdentifier] = await this.uploadToServer(
-//       captureBlob
-//     );
-//     const endTime = Date.now();
-//     logger({
-//       cnl: ["server"],
-//       act: "定时抓拍上传",
-//       ext: { cost: endTime - startTime },
-//     });
-//     await this.faceCompare(captureFilePath, signIdentifier, examRecordDataId);
-//     logger({
-//       cnl: ["server"],
-//       act: "定时抓拍比对",
-//       dtl: "定时抓拍流程成功",
-//       ext: { cost: Date.now() - endTime, signIdentifier },
-//     });
-//   } catch (error) {
-//     if (!(error instanceof Error)) {
-//       logger({
-//         cnl: ["server"],
-//         act: "snapAsync",
-//         dtl: "not an Error",
-//         stk: error + "",
-//       });
-//       return;
-//     }
-//     logger({
-//       cnl: ["server"],
-//       act: "定时抓拍流程失败",
-//       ejn: JSON.stringify(error),
-//       stk: error.stack,
-//       ext: {
-//         errorName: error.name,
-//         errorMessage: error.message,
-//         firstSnap: (this.lastSnapTime ? "(非初次抓拍)" : "") + "将再次抓拍",
-//       },
-//     });
-//     this.retrySnapTimeout = setTimeout(() => {
-//       this.logger({
-//         action: "答题页面",
-//         detail: "定时抓拍流程失败后重试",
-//       });
-//       this.toggleSnapNow();
-//     }, 60 * 1000);
-//   } finally {
-//     this.videoStartPlay();
-//     this.decreaseSnapCount();
-//   }
-// }
-
 async function videoStartPlay() {
 async function videoStartPlay() {
   if (video && video.paused) {
   if (video && video.paused) {
     await video.play().catch((e) => {
     await video.play().catch((e) => {
       if (!(e instanceof Error)) {
       if (!(e instanceof Error)) {
         logger({
         logger({
           cnl: ["server"],
           cnl: ["server"],
-          act: "videoStartPlay",
+          act: "restart video play error",
           dtl: "not an Error",
           dtl: "not an Error",
           stk: e + "",
           stk: e + "",
         });
         });
       } else {
       } else {
         logger({
         logger({
           cnl: ["server"],
           cnl: ["server"],
-          act: "restart video play",
+          act: "restart video play error",
           stk: e.stack,
           stk: e.stack,
           ejn: JSON.stringify(e),
           ejn: JSON.stringify(e),
         });
         });
@@ -201,7 +134,8 @@ async function videoStartPlay() {
   }
   }
 }
 }
 
 
-async function snap() {
+//#region 同步人脸比对
+async function snapSync() {
   logger({
   logger({
     cnl: ["server"],
     cnl: ["server"],
     act: "同步人脸比对",
     act: "同步人脸比对",
@@ -212,11 +146,18 @@ async function snap() {
   try {
   try {
     snapBtnDisabled = true;
     snapBtnDisabled = true;
     btnText = "拍照中...";
     btnText = "拍照中...";
+    logger({ cnl: ["server"], lvl: "debug", act: btnText });
     const captureBlob = await getSnapShot(true);
     const captureBlob = await getSnapShot(true);
-    console.log("抓拍照片大小", captureBlob.size);
+    if (!(captureBlob instanceof Blob)) return;
+
+    logger({
+      cnl: ["server"],
+      lvl: "debug",
+      act: "getSnapShot",
+      ext: { blobSize: captureBlob.size },
+    });
     if (captureBlob.size < 48 * 48 || captureBlob.size >= 2 * 1024 * 1024) {
     if (captureBlob.size < 48 * 48 || captureBlob.size >= 2 * 1024 * 1024) {
       $message.error("抓拍照片太小!");
       $message.error("抓拍照片太小!");
-
       logger({
       logger({
         cnl: ["server"],
         cnl: ["server"],
         act: "摄像头异常",
         act: "摄像头异常",
@@ -225,8 +166,8 @@ async function snap() {
       });
       });
       throw new Error("抓拍照片大小异常");
       throw new Error("抓拍照片大小异常");
     }
     }
-    void videoStartPlay();
     btnText = "上传照片中...";
     btnText = "上传照片中...";
+    logger({ cnl: ["server"], lvl: "debug", act: btnText });
     const [captureFilePath, signIdentifier] = await uploadToServer(captureBlob);
     const [captureFilePath, signIdentifier] = await uploadToServer(captureBlob);
     btnText = "人脸比对中...";
     btnText = "人脸比对中...";
     await faceCompareSync(captureFilePath, signIdentifier);
     await faceCompareSync(captureFilePath, signIdentifier);
@@ -242,14 +183,13 @@ async function snap() {
     console.log("同步照片比对流程失败");
     console.log("同步照片比对流程失败");
     throw error;
     throw error;
   } finally {
   } finally {
-    void videoStartPlay();
     btnText = "开始识别";
     btnText = "开始识别";
     // 避免人脸识别功能被大量重复点击
     // 避免人脸识别功能被大量重复点击
     await new Promise((resolve) => setTimeout(resolve, 3000));
     await new Promise((resolve) => setTimeout(resolve, 3000));
     snapBtnDisabled = false;
     snapBtnDisabled = false;
   }
   }
 }
 }
-async function getSnapShot(compareSync: boolean): Promise<Blob> {
+async function getSnapShot(compareSync: boolean): Promise<Blob | unknown> {
   return new Promise((resolve, reject) => {
   return new Promise((resolve, reject) => {
     if (video.readyState !== 4 || !(video.srcObject as MediaStream).active) {
     if (video.readyState !== 4 || !(video.srcObject as MediaStream).active) {
       $message.error("摄像头没有正常启用");
       $message.error("摄像头没有正常启用");
@@ -258,7 +198,6 @@ async function getSnapShot(compareSync: boolean): Promise<Blob> {
         pgu: "AUTO",
         pgu: "AUTO",
         act: "getSnapShot",
         act: "getSnapShot",
         dtl: "摄像头没有正常启用",
         dtl: "摄像头没有正常启用",
-        // (!compareSync && this.lastSnapTime ? "-退出(非初次抓拍)" : ""),
       });
       });
       reject("摄像头没有正常启用");
       reject("摄像头没有正常启用");
       if (!compareSync) {
       if (!compareSync) {
@@ -275,8 +214,9 @@ async function getSnapShot(compareSync: boolean): Promise<Blob> {
     context?.drawImage(video, 0, 0, 220, 165);
     context?.drawImage(video, 0, 0, 220, 165);
 
 
     canvas.toBlob((blob) => resolve(blob!), "image/png", 0.95);
     canvas.toBlob((blob) => resolve(blob!), "image/png", 0.95);
-  });
+  }).finally(() => void videoStartPlay()); // TODO: finally 此处的错误捕捉还需验证
 }
 }
+
 // 用来比对两次抓拍照片的md5是否一样
 // 用来比对两次抓拍照片的md5是否一样
 let __previousPhotoMD5 = "";
 let __previousPhotoMD5 = "";
 async function uploadToServer(captureBlob: Blob): Promise<[string, string]> {
 async function uploadToServer(captureBlob: Blob): Promise<[string, string]> {
@@ -328,7 +268,6 @@ async function uploadToServer(captureBlob: Blob): Promise<[string, string]> {
         });
         });
         throw new Error("图片校验失败");
         throw new Error("图片校验失败");
       }
       }
-      __previousPhotoMD5 = fileMd5Base64;
     } catch (error) {
     } catch (error) {
       logger({
       logger({
         cnl: ["server"],
         cnl: ["server"],
@@ -387,8 +326,6 @@ async function faceCompareSync(
       isStranger: res.data.isStranger,
       isStranger: res.data.isStranger,
     });
     });
   } catch (e) {
   } catch (e) {
-    console.log(e);
-    // this.$Message.error(e.message);
     logger({
     logger({
       cnl: ["server"],
       cnl: ["server"],
       act: "同步比对失败",
       act: "同步比对失败",
@@ -397,238 +334,220 @@ async function faceCompareSync(
     throw new Error("同步照片比较失败!");
     throw new Error("同步照片比较失败!");
   }
   }
 }
 }
-// async function faceCompare(
-//   captureFilePath: string,
-//   signIdentifier: string,
-//   examRecordDataId: number
-// ) {
-//   try {
-//     let cameraInfos;
-//     let hasVirtualCamera = false;
-//     if (typeof nodeRequire != "undefined") {
-//       try {
-//         var fs = window.nodeRequire("fs");
-//         if (fs.existsSync("multiCamera.exe")) {
-//           await new Promise((resolve, reject) => {
-//             window.nodeRequire("node-cmd").get("multiCamera.exe", () => {
-//               try {
-//                 cameraInfos = fs.readFileSync("CameraInfo.txt", "utf-8");
-//                 // cameraInfos =
-//                 //   '[{"detail":"@device:pnp:?display#int3470#4&300121c4&0&uid13424#{65e8773d-8f56-11d0-a3b9-00a0c9223196}{9c5f415a-02cd-4e28-aeb7-811cb317dd64}","name":"HP Truevision 5MP Front","pid":"13424","vid":"3470"},{"detail":"@device:pnp:?display#int3470#4&300121c4&0&uid13424#{65e8773d-8f56-11d0-a3b9-00a0c9223196}{a6c1c503-01f1-4767-a229-00a0b223162f}","name":"HP Truevision 8MP Rear","pid":"13424","vid":"3470"},{"detail":"@device:pnp:?usb#vid_8086&pid_0a80&mi_04#6&28913c47&0&0004#{65e8773d-8f56-11d0-a3b9-00a0c9223196}global","name":"Intel(R) RealSense(TM) 3D Camera (R200) RGB","pid":"0a80","vid":"8086"},{"detail":"@device:pnp:?usb#vid_8086&pid_0a80&mi_00#6&28913c47&0&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}global","name":"Intel(R) RealSense(TM) 3D Camera (R200) Left-Right","pid":"0a80","vid":"8086"},{"detail":"@device:pnp:?usb#vid_8086&pid_0a80&mi_02#6&28913c47&0&0002#{65e8773d-8f56-11d0-a3b9-00a0c9223196}global","name":"Intel(R) RealSense(TM) 3D Camera (R200) Depth","pid":"0a80","vid":"8086"}]';
-//                 if (cameraInfos && cameraInfos.trim()) {
-//                   cameraInfos = cameraInfos.trim();
-//                   cameraInfos = cameraInfos.replace(/\r\n/g, "");
-//                   cameraInfos = cameraInfos.replace(/\n/g, "");
-//                   console.log(cameraInfos);
-//                   this.logger({
-//                     page: "摄像头框",
-//                     cameraInfos,
-//                   });
-//                 }
-//                 if (cameraInfos.includes('""')) {
-//                   hasVirtualCamera = true;
-//                 }
-//                 // multiCamera.exe 1.0.1
-//                 if (cameraInfos.includes("cameraInfo")) {
-//                   cameraInfos = JSON.stringify(
-//                     JSON.parse(cameraInfos).cameraInfo
-//                   );
-//                 }
-//                 if (cameraInfos.length >= 800) {
-//                   this.logger({
-//                     page: "摄像头框",
-//                     type: "虚拟摄像头-cameraInfos超长",
-//                     cameraInfos: cameraInfos,
-//                   });
-//                   let ary = JSON.parse(cameraInfos);
-//                   // 相同pid&vid仅保留一个
-//                   const pidAndVidCollector = [];
-//                   ary = ary.filter((c) => {
-//                     const pv = c.pid + "|" + c.vid;
-//                     const res = pidAndVidCollector.includes(pv);
-//                     pidAndVidCollector.push(pv);
-//                     return !res;
-//                   });
-//                   cameraInfos = JSON.stringify(ary);
-//                   console.log("摄像头检测超长:", "去除重复pid&vid");
-//                   console.log(cameraInfos);
-//                   if (cameraInfos.length >= 800) {
-//                     cameraInfos = JSON.stringify(
-//                       JSON.parse(cameraInfos).map((v) => {
-//                         return {
-//                           pid: v.pid,
-//                           vid: v.pid,
-//                           detail: "omitted",
-//                           name: v.name,
-//                         };
-//                       })
-//                     );
-//                     console.log("摄像头检测超长:", "去除detail");
-//                     console.log(cameraInfos);
-//                   }
-//                   if (cameraInfos.length >= 800) {
-//                     console.log("摄像头检测超长:", "精简后还是超长");
-//                     this.logger({
-//                       page: "摄像头框",
-//                       type: "虚拟摄像头-精简后还是超长",
-//                       cameraInfos: cameraInfos,
-//                     });
-//                     console.log(cameraInfos);
-//                   }
-//                 }
-//                 resolve();
-//               } catch (error) {
-//                 this.logger({
-//                   page: "摄像头框",
-//                   type: "虚拟摄像头-读取摄像头列表失败",
-//                   errorJSON: JSON.stringify(error, (key, value) =>
-//                     key === "token" ? "" : value
-//                   ),
-//                   errorName: error.name,
-//                   errorMessage: error.message,
-//                   errorStack: error.stack,
-//                 });
-//                 window._hmt.push([
-//                   "_trackEvent",
-//                   "摄像头框",
-//                   "虚拟摄像头-读取摄像头列表失败",
-//                 ]);
-//                 reject("读取摄像头列表失败");
-//               }
-//             });
-//           });
-//         }
-//       } catch (error) {
-//         console.log(error);
-//       }
-//     }
-
-//     let body = {
-//       fileUrl: captureFilePath,
-//       signIdentifier,
-//       examRecordDataId,
-//     };
-
-//     if (cameraInfos) {
-//       body.cameraInfos = cameraInfos;
-//       body.hasVirtualCamera = hasVirtualCamera;
-
-//       this.logger({
-//         action: "抓拍照片详细日志",
-//         fileUrl: captureFilePath,
-//         signIdentifier,
-//         examRecordDataId,
-//         cameraInfos,
-//         hasVirtualCamera,
-//         duplicateMD5: this.__duplicateMD5,
-//       });
-//     }
-//     const res = await this.$http.post(
-//       "/api/ecs_oe_student_face/examCaptureQueue/uploadExamCapture",
-//       body
-//     );
-//     const fileName = res.data;
-//     try {
-//       await this.showSnapResult(fileName, examRecordDataId);
-//     } catch (error) {
-//       this.logger({
-//         page: "摄像头框",
-//         action: "设置获取抓拍结果失败!",
-//         errorJSON: JSON.stringify(error, (key, value) =>
-//           key === "token" ? "" : value
-//         ),
-//         errorName: error.name,
-//         errorMessage: error.message,
-//         errorStack: error.stack,
-//       });
-//       this.$Message.error({
-//         content: "设置获取抓拍结果失败!",
-//         duration: 15,
-//         closable: true,
-//       });
-//     }
-//   } catch (e) {
-//     console.log(e);
-//     this.logger({
-//       page: "摄像头框",
-//       action: "faceCompare失败",
-//       error: e.response ? e.response.data.desc : e,
-//     });
-//     window._hmt.push([
-//       "_trackEvent",
-//       "摄像头框",
-//       "faceCompare失败",
-//       e.response ? e.response.data.desc : e,
-//     ]);
-//     // this.$Message.error(e.message);
-//     throw new Error("异步比较抓拍照片失败");
-//   }
-// }
-// async function showSnapResult(fileName, examRecordDataId) {
-//   if (!fileName) return; // 交卷后提交照片会得不到照片名称
-//   if (this.$route.name !== "OnlineExamingHome") {
-//     // 非考试页,不显示结果,也不继续查询
-//     return;
-//   }
-
-//   try {
-//     // 获取抓拍结果
-//     const snapRes =
-//       (
-//         await this.$http.get(
-//           "/api/ecs_oe_student_face/examCaptureQueue/getExamCaptureResult?fileName=" +
-//             fileName +
-//             "&examRecordDataId=" +
-//             examRecordDataId
-//         )
-//       ).data || {};
-//     if (snapRes.isCompleted) {
-//       if (snapRes.isStranger) {
-//         this.$Message.error({
-//           content: "请独立完成考试",
-//           duration: 5,
-//           closable: true,
-//         });
-//       } else if (!snapRes.isPass) {
-//         this.$Message.error({
-//           content: "请调整坐姿,诚信考试",
-//           duration: 5,
-//           closable: true,
-//         });
-//       }
-//     } else {
-//       this.showSnapResultTimeout = setTimeout(
-//         this.showSnapResult.bind(this, fileName, examRecordDataId),
-//         30 * 1000
-//       );
-//     }
-//   } catch (e) {
-//     console.log(e);
-//     if (this.$route.name !== "OnlineExamingHome") {
-//       // 非考试页,不显示结果,也不继续查询
-//       return;
-//     }
-//     this.$Message.error(e.message);
-//     throw e.message;
-//   }
-// }
+//#endregion 同步人脸比对
+
+//#region 异步人脸比对
+async function snapAsync() {
+  try {
+    logger({ cnl: ["server"], act: "定时抓拍开始" });
+    const captureBlob = await getSnapShot(false);
+    if (!(captureBlob instanceof Blob)) return;
+
+    logger({ cnl: ["server"], act: "抓拍照片的大小:" + captureBlob.size });
+    if (captureBlob.size < 48 * 48 || captureBlob.size >= 2 * 1024 * 1024) {
+      // 经查以前记录,不完整图片均为8192大小。此处设置小于10KB的图片为未抓拍成功
+      // 检查百度统计的记录后,这里的图片大小可能小于8192,也可能是有效的数据,所以降低图片大小的要求为face++的要求
+      logger({
+        cnl: ["server"],
+        act: "摄像头异常",
+        dtl: "定时抓拍照片大小异常",
+        ext: { blobSize: captureBlob.size },
+      });
+      throw new Error("定时抓拍照片大小异常");
+    }
+    const startTime = Date.now();
+    const [captureFilePath, signIdentifier] = await uploadToServer(captureBlob);
+    const endTime = Date.now();
+    logger({
+      cnl: ["server"],
+      act: "定时抓拍上传",
+      ext: { cost: endTime - startTime },
+    });
+    await faceCompare(captureFilePath, signIdentifier, examRecordDataId);
+    logger({
+      cnl: ["server"],
+      act: "定时抓拍比对",
+      dtl: "定时抓拍流程成功",
+      ext: { cost: Date.now() - endTime, signIdentifier },
+    });
+  } catch (error) {
+    if (!(error instanceof Error)) {
+      logger({
+        cnl: ["server"],
+        act: "snapAsync",
+        dtl: "not an Error",
+        stk: error + "",
+      });
+      return;
+    }
+    logger({
+      cnl: ["server"],
+      act: "定时抓拍流程失败",
+      ejn: JSON.stringify(error),
+      stk: error.stack,
+      possibleError: error,
+    });
+    emit("on-async-recognize-result", {
+      hasError: true,
+      fileName: "",
+    });
+  }
+}
+
+type CameraInfo = {
+  detail: string;
+  pid: string;
+  vid: string;
+  name: string;
+};
+async function faceCompare(
+  captureFilePath: string,
+  signIdentifier: string,
+  examRecordDataId: number
+) {
+  try {
+    let cameraInfos;
+    let hasVirtualCamera = false;
+    if (typeof window.nodeRequire != "undefined") {
+      const fs: typeof import("fs") = window.nodeRequire("fs");
+      if (fileExists("multiCamera.exe")) {
+        try {
+          await execLocal("multiCamera.exe");
+          cameraInfos = fs.readFileSync("CameraInfo.txt", "utf-8");
+          // cameraInfos =
+          //   '[{"detail":"@device:pnp:?display#int3470#4&300121c4&0&uid13424#{65e8773d-8f56-11d0-a3b9-00a0c9223196}{9c5f415a-02cd-4e28-aeb7-811cb317dd64}","name":"HP Truevision 5MP Front","pid":"13424","vid":"3470"},{"detail":"@device:pnp:?display#int3470#4&300121c4&0&uid13424#{65e8773d-8f56-11d0-a3b9-00a0c9223196}{a6c1c503-01f1-4767-a229-00a0b223162f}","name":"HP Truevision 8MP Rear","pid":"13424","vid":"3470"},{"detail":"@device:pnp:?usb#vid_8086&pid_0a80&mi_04#6&28913c47&0&0004#{65e8773d-8f56-11d0-a3b9-00a0c9223196}global","name":"Intel(R) RealSense(TM) 3D Camera (R200) RGB","pid":"0a80","vid":"8086"},{"detail":"@device:pnp:?usb#vid_8086&pid_0a80&mi_00#6&28913c47&0&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}global","name":"Intel(R) RealSense(TM) 3D Camera (R200) Left-Right","pid":"0a80","vid":"8086"},{"detail":"@device:pnp:?usb#vid_8086&pid_0a80&mi_02#6&28913c47&0&0002#{65e8773d-8f56-11d0-a3b9-00a0c9223196}global","name":"Intel(R) RealSense(TM) 3D Camera (R200) Depth","pid":"0a80","vid":"8086"}]';
+          if (cameraInfos && cameraInfos.trim()) {
+            cameraInfos = cameraInfos.trim();
+            cameraInfos = cameraInfos.replace(/\r\n/g, "");
+            cameraInfos = cameraInfos.replace(/\n/g, "");
+            logger({
+              cnl: ["server"],
+              act: "multiCamera.exe",
+              ext: { cameraInfos },
+            });
+          }
+          if (cameraInfos.includes('""')) {
+            hasVirtualCamera = true;
+          }
+          // multiCamera.exe 1.0.1
+          if (cameraInfos.includes("cameraInfo")) {
+            cameraInfos = JSON.stringify(JSON.parse(cameraInfos).cameraInfo);
+          }
+          if (cameraInfos.length >= 800) {
+            logger({
+              cnl: ["server"],
+              act: "multiCamera.exe",
+              stk: "虚拟摄像头-cameraInfos超长",
+              ext: { cameraInfos },
+            });
+            let ary: CameraInfo[] = JSON.parse(cameraInfos);
+            // 相同pid&vid仅保留一个
+            const pidAndVidCollector: string[] = [];
+            ary = ary.filter((c) => {
+              const pv = c.pid + "|" + c.vid;
+              const res = pidAndVidCollector.includes(pv);
+              pidAndVidCollector.push(pv);
+              return !res;
+            });
+            cameraInfos = JSON.stringify(ary);
+            logger({
+              cnl: ["server"],
+              act: "multiCamera.exe",
+              stk: "除重复pid&vid",
+            });
+            if (cameraInfos.length >= 800) {
+              cameraInfos = JSON.stringify(
+                (<CameraInfo[]>JSON.parse(cameraInfos)).map((v) => {
+                  return {
+                    pid: v.pid,
+                    vid: v.pid,
+                    detail: "omitted",
+                    name: v.name,
+                  };
+                })
+              );
+              console.log("摄像头检测超长:", "去除detail");
+              console.log(cameraInfos);
+            }
+            if (cameraInfos.length >= 800) {
+              logger({
+                cnl: ["server"],
+                act: "multiCamera.exe",
+                stk: "精简后还是超长",
+                ext: { cameraInfos },
+              });
+            }
+          }
+        } catch (error) {
+          logger({
+            cnl: ["server"],
+            act: "multiCamera.exe",
+            stk: "虚拟摄像头-读取摄像头列表失败",
+            possibleError: error,
+          });
+          // throw new Error("读取摄像头列表失败");
+        }
+      }
+    }
+
+    let body: any = {
+      fileUrl: captureFilePath,
+      signIdentifier,
+      examRecordDataId,
+    };
+
+    if (cameraInfos) {
+      body.cameraInfos = cameraInfos;
+      body.hasVirtualCamera = hasVirtualCamera;
+    }
+    logger({
+      cnl: ["server"],
+      act: "抓拍照片详细日志",
+      ext: {
+        fileUrl: captureFilePath,
+        signIdentifier,
+        examRecordDataId,
+        cameraInfos,
+        hasVirtualCamera,
+      },
+    });
+    const res = await httpApp.post(
+      "/api/ecs_oe_student_face/examCaptureQueue/uploadExamCapture",
+      body
+    );
+
+    emit("on-async-recognize-result", {
+      hasError: false,
+      fileName: res.data,
+    });
+  } catch (e) {
+    logger({
+      cnl: ["server"],
+      act: "定时抓拍",
+      dtl: "抓拍失败",
+      possibleError: e,
+    });
+    emit("on-async-recognize-result", {
+      hasError: true,
+      fileName: "",
+    });
+
+    throw new Error("异步比较抓拍照片失败");
+  }
+}
+
+//#endregion 异步人脸比对
 </script>
 </script>
 
 
 <template>
 <template>
   <div>
   <div>
-    <video
-      id="video"
-      ref="video"
-      :width="width"
-      :height="height"
-      autoplay
-    ></video>
+    <video id="video" ref="video" :width="width" :height="height" autoplay />
     <div v-if="showRecognizeButton" class="btn-container">
     <div v-if="showRecognizeButton" class="btn-container">
       <button
       <button
         class="verify-button"
         class="verify-button"
         :class="[snapBtnDisabled && 'disable-verify-button']"
         :class="[snapBtnDisabled && 'disable-verify-button']"
         :disabled="snapBtnDisabled"
         :disabled="snapBtnDisabled"
-        @click="snap"
+        @click="snapSync"
       >
       >
         {{ btnText }}
         {{ btnText }}
       </button>
       </button>

+ 34 - 0
src/utils/logger.ts

@@ -180,3 +180,37 @@ export function createEncryptLog() {
     return;
     return;
   }
   }
 }
 }
+
+/** 获得页面的dimension
+ * @argument pgn - 页面名称
+ */
+export function dimensionLog(pgn: string) {
+  logger({
+    cnl: ["server", "local"],
+    pgn,
+    ext: {
+      scrollX: window.scrollX,
+      scrollY: window.scrollY,
+      width: window.screen.width,
+      height: window.screen.height,
+      screenX: window.screen.availWidth,
+      screenY: window.screen.availHeight,
+      clientWidth: document.documentElement.clientWidth,
+      clientHeight: document.documentElement.clientHeight,
+      windowInnerWidth: window.innerWidth,
+      windowInnerHeight: window.innerHeight,
+      windowOuterWidth: window.outerWidth,
+      windowOuterHeight: window.outerHeight,
+      // 是否全屏
+      equal1:
+        "dimesion1" +
+        (window.screen.width === window.outerWidth &&
+          window.screen.height === window.outerHeight),
+      // 是否打开了调试窗口
+      equal2:
+        "dimesion2" +
+        (window.innerWidth === window.outerWidth &&
+          window.innerHeight === window.outerHeight),
+    },
+  });
+}