Michael Wang 3 жил өмнө
parent
commit
054872d094

+ 2 - 1
.env.production

@@ -1 +1,2 @@
-VITE_SLS_STORE_NAME=student-client
+VITE_SLS_STORE_NAME=student-client-test
+VITE_CONFIG_FILE_SEVER_URL=https://ecs-test-static.qmth.com.cn

+ 4 - 2
index.html

@@ -11,9 +11,11 @@
     // 重命名 Electron 提供的 require 详细请参考:https://www.w3cschool.cn/electronmanual/electronmanual-electron-faq.html
     if (typeof require != "undefined") {
       window.nodeRequire = require;
+      window.proceess = process;
       delete window.require;
       delete window.exports;
       delete window.module;
+      delete window.process;
     }
   </script>
   <script src="https://static.geetest.com/static/tools/gt.js"></script>
@@ -36,9 +38,9 @@
       ">
       程序加载中...
     </div>
-    <div class="js-close" style="display: none; justify-content: center;">
+    <div class="js-close" style="display: none; justify-content: center; align-items: center;">
       加载太慢?
-      <button>关闭</button>
+      <button style="border: 1px solid; padding: 2px 4px; border-radius: 5px;">关闭</button>
     </div>
     <script>
       //加载超过30秒还没有加载完JS,就显示关闭按钮,可退出应用

+ 6 - 6
package.json

@@ -17,7 +17,7 @@
   "dependencies": {
     "@chenfengyuan/vue-qrcode": "^2.0.0",
     "@vicons/ionicons5": "^0.12.0",
-    "@vitejs/plugin-legacy": "^1.8.0",
+    "@vitejs/plugin-legacy": "1.8.0",
     "axios": "^0.26.1",
     "axios-progress-bar": "^1.2.0",
     "axios-retry": "^3.2.4",
@@ -25,7 +25,7 @@
     "js-md5": "^0.7.3",
     "js-sls-logger": "^2.0.1",
     "lodash-es": "^4.17.21",
-    "moment": "^2.29.1",
+    "moment": "2.29.1",
     "naive-ui": "^2.27.0",
     "pinia": "^2.0.13",
     "qrcode": "^1.5.0",
@@ -40,17 +40,17 @@
     "@types/lodash-es": "^4.17.6",
     "@types/node": "^17.0.23",
     "@types/ua-parser-js": "^0.7.36",
-    "@typescript-eslint/eslint-plugin": "^5.17.0",
-    "@typescript-eslint/parser": "^5.17.0",
+    "@typescript-eslint/eslint-plugin": "^5.18.0",
+    "@typescript-eslint/parser": "^5.18.0",
     "@vitejs/plugin-vue": "^2.3.1",
     "autoprefixer": "^10.4.4",
     "electron": "1.7.16",
     "eslint": "^8.12.0",
     "eslint-config-prettier": "^8.5.0",
-    "eslint-plugin-vue": "^8.5.0",
     "happy-dom": "^2.55.0",
+    "eslint-plugin-vue": "^8.6.0",
     "postcss": "^8.4.12",
-    "prettier": "^2.6.1",
+    "prettier": "^2.6.2",
     "typescript": "^4.6.3",
     "unplugin-auto-import": "^0.6.9",
     "unplugin-vue-components": "^0.18.5",

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 285 - 145
pnpm-lock.yaml


+ 3 - 0
src/constants/constants.ts

@@ -28,6 +28,9 @@ export const WEBSOCKET_FOR_FACE_ID =
 export const WEBSOCKET_FOR_AUDIO =
   window.location.origin.replace("http", "ws") + "/api/ws/fileAnswer";
 
+export const FACEID_LINENESS_URL =
+  "https://api.megvii.com/faceid/liveness/v2/do?token=";
+
 export const PRIVACY_READ_VERSION_NUMBER = "1";
 
 /** 限流请求的服务器 */

+ 12 - 74
src/features/OnlineExam/Examing/ExamingHome.vue

@@ -6,7 +6,7 @@ import QuestionFilters from "./QuestionFilters.vue";
 import ArrowNavView from "./ArrowNavView.vue";
 import QuestionNavView from "./QuestionNavView.vue";
 import FaceTracking from "./FaceTracking.vue";
-// import FaceId from "./FaceId.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";
@@ -22,6 +22,7 @@ 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";
 
 const { startWS } = useWebSocket();
 
@@ -31,6 +32,8 @@ let loading = $ref(true);
 const route = useRoute();
 const examId = +route.params.examId;
 const examRecordDataId = +route.params.examRecordDataId;
+store.exam.examId = examId;
+store.exam.examRecordDataId = examRecordDataId;
 
 useScreenTop(examRecordDataId);
 
@@ -219,9 +222,6 @@ onMounted(async () => {
 // beforeDestroy() {
 //   clearInterval(this.initSnapInterval);
 //   clearInterval(this.snapInterval);
-//   clearTimeout(this.faceIdMsgTimeout);
-//   clearTimeout(this.faceIdDivTimeout);
-//   closeWsWithoutReconnect();
 //   this.updateExamState({
 //     exam: null,
 //     paperStruct: null,
@@ -307,21 +307,6 @@ async function initData() {
   ]);
   courseName = _courseName;
 
-  // if (faceLivenessEnabled) {
-  //   const faceBiopsyBaseInfoData = await httpApp.get(
-  //     "/api/ecs_oe_student/faceBiopsy/getFaceBiopsyBaseInfo?examRecordDataId=" +
-  //       examRecordDataId,
-  //     { "axios-retry": { retries: 4 }, noErrorMessage: true }
-  //   );
-
-  //   // 释放出去,供定时
-  //   let faceVerifyMinute = null;
-  //   let identificationOfLivingBodyScheme = null;
-  //   faceVerifyMinute = faceBiopsyBaseInfoData.data.faceVerifyMinute;
-  //   identificationOfLivingBodyScheme =
-  //     faceBiopsyBaseInfoData.data.identificationOfLivingBodyScheme;
-  // }
-
   let examQuestionList = examQuestionListOrig;
 
   logger({
@@ -367,6 +352,9 @@ async function initData() {
 
   exam.WEIXIN_ANSWER_ENABLED = weixinAnswerEnabled;
 
+  store.exam.faceCheckEnabled = faceCheckEnabled;
+  store.exam.faceLivenessEnabled = faceLivenessEnabled;
+
   // if (faceCheckEnabled) {
   //   let initSnapshotTrialTimes = 0;
   //   this.initSnapInterval = setInterval(() => {
@@ -591,11 +579,14 @@ async function initData() {
     store.exam.questionAnswerFileUrl = [];
     startWS(
       WEBSOCKET_FOR_AUDIO + `?key=${store.user.key}&token=${store.user.token}`,
-      onAudioAnswer
+      onAudioAnswer,
+      "微信小程序作答socket"
     );
   }
 }
 
+let { showFaceId } = useFaceLive();
+
 // async function updateQuestion(next) {
 //   // 初始化套题的答案,为回填部分选项做准备
 //   // for (let q of this.examQuestionList) {
@@ -611,50 +602,6 @@ async function initData() {
 //   if (!this.exam) return;
 // }
 
-// 仅在线上使用活体检测
-// if (process.env.NODE_ENV === "production" && faceVerifyMinute) {
-
-// if (faceVerifyMinute) {  }
-// TODO: 活检定时,通过watch remain 来确定
-// console.log("活检定时");
-// this.logger({ action: "活检定时", detail: faceVerifyMinute });
-// const enoughTimeForFaceId = this.remainTime // 如果remainTime取到了的话
-//   ? this.remainTime / (60 * 1000) - 1 > faceVerifyMinute
-//   : true;
-// if (!enoughTimeForFaceId) return;
-// this.faceIdMsgTimeout = setTimeout(() => {
-//   this.logger({
-//     action: "答题页面",
-//     detail: "活体检测前抓拍",
-//   });
-//   this.toggleSnapNow();
-//   this.$Message.info({
-//     content: "30秒后开始指定动作检测",
-//     duration: 15,
-//     closable: true,
-//   });
-// }, faceVerifyMinute * 60 * 1000 - 30 * 1000); // 活体检测提醒
-// this.faceIdDivTimeout = setTimeout(() => {
-//   if (identificationOfLivingBodyScheme === "S1") {
-//     this.showFaceId = true;
-//   } else if (identificationOfLivingBodyScheme === "S2") {
-//     this.showFaceMotion = true;
-//   }
-// }, faceVerifyMinute * 60 * 1000); // 定时做活体检测
-// // }, 1 * 1000); // 定时做活体检测
-
-// for test
-// setTimeout(() => {
-//   this.showFaceId = true;
-//   // this.$Modal.remove();
-//   // }, this.$route.query.faceVerifyMinute * 60 * 1000); // 定时做活体检测
-// }, 5 * 1000); // 定时做活体检测
-
-// let showFaceId = $ref(false);
-// function closeFaceId() {
-//   showFaceId = false;
-// }
-
 function resetExamQuestionDirty() {
   store.exam.examQuestionList = store.exam.examQuestionList.map((eq) => {
     return Object.assign({}, eq, { dirty: false });
@@ -843,16 +790,7 @@ addInterval(() => checkRemoteApp(), 3 * 60 * 1000);
         />
       </div>
     </div>
-    <!-- <Modal
-      v-model="showFaceId"
-      :maskClosable="false"
-      :closable="false"
-      width="800"
-      :styles="{ top: '10px' }"
-    >
-      <FaceId v-if="showFaceId" @closeFaceid="closeFaceId" />
-      <p slot="footer"></p>
-    </Modal> -->
+    <FaceId v-if="showFaceId" @closeFaceid="showFaceId = false" />
     <FaceTracking v-if="store.exam.faceCheckEnabled" />
     <div
       v-if="disableExamingBecauseRemoteApp"

+ 478 - 0
src/features/OnlineExam/Examing/FaceId.vue

@@ -0,0 +1,478 @@
+<script setup lang="ts">
+import {
+  FACEID_LINENESS_URL,
+  WEBSOCKET_FOR_FACE_ID,
+} from "@/constants/constants";
+import { httpApp } from "@/plugins/axiosApp";
+import { useTimers } from "@/setups/useTimers";
+import { useWebSocket } from "@/setups/useWebSocket";
+import { store } from "@/store/store";
+import { showLogout } from "@/utils/utils";
+import { onUnmounted } from "vue";
+
+const emit = defineEmits<{ (e: "close-faceid"): void }>();
+
+const { addTimeout, addInterval } = useTimers();
+
+let electronDir = "";
+if (typeof window.nodeRequire != "undefined") {
+  const remote: import("electron").Remote =
+    window.nodeRequire("electron").remote;
+  electronDir = "file://" + remote.app.getAppPath() + "/";
+  logger({ cnl: ["server"], pgn: "活体检测弹出框", ext: { electronDir } });
+}
+
+logger({ cnl: ["server", "local"], pgn: "活体检测弹出框", act: "弹出框" });
+
+const { startWS } = useWebSocket();
+startWS(
+  WEBSOCKET_FOR_FACE_ID + `?key=${store.user.key}&token=${store.user.token}`,
+  onFaceIdMessage,
+  "活体检测socket"
+);
+
+// 退出faceid的办法只能二选一。下面是socket返回结果退出。
+let processedBySocketMsg = $ref(false);
+function onFaceIdMessage(event: MessageEvent<string>) {
+  logger({ cnl: ["server"], act: "活体检测FaceId response", dtl: event.data });
+  if (processedByTimeout) {
+    logger({ cnl: ["server"], act: "活体检测websocket消息来迟了,拒绝处理" });
+    return;
+  }
+  if (event.data.indexOf("verifyResult") > -1) {
+    logger({
+      cnl: ["server"],
+      pgn: "活体检测弹出框",
+      dtl: "websocket得到verifyResult消息",
+    });
+    processedBySocketMsg = true;
+    try {
+      const receivedMsgData: { content: { returnMsgJson: string } } =
+        JSON.parse(event.data);
+      const receivedMsg: FaceIDMessage = JSON.parse(
+        receivedMsgData.content.returnMsgJson
+      );
+      // 两个结束点。第二个结束点:从websocket得到消息。
+      void faceTestEnd(receivedMsg);
+    } catch (error) {
+      logger({
+        cnl: ["server"],
+        pgn: "活体检测弹出框",
+        act: "处理websocket消息时",
+        possibleError: error,
+      });
+    }
+    emit("close-faceid");
+  }
+}
+
+store.exam.isDoingFaceLiveness = true;
+onUnmounted(() => {
+  logger({ cnl: ["server"], pgn: "关闭活体检测弹出框" });
+  clearTimeout(iframeDomReadyTimeout);
+  store.exam.isDoingFaceLiveness = false;
+});
+
+let showIframe = $ref(false);
+let redoBtnDisabled = $ref(true);
+let redoBtnShow = $ref(false);
+let redoBtnMsg = $ref("");
+
+/** 设置重做按钮的文本 */
+function showRedo(redoMsg: string) {
+  showIframe = false;
+  redoBtnDisabled = false;
+  redoBtnShow = true;
+  redoBtnMsg = redoMsg || "系统繁忙,请手动点击重试";
+  logger({ cnl: ["server"], pgn: "活体检测弹出框", act: redoBtnMsg });
+}
+
+async function updateFaceVerifyMsg(errorMsg: string) {
+  logger({
+    cnl: ["server"],
+    pgn: "活体检测弹出框",
+    act: "faceid页面报错",
+    dtl: errorMsg,
+  });
+  await httpApp.get(
+    "/api/ecs_oe_student/examFaceLivenessVerify/updateFaceLivenessVerify/" +
+      store.exam.examRecordDataId,
+    { params: { errorMsg: errorMsg } }
+  );
+}
+
+// 退出faceid的办法只能二选一。下面是timeout退出。
+let processedByTimeout = false;
+async function faceidLoadedButTimeouted() {
+  if (!processedBySocketMsg) {
+    logger({
+      cnl: ["server"],
+      act: "faceidLoadedButTimeouted",
+      dtl: "已被websocket处理了",
+    });
+    return;
+  }
+
+  processedByTimeout = true;
+  logger({
+    cnl: ["server"],
+    act: "faceidLoadedButTimeouted",
+    dtl: "进入超时处理",
+  });
+  await httpApp
+    .get(
+      "/api/ecs_oe_student/examFaceLivenessVerify/getFaceVerifyResult/" +
+        faceVerifyId,
+      { "axios-retry": { retries: 4 } }
+    )
+    .then((response) => {
+      logger({
+        cnl: ["server"],
+        pgn: "活体检测弹出框",
+        act: "60秒超时非websocket处理结果",
+      });
+      const receivedMsg: FaceIDMessage = response.data;
+      void faceTestEnd(receivedMsg);
+      emit("close-faceid");
+    })
+    .catch((error) => {
+      logger({
+        cnl: ["server"],
+        pgn: "活体检测弹出框",
+        act: "60秒超时非websocket处理结果--api失败",
+        possibleError: error,
+      });
+    });
+}
+
+type FaceIDMessage = {
+  verifyCount: number;
+  verifyResult: string;
+};
+/** 指定动作检测结束 */
+async function faceTestEnd(receivedMsg: FaceIDMessage) {
+  logger({
+    cnl: ["server"],
+    pgn: "活体检测弹出框",
+    act: "指定动作检测结束",
+    ext: {
+      verifyCount: receivedMsg.verifyCount,
+      verifyResult: receivedMsg.verifyResult,
+    },
+  });
+  if (receivedMsg.verifyCount == 1) {
+    if (receivedMsg.verifyResult == "TIME_OUT") {
+      logger({
+        cnl: ["server"],
+        act: "第一次指定动作检测超时,检测失败,系统退出,请重新登录",
+      });
+      showLogout("第一次指定动作检测超时,检测失败,系统退出,请重新登录");
+    } else if (receivedMsg.verifyResult == "VERIFY_FAILED") {
+      logger({
+        cnl: ["server"],
+        act: "第一次指定动作检测失败,系统退出,请重新登录",
+      });
+      showLogout("第一次指定动作检测失败,系统退出,请重新登录");
+    } else if (receivedMsg.verifyResult == "NOT_ONESELF") {
+      logger({ cnl: ["server"], act: "指定动作检测不合格,结束考试" });
+      $message.error("指定动作检测不合格,结束考试");
+      return faceTestUploadResult("FAILED");
+    } else if (receivedMsg.verifyResult == "VERIFY_SUCCESS") {
+      logger({ cnl: ["server"], act: "指定动作检测成功,请继续完成考试" });
+      $message.info("指定动作检测成功,请继续完成考试");
+      return faceTestUploadResult("SUCCESS");
+    } else if (receivedMsg.verifyResult == "UNKNOWN") {
+      showLogout("第一次指定动作检测异常(fid),系统退出,请重新登录");
+    }
+  } else if (receivedMsg.verifyCount >= 2) {
+    if (receivedMsg.verifyResult == "VERIFY_SUCCESS") {
+      logger({ cnl: ["server"], act: "指定动作检测成功,请继续完成考试" });
+      $message.info("指定动作检测成功,请继续完成考试");
+      return faceTestUploadResult("SUCCESS");
+    } else {
+      logger({ cnl: ["server"], act: "指定动作检测不合格,结束考试" });
+      $message.error("指定动作检测不合格,结束考试");
+      return faceTestUploadResult("FAILED");
+    }
+  }
+}
+
+/** 指定动作检测结果返回后台处理 */
+async function faceTestUploadResult(result: string) {
+  logger({
+    cnl: ["server"],
+    pgn: "活体检测弹出框",
+    act: "上传活体检测结果",
+    ext: { result },
+  });
+  return httpApp
+    .get(
+      "/api/ecs_oe_student/examFaceLivenessVerify/faceLivenessVerifyEnd/" +
+        store.exam.examRecordDataId +
+        "?result=" +
+        result,
+      { "axios-retry": { retries: 4 } }
+    )
+    .then(() => {
+      if (result != "SUCCESS") {
+        logger({
+          cnl: ["server"],
+          pgn: "活体检测弹出框",
+          act: "活体检测失败-退出登录",
+        });
+        showLogout("活体检测失败");
+      } else {
+        logger({ cnl: ["server"], pgn: "活体检测弹出框", act: "活体检测成功" });
+      }
+    })
+    .catch(() => {
+      logger({
+        cnl: ["server"],
+        pgn: "活体检测弹出框",
+        act: "上传指定动作检测结果出错!--退出登录",
+      });
+      showLogout("上传指定动作检测结果出错!");
+    });
+}
+
+let timeCount = $ref(60); //指定动作检测倒计时60秒
+function iframeLoadSuccess() {
+  logger({
+    cnl: ["server"],
+    pgn: "活体检测弹出框",
+    act: "FaceID页面iframe加载成功",
+  });
+  // @ts-expect-error
+  if (!iframeLoadSuccess.loaded) {
+    // @ts-expect-error
+    iframeLoadSuccess.loaded = true;
+  } else {
+    logger({
+      cnl: ["server"],
+      key: "不可能的事情发生了",
+      dtl: "iframeLoadSuccess.loaded",
+    });
+    return;
+  }
+  const timeCountInterval = addInterval(() => {
+    timeCount--;
+    if (timeCount === 0) {
+      clearInterval(timeCountInterval);
+    }
+  }, 1000);
+
+  // 两个结束点。第一个结束点:超时。先传后台,再根据后台信息进行处理。可能ws没有收到处理结果,会通过http接收一遍。
+  //定时事件,如果1分钟内未完成指定动作检测,执行内部程序
+  addTimeout(faceidLoadedButTimeouted, 60 * 1000); //60000
+}
+
+async function startFaceVerifyClicked() {
+  logger({
+    cnl: ["server"],
+    pgn: "活体检测弹出框",
+    act: "点击重试按钮",
+  });
+  await startFaceVerify();
+}
+
+let faceVerifyId: string | null = null;
+let iframeDomReadyTimeout = -1;
+async function startFaceVerify() {
+  redoBtnDisabled = true;
+  redoBtnMsg = "正在进入指定动作检测...";
+
+  let response = null;
+  if (faceVerifyId) {
+    try {
+      response = await httpApp.get(
+        "/api/ecs_oe_student/examFaceLivenessVerify/getFaceVerifyToken/" +
+          faceVerifyId,
+        { "axios-retry": { retries: 4 } }
+      );
+    } catch (error) {
+      showRedo("网络异常,请手动点击重试");
+      logger({
+        cnl: ["server"],
+        pgn: "活体检测弹出框",
+        act: "网络异常-getFaceVerifyToken",
+        possibleError: error,
+      });
+      return;
+    }
+  } else {
+    try {
+      response = await httpApp.get(
+        "/api/ecs_oe_student/examFaceLivenessVerify/startFaceVerify/" +
+          store.exam.examRecordDataId,
+        { "axios-retry": { retries: 4 } }
+      );
+      faceVerifyId = response.data.faceVerifyId;
+    } catch (error) {
+      logger({
+        cnl: ["server"],
+        pgn: "活体检测弹出框",
+        act: "获取底照token失败,请重新登录!",
+        possibleError: error,
+      });
+      showLogout("获取底照token失败,请重新登录!");
+      return;
+    }
+  }
+
+  if (!response.data.success) {
+    logger({
+      cnl: ["server"],
+      pgn: "活体检测弹出框",
+      act: "您上传的底照不适合做活体检测,请联系老师!",
+      dtl: JSON.stringify(response.data),
+    });
+    showLogout("您上传的底照不适合做活体检测,请联系老师!");
+    return;
+  }
+
+  showIframe = true;
+  const iframe = <HTMLIFrameElement>document.getElementById("myFrame");
+  try {
+    iframe.src = FACEID_LINENESS_URL + response.data.faceLivenessToken;
+  } catch (err) {
+    logger({
+      cnl: ["server"],
+      pgn: "活体检测弹出框",
+      act: "set iframe.src",
+      key: "不可能的事情发生了",
+      possibleError: err,
+    });
+    showLogout("网络错误,请重试!");
+  }
+
+  if (!iframe) return;
+
+  {
+    // iframe 状态管理
+    clearTimeout(iframeDomReadyTimeout);
+
+    // 网络异常,最后的管理
+    iframeDomReadyTimeout = window.setTimeout(() => {
+      if (iframeDomReady === false) {
+        clearTimeout(iframeDomReadyTimeout);
+        showRedo("网络异常,请手动点击重试");
+        logger({ cnl: ["server"], pgn: "活体检测弹出框", act: "网络异常" });
+      }
+    }, 30 * 1000);
+
+    let iframeDomReady = false;
+    iframe.addEventListener("did-start-loading", () => {
+      logger({
+        cnl: ["server", "local", "console"],
+        pgn: "活体检测弹出框",
+        act: "loading faceid iframe",
+      });
+      console.log(iframe);
+    });
+
+    iframe.addEventListener("did-fail-load", () => {
+      logger({
+        cnl: ["server", "local", "console"],
+        pgn: "活体检测弹出框",
+        act: "failed loading faceid iframe",
+      });
+      clearTimeout(iframeDomReadyTimeout);
+      if (iframe.src.includes(FACEID_LINENESS_URL)) {
+        showRedo("网络异常,请手动点击重试");
+        logger({
+          cnl: ["server"],
+          pgn: "活体检测弹出框",
+          act: "网络异常-加载失败",
+        });
+      }
+    });
+
+    iframe.addEventListener("dom-ready", () => {
+      iframeDomReady = true;
+      logger({ cnl: ["server", "console"], act: "faceid iframe dom ready" });
+      // TODO: need verify
+      // iframe.insertCSS(".copyright { display: none !important;}");
+      // iframe.openDevTools();
+    });
+
+    iframe.addEventListener("ipc-message", (event: any) => {
+      logger({
+        cnl: ["server", "console"],
+        act: "got ipc-message",
+        dtl: event.channel,
+      });
+      clearTimeout(iframeDomReadyTimeout);
+      const iframeLoadMsg: string = event.channel;
+      if (iframeLoadMsg.indexOf("error_message") > -1) {
+        showRedo("");
+        void updateFaceVerifyMsg(iframeLoadMsg);
+      }
+      if (iframeLoadMsg === "success") {
+        iframeLoadSuccess();
+      }
+    });
+  }
+}
+
+// 页面渲染后自动进入流程
+void startFaceVerify();
+</script>
+
+<template>
+  <n-modal
+    :show="true"
+    :closable="false"
+    :maskClosable="false"
+    preset="card"
+    style="width: 800px"
+  >
+    <div class="col-md-12 text-center" style="padding: 8px">
+      <div style="font-size: 30px">
+        <span>指定动作检测</span>
+        <span v-if="showIframe">({{ timeCount }})</span>
+      </div>
+      <div
+        v-if="showIframe"
+        class="text-center"
+        style="color: red; font-size: 16px"
+      >
+        (注意:请点击下方“开始比对”按钮并在60秒内完成指定动作检测,超时将退出考试)
+      </div>
+    </div>
+    <div
+      id="faceIdDiv"
+      style="
+        position: relative;
+        height: 710px;
+        background-color: #6e6f72 !important;
+        background-image: radial-gradient(circle at 50% 0, #a9a9a9, #34363c);
+      "
+    >
+      <div
+        v-show="!showIframe"
+        width="100%"
+        height="200px"
+        style="text-align: center; line-height: 100px; margin-top: 5px"
+      >
+        <div style="color: white; font-weight: bold; font-size: 20px">
+          {{ redoBtnMsg }}
+        </div>
+        <button
+          v-if="redoBtnShow"
+          type="button"
+          class="qm-primary-button"
+          :disabled="redoBtnDisabled"
+          @click="startFaceVerifyClicked"
+        >
+          重试
+        </button>
+      </div>
+      <webview
+        v-show="showIframe"
+        id="myFrame"
+        :preload="electronDir + 'manipulateFaceID.js'"
+        style="position: absolute; width: 100%; height: 710px"
+      />
+    </div>
+  </n-modal>
+</template>

+ 2 - 10
src/features/OnlineExam/Examing/FaceTracking.vue

@@ -4,6 +4,7 @@ import { FACE_API_MODEL_PATH } from "@/constants/constants";
 import { isThisMachineOwnByStudent } from "@/utils/utils";
 import { onMounted } from "vue";
 import { useTimers } from "@/setups/useTimers";
+import { store } from "@/store/store";
 
 const { addTimeout, addInterval } = useTimers();
 // window.faceapi = faceapi;
@@ -61,7 +62,6 @@ function tensorFlowWebPackStatus() {
 // if (os.isWin10) alert("是win10");
 
 let __inputSize = 128;
-let __isDoingFaceLiveness = false;
 let disableFaceTracking = false;
 
 async function detectTest() {
@@ -115,7 +115,7 @@ async function detectTest() {
   for (let idx = 0; idx < inputSizeList.length; idx++) {
     for (let n = 0; n < detectTimes; n++) {
       await new Promise((resolve) => setTimeout(resolve, 3 * 1000));
-      if (__isDoingFaceLiveness) {
+      if (store.exam.isDoingFaceLiveness) {
         console.log("正在活检,暂停实时人脸");
         await new Promise((resolve) => setTimeout(resolve, 120 * 1000));
       }
@@ -193,14 +193,6 @@ function getFaceDetectorOptions() {
 
 const detectTimeArray: number[] = [];
 
-//   ...mapState(["isDoingFaceLiveness"]),
-// },
-// watch: {
-//   isDoingFaceLiveness: function (val) {
-//     __isDoingFaceLiveness = val;
-//   },
-// },
-
 onMounted(async () => {
   await faceapi.nets.tinyFaceDetector.load(FACE_API_MODEL_PATH);
   // faceapi.nets.faceRecognitionNet.load(modelsPath);

+ 70 - 0
src/features/OnlineExam/Examing/setups/useFaceLive.ts

@@ -0,0 +1,70 @@
+import { httpApp } from "@/plugins/axiosApp";
+import { useTimers } from "@/setups/useTimers";
+import { store } from "@/store/store";
+import { watch } from "vue";
+
+const { addTimeout } = useTimers();
+
+export function useFaceLive() {
+  let showFaceId = $ref(false);
+
+  watch(() => store.exam.faceLivenessEnabled, initData, { immediate: true });
+
+  async function initData() {
+    if (!store.exam.faceLivenessEnabled) {
+      return;
+    }
+
+    const faceBiopsyBaseInfoData = await httpApp.get<{
+      faceVerifyMinute: number;
+      identificationOfLivingBodyScheme: "S1" | "S2";
+    }>(
+      "/api/ecs_oe_student/faceBiopsy/getFaceBiopsyBaseInfo?examRecordDataId=" +
+        store.exam.examRecordDataId,
+      { "axios-retry": { retries: 4 }, noErrorMessage: true }
+    );
+
+    const faceVerifyMinute = faceBiopsyBaseInfoData.data.faceVerifyMinute;
+    const identificationOfLivingBodyScheme =
+      faceBiopsyBaseInfoData.data.identificationOfLivingBodyScheme;
+
+    // 仅在线上使用活体检测
+    // if (process.env.NODE_ENV === "production" && faceVerifyMinute) {
+    if (faceVerifyMinute) {
+      // TODO: 活检定时,通过watch remain 来确定
+      logger({
+        cnl: ["server"],
+        act: "活检定时",
+        ext: {
+          faceVerifyMinute,
+          remainTime: store.exam.remainTime,
+        },
+      });
+
+      addTimeout(() => {
+        logger({ cnl: ["server"], act: "活体检测前抓拍" });
+        // FIXME: 添加抓拍事件
+        // this.toggleSnapNow();
+        $message.info("30秒后开始指定动作检测");
+      }, faceVerifyMinute * 60 * 1000 - 30 * 1000); // 活体检测提醒
+
+      addTimeout(() => {
+        if (identificationOfLivingBodyScheme === "S1") {
+          showFaceId = true;
+          // } else if (identificationOfLivingBodyScheme === "S2") {
+          //   this.showFaceMotion = true;
+        }
+        // }, faceVerifyMinute * 60 * 1000); // 定时做活体检测
+      }, 1 * 1000); // 定时做活体检测
+    }
+
+    // for test
+    // setTimeout(() => {
+    //   this.showFaceId = true;
+    //   // this.$Modal.remove();
+    //   // }, this.$route.query.faceVerifyMinute * 60 * 1000); // 定时做活体检测
+    // }, 5 * 1000); // 定时做活体检测
+  }
+
+  return { showFaceId: $$(showFaceId) };
+}

+ 5 - 1
src/features/OnlineExam/OnlineExamHome.vue

@@ -12,6 +12,8 @@ const { examType = "ONLINE" } = defineProps<{ examType?: ExamType }>();
 let courses: OnlineExam[] = $ref([]);
 let endCourses: OnlineExam[] = $ref([]);
 
+let loading = $ref(true);
+
 onMounted(async () => {
   // router.back 时关闭摄像头
   closeMediaStream();
@@ -21,7 +23,7 @@ onMounted(async () => {
     act: "进入页面",
   });
 
-  await getData();
+  await getData().finally(() => (loading = false));
 });
 
 async function getData() {
@@ -82,7 +84,9 @@ async function getData() {
 
 <template>
   <div class="part-box">
+    <div v-if="loading">loading</div>
     <OnlineExamList
+      v-else
       :courses="courses"
       :endCourses="endCourses"
       :examType="examType"

+ 2 - 2
src/features/UserLogin/UserLogin.vue

@@ -46,9 +46,9 @@ if (isElectron()) {
     act: "versonstats",
     ext: {
       packageVersion: "ua-" + ua.getBrowser().version,
-      file: eval(`process.env.PORTABLE_EXECUTABLE_FILE`),
+      file: eval(`proceess.env.PORTABLE_EXECUTABLE_FILE`),
       uaGood:
-        "uagood-" + (eval(`process.env.PORTABLE_EXECUTABLE_FILE`) ? 1 : 0),
+        "uagood-" + (eval(`proceess.env.PORTABLE_EXECUTABLE_FILE`) ? 1 : 0),
     },
   });
 }

+ 1 - 1
src/features/UserLogin/useAppVersion.ts

@@ -5,7 +5,7 @@ import { onMounted, Ref, watch } from "vue";
 
 export function useAppVersion(newVersionAvailable: Ref<boolean>) {
   function checkApp() {
-    if (isElectron() && !eval(`process.env.PORTABLE_EXECUTABLE_FILE`)) {
+    if (isElectron() && !eval(`proceess.env.PORTABLE_EXECUTABLE_FILE`)) {
       disableLoginBtnBecauseAppVersionChecker = true;
       if (ua.getBrowser().version !== "1.9.3") {
         $message.error("请与学校申请最新的客户端,进行考试!", {

+ 3 - 1
src/features/UserLogin/useNewVersion.ts

@@ -10,7 +10,9 @@ export function useNewVersion() {
     myHeaders.append("Content-Type", "application/javascript");
     myHeaders.append("Cache-Control", "no-cache");
     const response = await fetch(
-      [...document.scripts].at(-1)?.src + "?x" + Date.now(),
+      [...document.scripts].at(-1)?.getAttribute("data-src") +
+        "?x" +
+        Date.now(),
       {
         method: import.meta.env.DEV ? "GET" : "HEAD",
         headers: myHeaders,

+ 10 - 7
src/features/UserLogin/useRemoteAppChecker.ts

@@ -89,7 +89,7 @@ export function useRemoteAppChecker() {
 
   const QECSConfig = $computed(() => store.QECSConfig);
   watch(
-    QECSConfig,
+    () => QECSConfig,
     async () => {
       if (import.meta.env.DEV) {
         disableLoginBtnBecauseRemoteApp = false;
@@ -105,13 +105,13 @@ export function useRemoteAppChecker() {
       }
 
       let exe = "Project1.exe";
-      if (fileExists("Project2.exe")) {
-        const remoteAppName = REMOTE_APP_NAME;
-        exe = `Project2.exe "${remoteAppName}" `;
-      }
-
-      const fs: typeof import("fs") = window.nodeRequire("fs");
       try {
+        if (fileExists("Project2.exe")) {
+          const remoteAppName = REMOTE_APP_NAME;
+          exe = `Project2.exe "${remoteAppName}" `;
+        }
+
+        const fs: typeof import("fs") = window.nodeRequire("fs");
         fileExists("remoteApplication.txt") &&
           fs.unlinkSync("remoteApplication.txt");
       } catch (error) {
@@ -121,7 +121,10 @@ export function useRemoteAppChecker() {
           pgu: "AUTO",
           key: "checkRemoteAppTxt",
           dtl: "unlink remoteApplication.txt 失败",
+          possibleError: error,
         });
+        $message.error("系统检测出错(e-01),请退出程序后重试!");
+        throw error;
       }
       await execLocal(exe);
 

+ 28 - 22
src/features/UserLogin/useVCamChecker.ts

@@ -60,32 +60,38 @@ export function useVCamChecker() {
   let disableLoginBtnBecauseVCam = $ref(true);
 
   const QECSConfig = $computed(() => store.QECSConfig);
-  watch(QECSConfig, async () => {
-    if (
-      !QECSConfig.PREVENT_CHEATING_CONFIG.includes("DISABLE_VIRTUAL_CAMERA")
-    ) {
-      disableLoginBtnBecauseVCam = false;
-      return;
-    }
+  watch(
+    () => QECSConfig,
+    async () => {
+      if (
+        !QECSConfig.PREVENT_CHEATING_CONFIG.includes("DISABLE_VIRTUAL_CAMERA")
+      ) {
+        disableLoginBtnBecauseVCam = false;
+        return;
+      }
 
-    if (import.meta.env.DEV) return;
+      if (import.meta.env.DEV) return;
 
-    const fs: typeof import("fs") = window.nodeRequire("fs");
-    try {
-      fileExists("CameraInfo.txt") && fs.unlinkSync("CameraInfo.txt");
-    } catch (error) {
-      console.log(error);
-      logger({
-        cnl: ["local", "server"],
-        key: "checkVCamTxt",
-        dtl: "unlink CameraInfo.txt 失败",
-      });
-    }
+      try {
+        const fs: typeof import("fs") = window.nodeRequire("fs");
+        fileExists("CameraInfo.txt") && fs.unlinkSync("CameraInfo.txt");
+      } catch (error) {
+        console.log(error);
+        logger({
+          cnl: ["local", "server"],
+          key: "checkVCamTxt",
+          dtl: "unlink CameraInfo.txt 失败",
+          possibleError: error,
+        });
+        $message.error("系统检测出错(e-02),请退出程序后重试!");
+        throw error;
+      }
 
-    await execLocal("multiCamera.exe");
+      await execLocal("multiCamera.exe");
 
-    await checkVCamTxt();
-  });
+      await checkVCamTxt();
+    }
+  );
 
   return { disableLoginBtnBecauseVCam: $$(disableLoginBtnBecauseVCam) };
 }

+ 31 - 11
src/setups/useWebSocket.ts

@@ -9,6 +9,7 @@ export function useWebSocket() {
   let ws: WebSocket;
   let heartbeatIds: number[] = [];
   const RECONNECT_INTERVAL = 6 * 1000;
+  let reconnectIds: number[] = [];
   const HEARTBEAT_INTERVAL = 50 * 1000;
   let reconnectNumber = 0;
 
@@ -16,12 +17,18 @@ export function useWebSocket() {
 
   let url: string;
   let onMessage: (e: MessageEvent) => void;
+  let byWho: string;
 
   const { addTimeout, addInterval } = useTimers();
 
-  function startWS(_url: string, _onMessage: (e: MessageEvent) => void) {
+  function startWS(
+    _url: string,
+    _onMessage: (e: MessageEvent) => void,
+    _byWho: string
+  ) {
     url = _url;
     onMessage = _onMessage;
+    byWho = _byWho;
     openWS();
   }
 
@@ -31,7 +38,7 @@ export function useWebSocket() {
   function openWS() {
     logger({
       cnl: ["server", "console"],
-      key: "微信小程序websocket",
+      key: byWho,
       act: "准备连接",
       // 连接websocket时,ws要么还没初始化,要么是closed,否则均不正常
       dtl: [undefined, 3].includes(ws?.readyState)
@@ -46,7 +53,7 @@ export function useWebSocket() {
       $message.error("Websocket初始化失败", { duration: 5, closable: true });
       logger({
         cnl: ["server", "console"],
-        key: "微信小程序websocket",
+        key: byWho,
         act: "Websocket初始化失败",
         possibleError: error,
         ext: { url },
@@ -56,12 +63,22 @@ export function useWebSocket() {
     ws.onopen = () => {
       logger({
         cnl: ["server", "console"],
-        key: "微信小程序websocket",
+        key: byWho,
         act: "连接成功",
         ext: { url },
       });
 
+      // 后台是通过onmessage来发送事件告知status的,这种情况下404判断不了reconnect
+      // try {
+      //   const msg = JSON.parse((<any>event).data as string);
+      //   if (msg.status.code === "200") {
+      //     reconnectNumber = 0;
+      //   }
+      // } catch (error) {
+      //   logger({ cnl: ["server"], key: byWho, act: "open message not valid" });
+      // }
       reconnectNumber = 0;
+
       heartbeat();
     };
 
@@ -70,7 +87,7 @@ export function useWebSocket() {
     ws.onclose = (event) => {
       logger({
         cnl: ["server", "console"],
-        key: "微信小程序websocket",
+        key: byWho,
         act: "ws closed by server",
         dtl: JSON.stringify(event),
         ext: { url },
@@ -84,7 +101,9 @@ export function useWebSocket() {
     };
 
     function reconnect(cause: string) {
-      addTimeout(() => {
+      reconnectIds.forEach((id) => clearTimeout(id));
+      reconnectIds = [];
+      const tid = addTimeout(() => {
         reconnectNumber++;
         if (reconnectNumber >= 5) {
           reconnectNumber = 0;
@@ -95,19 +114,20 @@ export function useWebSocket() {
         }
         logger({
           cnl: ["server", "console"],
-          key: "微信小程序websocket",
+          key: byWho,
           act: "连接被关闭后-准备连接-" + cause,
           ext: { url },
         });
         // 会让断开就重连
         openWS();
       }, RECONNECT_INTERVAL);
+      reconnectIds.push(tid);
     }
 
     ws.onerror = (event) => {
       logger({
         cnl: ["server", "console"],
-        key: "微信小程序websocket",
+        key: byWho,
         act: "onerror",
         dtl: JSON.stringify(event),
         ext: { url },
@@ -126,7 +146,7 @@ export function useWebSocket() {
       logger({
         lvl: "debug",
         cnl: ["server", "console"],
-        key: "微信小程序websocket",
+        key: byWho,
         act: "websocket heartbeat",
         ext: { url },
       });
@@ -140,7 +160,7 @@ export function useWebSocket() {
     closeExplicitly = true;
     logger({
       cnl: ["server", "console"],
-      key: "微信小程序websocket",
+      key: byWho,
       act: "客户端准备关闭ws。",
       ext: { url },
     });
@@ -152,7 +172,7 @@ export function useWebSocket() {
     } catch (e) {
       logger({
         cnl: ["server", "console"],
-        key: "微信小程序websocket",
+        key: byWho,
         act: "关闭ws异常。",
         possibleError: e,
         ext: { url },

+ 7 - 0
src/types/global.d.ts

@@ -29,3 +29,10 @@ declare module "axios/index" {
     setGlobalMask?: boolean;
   }
 }
+
+declare module "vue" {
+  export interface GlobalComponents {
+    /** electron webview */
+    webview: any;
+  }
+}

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

@@ -148,6 +148,8 @@ export type Store = {
       /** 活体检测类型 S1: FaceID S2: 自研活体 */
       identificationOfLivingBodyScheme: "S1" | "S2";
     };
+    /** 是否在进行活体检测 */
+    isDoingFaceLiveness: boolean;
     /** 抓拍间隔(秒) */
     SNAPSHOT_INTERVAL: number;
     /** 考试冻结(秒) */

+ 1 - 1
src/utils/camera.ts

@@ -53,7 +53,7 @@ export async function getMediaStream(): Promise<MediaStream> {
     // logger detail info
     {
       const vt0 = stream.getVideoTracks()[0];
-      if (vt0 && vt0.getConstraints && vt0.getSettings) {
+      if (vt0 && vt0.getCapabilities && vt0.getSettings) {
         logger({
           cnl: ["local", "server"],
           pgu: "AUTO",

+ 41 - 30
src/utils/nativeMethods.ts

@@ -182,7 +182,7 @@ export function checkMainExe(): boolean {
       .replace("ExecutablePath=", "")
       .trim()
       .replace(/&amp;/g, "&");
-    if (executablePath === eval(`process.env.PORTABLE_EXECUTABLE_FILE`)) {
+    if (executablePath === eval(`proceess.env.PORTABLE_EXECUTABLE_FILE`)) {
       const crypto: typeof import("crypto") = window.nodeRequire("crypto");
       const getHash = crypto
         .createHmac("sha256", "abcdefg")
@@ -213,18 +213,26 @@ export function checkMainExe(): boolean {
 /** 初始化桌面抓拍 */
 export function initScreenShot() {
   if (import.meta.env.DEV) return;
-  // eslint-disable-next-line @typescript-eslint/no-unsafe-call
-  window
-    .nodeRequire("electron")
-    .desktopCapturer.getSources(
-      { types: ["window", "screen"] },
-      async (e: any, sources: { id: string; name: string }[]) => {
-        console.log(e, sources);
-        for (const source of sources) {
-          console.log(source);
-          if (source.name === "Entire screen") {
-            try {
-              const stream = await navigator.mediaDevices.getUserMedia({
+  if (!isElectron()) return;
+
+  function handleStream(stream: MediaStream) {
+    const video: HTMLVideoElement = document.querySelector("#ssVideo")!;
+    video.srcObject = stream;
+    video.onloadedmetadata = () => video.play();
+  }
+
+  const electron: typeof import("electron") = window.nodeRequire("electron");
+
+  electron.desktopCapturer.getSources(
+    { types: ["window", "screen"] },
+    (e: any, sources: { id: string; name: string }[]) => {
+      console.log(e, sources);
+      for (const source of sources) {
+        console.log(source);
+        if (source.name === "Entire screen") {
+          try {
+            navigator.mediaDevices
+              .getUserMedia({
                 audio: false,
                 video: {
                   // @ts-expect-error 不确定是chrome/electron标准是否不一样,需要测试
@@ -237,26 +245,29 @@ export function initScreenShot() {
                     maxHeight: 480,
                   },
                 },
-              });
-              handleStream(stream);
-            } catch (err) {
-              logger({
-                cnl: ["local", "server"],
-                pgu: "AUTO",
-                act: "ss-failed",
-                ejn: JSON.stringify(err),
-              });
-            }
-            return;
+              })
+              .then((stream) => handleStream(stream))
+              .catch((e) =>
+                logger({
+                  cnl: ["local", "server"],
+                  pgu: "AUTO",
+                  act: "ss-failed",
+                  possibleError: e,
+                })
+              );
+          } catch (err) {
+            logger({
+              cnl: ["local", "server"],
+              pgu: "AUTO",
+              act: "ss-failed",
+              ejn: JSON.stringify(err),
+            });
           }
+          return;
         }
       }
-    );
-  function handleStream(stream: MediaStream) {
-    const video: HTMLVideoElement = document.querySelector("#ssVideo")!;
-    video.srcObject = stream;
-    video.onloadedmetadata = () => video.play();
-  }
+    }
+  );
 }
 
 /** 保存当前屏幕截图 */

+ 1 - 3
src/utils/utils.ts

@@ -20,9 +20,7 @@ export function showLogout(cause: string) {
     pgu: "AUTO",
     cnl: ["console", "local", "server"],
     dtl: "用户被要求重新登录",
-    ext: {
-      cause,
-    },
+    ext: { cause },
   });
   $message.warning(cause, { duration: 5 * 60 * 1000, closable: true });
   void router.push({ name: "UserLogin" });

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно