Răsfoiți Sursa

Merge branch 'release_v4.0.2'

Michael Wang 4 ani în urmă
părinte
comite
043a653dec
31 a modificat fișierele cu 824 adăugiri și 189 ștergeri
  1. 1 1
      prebuild.js
  2. 101 9
      src/components/FaceRecognition/FaceRecognition.vue
  3. 19 2
      src/components/MainLayout/MainLayout.vue
  4. 5 0
      src/components/MainLayout/SiteMessagePopup.vue
  5. 3 1
      src/constants/constants.js
  6. 24 4
      src/features/Login/GeeTest.vue
  7. 5 0
      src/features/Login/GlobalNotice.vue
  8. 57 11
      src/features/Login/Login.vue
  9. 35 8
      src/features/OfflineExam/OfflineExamList.vue
  10. 1 1
      src/features/OnlineExam/CheckComputer.vue
  11. 20 6
      src/features/OnlineExam/Examing/ExamingHome.vue
  12. 9 1
      src/features/OnlineExam/Examing/FaceId.vue
  13. 6 1
      src/features/OnlineExam/Examing/FaceTracking.vue
  14. 4 1
      src/features/OnlineExam/Examing/RemainTime.vue
  15. 5 0
      src/features/OnlineExam/OnlineExamFaceCheckModal.vue
  16. 30 22
      src/features/OnlineExam/OnlineExamHome.vue
  17. 54 2
      src/features/OnlineExam/OnlineExamList.vue
  18. 18 2
      src/features/OnlineExam/OnlineExamOverview.vue
  19. 15 1
      src/features/OnlineExam/PhoneVerifyForDD.vue
  20. 219 0
      src/features/OnlineExam/PrivacyDialog.vue
  21. 89 12
      src/features/OnlinePractice/OnlinePracticeList.vue
  22. 5 0
      src/features/Password/Password.vue
  23. 5 0
      src/features/SiteMessage/SiteMessageDetail.vue
  24. 5 0
      src/features/SiteMessage/SiteMessageHome.vue
  25. 8 2
      src/utils/axios.js
  26. 22 0
      src/utils/deviceInfo.js
  27. 12 4
      src/utils/logger.js
  28. 30 94
      src/utils/monitors.js
  29. 1 0
      src/utils/nativeExe.js
  30. 11 4
      src/utils/tryLimit.js
  31. 5 0
      src/views/NotFoundComponent.vue

+ 1 - 1
prebuild.js

@@ -8,7 +8,7 @@ if (packageJson.dependencies.iview !== "^3.5.4") {
 console.log("> prebuild");
 console.log("> prebuild create .env.*.local");
 
-const buildDate = require("moment")().format("YYYY-MM-DD HH:mm:SS");
+const buildDate = require("moment")().format("YYYY-MM-DD HH:mm:ss");
 console.log("  构建日期为 " + buildDate);
 
 const revision = require("child_process")

+ 101 - 9
src/components/FaceRecognition/FaceRecognition.vue

@@ -200,11 +200,12 @@ export default {
               this.logger({
                 action: "摄像头没有正常启用",
                 detail: error,
-                errorName: error.name,
-                errorMessage: error.message,
-                errorStringify: JSON.stringify(error, (key, value) =>
+                errorJSON: JSON.stringify(error, (key, value) =>
                   key === "token" ? "" : value
                 ),
+                errorName: error.name,
+                errorMessage: error.message,
+                errorStack: error.stack,
               });
               this.$Message.error({
                 content: "摄像头没有正常启用: " + error,
@@ -288,7 +289,12 @@ export default {
           this.logger({
             action: "摄像头打开失败",
             detail: "无法启用摄像头",
-            error: errorMsgLog,
+            errorJSON: JSON.stringify(error, (key, value) =>
+              key === "token" ? "" : value
+            ),
+            errorName: error.name,
+            errorMessage: error.message,
+            errorStack: error.stack,
             // getSupportedConstraints:
             //   navigator.mediaDevices.getSupportedConstraints &&
             //   navigator.mediaDevices.getSupportedConstraints(),
@@ -365,9 +371,12 @@ export default {
         console.log("定时抓拍流程失败", error);
         this.logger({
           action: "定时抓拍流程失败",
-          detail: JSON.stringify(error, (key, value) =>
+          errorJSON: JSON.stringify(error, (key, value) =>
             key === "token" ? "" : value
           ),
+          errorName: error.name,
+          errorMessage: error.message,
+          errorStack: error.stack,
         });
         window._hmt.push([
           "_trackEvent",
@@ -404,6 +413,12 @@ export default {
       // console.log(v);
       // return;
 
+      this.logger({
+        page: "同步人脸比对",
+        button: "开始识别按钮",
+        action: "点击",
+      });
+
       this.$Message.destroy();
       try {
         this.disableSnap = true;
@@ -531,6 +546,8 @@ export default {
         }
         // console.log(fileMd5);
         // console.log(fileMd5, fileMd5Base64);
+        this.__duplicateMD5 = window.__previousPhotoMD5 === fileMd5Base64;
+        window.__previousPhotoMD5 = fileMd5Base64;
 
         const params = new URLSearchParams();
         params.append("fileSuffix", "png");
@@ -587,9 +604,12 @@ export default {
           this.logger({
             page: "摄像头框",
             detail: "抓拍照片保存失败",
-            error: JSON.stringify(error, (key, value) =>
+            errorJSON: JSON.stringify(error, (key, value) =>
               key === "token" ? "" : value
             ),
+            errorName: error.name,
+            errorMessage: error.message,
+            errorStack: error.stack,
           });
           window._hmt.push([
             "_trackEvent",
@@ -617,9 +637,12 @@ export default {
         this.logger({
           page: "摄像头框",
           detail: "保存抓拍照片到服务器失败!",
-          error: JSON.stringify(e, (key, value) =>
+          errorJSON: JSON.stringify(e, (key, value) =>
             key === "token" ? "" : value
           ),
+          errorName: e.name,
+          errorMessage: e.message,
+          errorStack: e.stack,
         });
         window._hmt.push([
           "_trackEvent",
@@ -646,12 +669,20 @@ export default {
     },
     async faceCompareSync(captureFilePath, signIdentifier) {
       try {
+        this.logger({
+          page: "摄像头框",
+          action: "同步比对开始",
+        });
         const res = await this.$http.post(
           "/api/ecs_oe_student_face/examCaptureQueue/compareFaceSync?signIdentifier=" +
             signIdentifier +
             "&fileUrl=" +
             encodeURIComponent(captureFilePath)
         );
+        this.logger({
+          page: "摄像头框",
+          action: "同步比对成功",
+        });
 
         this.$emit("on-recognize-result", {
           error: null,
@@ -661,6 +692,16 @@ export default {
       } catch (e) {
         console.log(e);
         // this.$Message.error(e.message);
+        this.logger({
+          page: "摄像头框",
+          action: "同步比对失败",
+          errorJSON: JSON.stringify(e, (key, value) =>
+            key === "token" ? "" : value
+          ),
+          errorName: e.name,
+          errorMessage: e.message,
+          errorStack: e.stack,
+        });
         throw new Error("同步照片比较失败!");
       }
     },
@@ -676,6 +717,8 @@ export default {
                 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, "");
@@ -695,14 +738,59 @@ export default {
                         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: "虚拟摄像头-读取摄像头列表失败",
-                      error: JSON.stringify(error, (key, value) =>
+                      errorJSON: JSON.stringify(error, (key, value) =>
                         key === "token" ? "" : value
                       ),
+                      errorName: error.name,
+                      errorMessage: error.message,
+                      errorStack: error.stack,
                     });
                     window._hmt.push([
                       "_trackEvent",
@@ -736,6 +824,7 @@ export default {
             examRecordDataId,
             cameraInfos,
             hasVirtualCamera,
+            duplicateMD5: this.__duplicateMD5,
           });
         }
         const res = await this.$http.post(
@@ -749,9 +838,12 @@ export default {
           this.logger({
             page: "摄像头框",
             action: "设置获取抓拍结果失败!",
-            error: JSON.stringify(error, (key, value) =>
+            errorJSON: JSON.stringify(error, (key, value) =>
               key === "token" ? "" : value
             ),
+            errorName: error.name,
+            errorMessage: error.message,
+            errorStack: error.stack,
           });
           this.$Message.error({
             content: "设置获取抓拍结果失败!",

+ 19 - 2
src/components/MainLayout/MainLayout.vue

@@ -7,7 +7,10 @@
           <Tabs type="card">
             <TabPane label="下载安卓apk">
               <div class="qm-primary-text flex-center">
-                <qrcode :value="qrValue" :options="{ width: 200 }"></qrcode>
+                <qrcode
+                  value="https://d.zqapps.com/qfns"
+                  :options="{ width: 200 }"
+                ></qrcode>
               </div>
             </TabPane>
             <TabPane label="绑定用户">
@@ -68,7 +71,16 @@
       <a
         class="qm-primary-text"
         style="display: inline-block; margin-right: 20px; text-align: center"
-        @click="() => logout('?LogoutReason=正常退出')"
+        @click="
+          () => {
+            logger({
+              page: 'MainLayout',
+              button: '退出按钮',
+              action: '点击',
+            });
+            logout('?LogoutReason=正常退出');
+          }
+        "
       >
         {{ isEpcc ? "返回" : "退出登录" }}
       </a>
@@ -240,6 +252,11 @@ export default {
   methods: {
     ...mapMutations(["updateMenus"]),
     goChangePwd() {
+      this.logger({
+        page: "MainLayout",
+        button: "修改密码按钮",
+        action: "点击",
+      });
       this.$router.push("/password");
     },
   },

+ 5 - 0
src/components/MainLayout/SiteMessagePopup.vue

@@ -87,6 +87,11 @@ export default {
   },
   methods: {
     ignoreMessage() {
+      this.logger({
+        page: "SiteMessagePopup",
+        button: "忽略按钮",
+        action: "点击",
+      });
       this.ignoreMessageIds.push(this.unreadMessage.id);
       window.sessionStorage.setItem(
         "ignoreMessageIds",

+ 3 - 1
src/constants/constants.js

@@ -36,7 +36,9 @@ export const CUG_DOMAIN = "cug.ecs.qmth.com.cn";
 
 let domain;
 if (process.env.VUE_APP_SELF_DEFINE_DOMAIN === "true") {
-  domain = window.localStorage.getItem("domain_in_url");
+  domain = window.localStorage.getItem("exam_cloud_domain_in_url");
 }
 if (!domain) domain = window.location.hostname.split(".")[0];
 export const DOMAIN_IN_URL = domain + ".ecs.qmth.com.cn";
+
+export const PRIVACY_READ_VERSION_NUMBER = "1";

+ 24 - 4
src/features/Login/GeeTest.vue

@@ -52,10 +52,30 @@ export default {
         });
       }
 
-      const res = await this.$http.post("/api/ecs_core/verifyCode/register", {
-        user_id: localStorage.getItem("uuidForEcs"),
-        client_type: "Web",
-      });
+      let res = null;
+      try {
+        res = await this.$http.post("/api/ecs_core/verifyCode/register", {
+          user_id: localStorage.getItem("uuidForEcs"),
+          client_type: "Web",
+        });
+      } catch (error) {
+        createLog({
+          currentPage: "极验",
+          action: "register接口调用失败",
+          errorJSON: JSON.stringify(error, (key, value) =>
+            key === "token" ? "" : value
+          ),
+          errorName: error.name,
+          errorMessage: error.message,
+          errorStack: error.stack,
+        });
+        this.$Message.error({
+          content: "资源注册失败,请关闭程序,检查网络状况后重试。",
+          duration: 5 * 60,
+          closable: true,
+        });
+        return;
+      }
       // console.log(res);
       const data = res.data;
       if (!data.success) {

+ 5 - 0
src/features/Login/GlobalNotice.vue

@@ -75,6 +75,11 @@ export default {
       }
     },
     clickBtn() {
+      this.logger({
+        page: "GlobalNotice",
+        button: "确定按钮",
+        action: "点击",
+      });
       this.clicked = true;
     },
   },

+ 57 - 11
src/features/Login/Login.vue

@@ -279,6 +279,20 @@ export default {
       return isThisOrgUndefined ? allOrg : thisOrg;
     },
   },
+  watch: {
+    "loginForm.accountValue": function () {
+      if (Date.now() - this.resetGeeTime > 60 * 1000) {
+        this.captchaObj.destroy();
+        this.resetGeeTime = Date.now();
+      }
+    },
+    "loginForm.password": function () {
+      if (Date.now() - this.resetGeeTime > 60 * 1000) {
+        this.captchaObj.destroy();
+        this.resetGeeTime = Date.now();
+      }
+    },
+  },
   async mounted() {
     // this.testServiceWorker();
 
@@ -490,12 +504,23 @@ export default {
     async loginForuser() {
       // 供user点击的 login 方法。主要是保护 login 方法,不因为user重复点击,多个请求不按预期时间进行。
       createLog({ currentPage: "登录页面", action: "点击登录按钮" });
+
+      try {
+        const hasNewVersion = await this.checkNewVersion();
+        if (hasNewVersion) return;
+      } catch (error) {
+        console.log("检测新版本出错");
+      }
+
       if (this.loginBtnLoading) {
         return;
       }
       this.loginBtnLoading = true;
 
-      const limitResult = await tryLimit({ action: "login", limit: 500 });
+      const { limitResult, serverOk } = await tryLimit({
+        action: "login",
+        limit: 500,
+      });
       this.logger({
         action: "登录页面--login clicked",
         logId: "登录限流API call",
@@ -504,6 +529,7 @@ export default {
         createLog({
           currentPage: "登录页面--login clicked",
           action: "登录被限流",
+          serverOk,
         });
         this.$Modal.warning({
           title: "提示",
@@ -542,7 +568,7 @@ export default {
       // alert("haha");
       createLog({
         currentPage: "登录页面--login clicked",
-        action: "page created",
+        action: "in login()",
         UA: navigator.userAgent,
       });
       // alert("haha end");
@@ -552,13 +578,6 @@ export default {
         return;
       }
 
-      try {
-        const hasNewVersion = await this.checkNewVersion();
-        if (hasNewVersion) return;
-      } catch (error) {
-        console.log("检测新版本出错");
-      }
-
       let loginResponse;
       if (!this.isEPCC) {
         // https://www.cnblogs.com/weiqinl/p/6708993.html
@@ -582,6 +601,14 @@ export default {
             seccode: geeRes.geetest_seccode, // geeForm[2].value,
           };
         }
+        createLog({
+          currentPage: "登录页面",
+          action: "send params",
+          domain: this.schoolDomain,
+          rootOrgId: this.QECSConfig.ROOT_ORG_ID,
+          accountType: this.loginType,
+          accountValue: this.loginForm.accountValue,
+        });
         // 以下网络请求失败,直接报网络异常错误
         loginResponse = await this.$http.post(
           "/api/ecs_core/verifyCode/gt/login",
@@ -595,6 +622,13 @@ export default {
           }
         );
       } else {
+        try {
+          const hasNewVersion = await this.checkNewVersion();
+          if (hasNewVersion) return;
+        } catch (error) {
+          console.log("检测新版本出错");
+        }
+
         loginResponse = await this.epccLogin();
         if (!loginResponse) {
           return;
@@ -689,7 +723,10 @@ export default {
             "登录失败",
             "getStudentInfoBySession失败",
           ]);
-          this.logger({ action: "登录失败" });
+          this.logger({
+            action: "登录失败",
+            detail: "getStudentInfoBySession失败",
+          });
           this.$Message.error({
             content: "获取学生信息失败,请重试!",
             duration: 15,
@@ -702,7 +739,11 @@ export default {
         }
       } else {
         window._hmt.push(["_trackEvent", "登录页面", "登录失败", data.desc]);
-        createLog({ currentPage: "登录页面", action: "登录失败" });
+        createLog({
+          currentPage: "登录页面",
+          action: "登录失败",
+          desc: data.desc,
+        });
         this.errorInfo = data.desc;
         // this.captchaObj.reset();
         this.captchaObj.destroy();
@@ -1075,6 +1116,11 @@ export default {
       }
     },
     closeApp() {
+      this.logger({
+        page: "登录页",
+        button: "关闭按钮",
+        action: "点击",
+      });
       console.log("关闭应用");
       window.close();
     },

+ 35 - 8
src/features/OfflineExam/OfflineExamList.vue

@@ -27,11 +27,17 @@
                   :title="file.originalFileName"
                   ondragstart="return false;"
                   @click="
-                    () =>
+                    () => {
+                      logger({
+                        page: '离线考试页',
+                        button: '下载作答按钮',
+                        action: '点击',
+                      });
                       downloadOfflineFile(
                         file.offlineFileUrl,
                         file.originalFileName
-                      )
+                      );
+                    }
                   "
                 >
                   <i-icon type="ios-cloud-download"></i-icon>下载作答
@@ -55,7 +61,16 @@
                   href="#"
                   download
                   ondragstart="return false;"
-                  @click="() => tempDisableBtnAndDownloadPaper(course)"
+                  @click="
+                    () => {
+                      logger({
+                        page: '离线考试页面',
+                        button: '下载试卷按钮',
+                        action: '点击',
+                      });
+                      tempDisableBtnAndDownloadPaper(course);
+                    }
+                  "
                 >
                   下载试卷
                 </a>
@@ -145,6 +160,12 @@ export default {
   },
   methods: {
     async enterExam(course) {
+      this.logger({
+        page: "离线考试页面",
+        button: "抽取试卷按钮",
+        action: "点击",
+        examStudentId: course.examStudentId,
+      });
       // 若出错,直接报网络异常
       await this.$http.get(
         "/api/branch_ecs_oe_admin/offlineExam/startOfflineExam",
@@ -156,6 +177,11 @@ export default {
     },
     previewPaper(course) {
       window._hmt.push(["_trackEvent", "离线考试页面", "预览"]);
+      this.logger({
+        page: "离线考试页面",
+        button: "查看试卷按钮",
+        action: "点击",
+      });
       var user = {
         loginName: course.examStudentId,
         backUrl: window.document.location.href,
@@ -209,11 +235,7 @@ export default {
       window.location.href =
         "/api/branch_ecs_ques/paper/export/" +
         course.paperId +
-        "/PAPER/" +
-        this.user.rootOrgId +
-        "/" +
-        course.paperId +
-        "/offLine?$key=" +
+        "/PAPER/offLine?$key=" +
         this.user.key +
         "&$token=" +
         this.user.token;
@@ -222,6 +244,11 @@ export default {
       return this.downloadIds.has(course.examStudentId);
     },
     uploadHandler(course) {
+      this.logger({
+        page: "离线考试页面",
+        button: "上传答案按钮",
+        action: "点击",
+      });
       this.selectedCourse = course;
       // setTimeout(() => {
       //   console.log(this.$refs);

+ 1 - 1
src/features/OnlineExam/CheckComputer.vue

@@ -621,7 +621,7 @@
         v-if="current === 5"
         key="xxx"
         type="primary"
-        @click="() => this.$emit('on-close')"
+        @click="() => $emit('on-close')"
       >
         进入考试
       </Button>

+ 20 - 6
src/features/OnlineExam/Examing/ExamingHome.vue

@@ -4,8 +4,8 @@
       <RemainTime></RemainTime>
       <OverallProgress :exam-question-list="examQuestionList"></OverallProgress>
       <div>
-        {{ this.$store.state.user.displayName }} -&nbsp;
-        {{ this.$store.state.user.studentCodeList.join(",") }}
+        {{ $store.state.user.displayName }} -&nbsp;
+        {{ $store.state.user.studentCodeList.join(",") }}
       </div>
       <QuestionFilters :exam-question-list="examQuestionList"></QuestionFilters>
       <i-button class="qm-primary-button" @click="submitPaper">交卷</i-button>
@@ -82,7 +82,7 @@
       "
     >
       <h3 style="margin-top: 80px">请关闭远程桌面软件再进行考试!</h3>
-      <i-button @click="checkRemoteApp">确认已关闭远程桌面软件</i-button>
+      <i-button @click="checkRemoteAppClicked">确认已关闭远程桌面软件</i-button>
     </div>
   </div>
   <div v-else>
@@ -764,9 +764,12 @@ export default {
           console.log(error);
           this.logger({
             action: "提交答案失败",
-            detail: JSON.stringify(error, (key, value) =>
+            errorJSON: JSON.stringify(error, (key, value) =>
               key === "token" ? "" : value
             ),
+            errorName: error.name,
+            errorMessage: error.message,
+            errorStack: error.stack,
           });
           this.$Message.error({
             content: "提交答案失败",
@@ -920,9 +923,12 @@ export default {
         console.log(e);
         this.logger({
           action: "交卷失败",
-          detail: JSON.stringify(e, (key, value) =>
+          errorJSON: JSON.stringify(e, (key, value) =>
             key === "token" ? "" : value
           ),
+          errorName: e.name,
+          errorMessage: e.message,
+          errorStack: e.stack,
         });
       }
       this.submitLock = false;
@@ -938,9 +944,17 @@ export default {
     },
     reloadPage() {
       window._hmt.push(["_trackEvent", "答题页面", "页面加载失败", "reload"]);
-      this.logger({ page: "答题页面", action: "页面加载失败" });
+      this.logger({ page: "答题页面", button: "重试按钮", action: "点击" });
       window.location.reload();
     },
+    async checkRemoteAppClicked() {
+      this.logger({
+        page: "答题页面",
+        button: "确认已关闭远程桌面软件",
+        action: "点击",
+      });
+      this.checkRemoteApp();
+    },
     async checkRemoteApp() {
       if (typeof nodeRequire == "undefined") {
         return;

+ 9 - 1
src/features/OnlineExam/Examing/FaceId.vue

@@ -36,7 +36,7 @@
           type="button"
           class="qm-primary-button"
           :disabled="redoBtnDisabled"
-          @click="startFaceVerify"
+          @click="startFaceVerifyClicked"
         >
           重试
         </button>
@@ -478,6 +478,14 @@ export default {
         this.faceidLoadedButTimeouted();
       }, 60000); //60000
     },
+    async startFaceVerifyClicked() {
+      this.logger({
+        page: "活体检测弹出框",
+        button: "重试按钮",
+        action: "点击",
+      });
+      await this.startFaceVerify();
+    },
     async startFaceVerify() {
       this.redoBtnDisabled = true;
       this.redoBtnMsg = "正在进入指定动作检测...";

+ 6 - 1
src/features/OnlineExam/Examing/FaceTracking.vue

@@ -114,7 +114,12 @@ async function detectTest() {
     createLog({
       page: "实时人脸检测",
       action: "启动检测错误:停止实时",
-      error: JSON.stringify(error),
+      errorJSON: JSON.stringify(error, (key, value) =>
+        key === "token" ? "" : value
+      ),
+      errorName: error.name,
+      errorMessage: error.message,
+      errorStack: error.stack,
     });
     return;
   }

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

@@ -144,9 +144,12 @@ export default {
             action: "发出心跳",
             error: "心跳失败",
             detail: `考试剩余时间:${this.remainTime / 1000}`,
-            extraDetail: JSON.stringify(error, (key, value) =>
+            errorJSON: JSON.stringify(error, (key, value) =>
               key === "token" ? "" : value
             ),
+            errorName: error.name,
+            errorMessage: error.message,
+            errorStack: error.stack,
           });
         }
         this.heartbeatErrorNum++;

+ 5 - 0
src/features/OnlineExam/OnlineExamFaceCheckModal.vue

@@ -100,6 +100,11 @@ export default {
   methods: {
     ...mapMutations(["toggleFaceCheckModal"]),
     closeModal() {
+      this.logger({
+        page: "OnlineExamFaceCheckModal",
+        button: "关闭按钮",
+        action: "点击",
+      });
       this.closeCamera = true;
       this.toggleFaceCheckModal(false);
     },

+ 30 - 22
src/features/OnlineExam/OnlineExamHome.vue

@@ -18,19 +18,23 @@
     </div>
 
     <PhoneVerifyForDD />
+    <PrivacyDialog />
   </main-layout>
 </template>
 
 <script>
 import EcsOnlineList from "./OnlineExamList.vue";
 import PhoneVerifyForDD from "./PhoneVerifyForDD.vue";
-import { mapMutations, mapState, mapGetters } from "vuex";
+import PrivacyDialog from "./PrivacyDialog.vue";
+import { mapMutations, mapState } from "vuex";
+import { PRIVACY_READ_VERSION_NUMBER } from "@/constants/constants";
 
 export default {
   name: "OnlineExamHome",
   components: {
     "ecs-online-list": EcsOnlineList,
     PhoneVerifyForDD,
+    PrivacyDialog,
   },
   beforeRouteEnter(to, from, next) {
     next((vm) => {
@@ -52,13 +56,12 @@ export default {
   data() {
     return {
       previousUrl: "",
-      autoCloseModal: 10,
+      autoCloseModal: 100000, // 直到隐私框和电话框确认通过后
       courses: [],
     };
   },
   computed: {
     ...mapState(["user", "siteMessagesTimeStamp", "menus"]),
-    ...mapGetters(["isEpcc"]),
     locationTitle() {
       return (
         this.menus.find(
@@ -90,14 +93,6 @@ export default {
         this.previousUrl.startsWith("/login/") &&
         process.env.NODE_ENV !== "development"
       ) {
-        const os = window.navigator.userAgent.match(
-          /Intel Mac OS X (\d+)_(\d+)_(\d+)/
-        );
-
-        let macosVersionTooLow = false;
-        if (os && +(os[2] + "." + os[3]) < 14.6) {
-          macosVersionTooLow = true;
-        }
         this.$Modal.info({
           render: () => (
             <div class="welcome-modal">
@@ -110,17 +105,9 @@ export default {
                   姓名:{this.$store.state.user.name} -{" "}
                   {this.$store.state.user.studentCodeList.join(",")}
                 </div>
-                {!this.isEpcc && (
-                  <div style="font-weight:bold; line-height: 25px;">
-                    专业:{this.$store.state.user.specialty}
-                  </div>
-                )}
-                {macosVersionTooLow && (
-                  <div style="font-weight:bold; line-height: 25px; color: red">
-                    您的操作系统版本过低,为保证考试过程顺利完成,请更新操作系统到
-                    macOS Mojave (10.14.5) 以上!
-                  </div>
-                )}
+                <div style="font-weight:bold; line-height: 25px;">
+                  专业:{this.$store.state.user.specialty}
+                </div>
               </div>
             </div>
           ),
@@ -129,6 +116,26 @@ export default {
           },
         });
         this.interval = setInterval(() => {
+          const privacyReadVersionUserId = localStorage.getItem(
+            "privacy-read-version-" + this.user.id
+          );
+          const shouldPhoneCheck = [
+            "cugr.ecs.qmth.com.cn",
+            "test.cugr.qmth.com.cn",
+          ].includes(localStorage.getItem("domain"));
+          const passPhoneCheck = localStorage.getItem("phoneVerified");
+          // console.log(
+          //   privacyReadVersionUserId,
+          //   shouldPhoneCheck,
+          //   passPhoneCheck
+          // );
+          if (
+            privacyReadVersionUserId === PRIVACY_READ_VERSION_NUMBER &&
+            (shouldPhoneCheck ? passPhoneCheck : true) &&
+            this.autoCloseModal > 10
+          ) {
+            this.autoCloseModal = 10;
+          }
           this.autoCloseModal--;
           if (this.autoCloseModal <= 0) {
             this.$Modal.remove();
@@ -151,6 +158,7 @@ export default {
     await this.getData();
   },
   beforeDestroy() {
+    this.$Modal.remove();
     clearInterval(this.interval);
   },
   methods: {

+ 54 - 2
src/features/OnlineExam/OnlineExamList.vue

@@ -7,6 +7,7 @@
           <td v-if="!isEpcc" key="cc">层次</td>
           <td v-if="!isEpcc" key="zy">专业</td>
           <td>考试进入时间</td>
+          <td>考试时间周期</td>
           <td>剩余考试次数</td>
           <td style="max-width: 200px">操作</td>
         </tr>
@@ -20,6 +21,9 @@
             ~ <br />
             {{ course.endTime }}
           </td>
+          <td>
+            <span v-html="cycleDesc(course)" />
+          </td>
           <td>{{ course.allowExamCount }}</td>
           <td style="min-width: 180px">
             <div
@@ -145,6 +149,26 @@ export default {
   },
   methods: {
     ...mapMutations(["toggleFaceCheckModal"]),
+    cycleDesc(course) {
+      if (!course.examCycleEnabled) {
+        return "";
+      }
+      const weekdayNames = {
+        1: "一",
+        2: "二",
+        3: "三",
+        4: "四",
+        5: "五",
+        6: "六",
+        7: "日",
+      };
+      const weekDesc = course.examCycleWeek.map((v) => "周" + weekdayNames[v]);
+      const timeRangeDesc = course.examCycleTimeRange
+        .map((v) => v.timeRange)
+        .map((v) => v[0] + "~" + v[1])
+        .join("<br>");
+      return weekDesc + "<br>" + timeRangeDesc;
+    },
     getNow() {
       this.now = Date.now() + this.timeDifference;
     },
@@ -161,18 +185,42 @@ export default {
         return "无剩余考试次数";
       } else if (this.countdown > 0) {
         return "请稍后点击";
+      } else if (!this.courseInCycle(course)) {
+        return "不在考试时间周期内";
       } else {
         return "";
       }
     },
+    courseInCycle(course) {
+      if (!course.examCycleEnabled) {
+        return true;
+      }
+      const weekday = moment(this.now).isoWeekday();
+      if (!course.examCycleWeek.includes(weekday)) {
+        return false;
+      }
+      const HHmm = moment(this.now).format("HH:mm");
+      const ranges = course.examCycleTimeRange.map((v) => v.timeRange);
+      // console.log(HHmm, ranges);
+      // console.log(HHmm > "11:00" && HHmm < "23:00");
+      const inRange = ranges.some((v) => HHmm >= v[0] && HHmm <= v[1]);
+      // console.log(inRange);
+      return inRange;
+    },
     disableTheCourse(course) {
       return (
         !this.courseInBetween(course) ||
         course.allowExamCount < 1 ||
-        this.countdown > 0
+        this.countdown > 0 ||
+        !this.courseInCycle(course)
       );
     },
     async raceEnter(course) {
+      this.logger({
+        page: "待考列表页",
+        button: "进入考试按钮",
+        action: "点击",
+      });
       this.spinShow = true;
       this.processingMessage = "正在请求...";
       const minutesAfterCourseStart = Math.floor(
@@ -180,7 +228,10 @@ export default {
       );
 
       this.enterButtonClicked = true;
-      const limitResult = await tryLimit({ action: "startExam", limit: 100 });
+      const { limitResult, serverOk } = await tryLimit({
+        action: "startExam",
+        limit: 100,
+      });
       this.logger({
         action: "在线考试列表页面",
         logId: "开考限流API call",
@@ -208,6 +259,7 @@ export default {
           action: "在线考试列表页面",
           logId: "开考被限流",
           detail: "限流-" + minutesAfterCourseStart + "分被限流",
+          serverOk,
         });
         this.$Modal.warning({
           title: "提示",

+ 18 - 2
src/features/OnlineExam/OnlineExamOverview.vue

@@ -10,7 +10,7 @@
         class="qm-primary-button"
         :disabled="isForceRead"
         style="display: inline-block; width: 100%"
-        @click="goToPaper"
+        @click="gotoPaperClicked"
       >
         接受以上条款,开始考试(倒计时:
         <span class="animated infinite pulse"> {{ remainTimeFormatted }} </span
@@ -71,6 +71,10 @@ export default {
   },
   async mounted() {
     window._hmt.push(["_trackEvent", "在线考试概览页面", "进入页面"]);
+    this.logger({
+      page: "在线考试概览页面",
+      action: "开考成功",
+    });
     this.intervalId = setInterval(() => {
       this.remainTime -= 1;
       this.isForceRead = TOTAL_READ_TIME - this.remainTime < FORCE_READ_TIME;
@@ -111,6 +115,10 @@ export default {
         "获取考试概览信息异常",
         e.response ? e.response.data.desc : e,
       ]);
+      this.logger({
+        page: "在线考试概览页面",
+        action: "获取考试概览信息异常",
+      });
       this.$router.back();
       return;
     }
@@ -121,7 +129,15 @@ export default {
     clearInterval(this.intervalId);
   },
   methods: {
-    goToPaper: function () {
+    gotoPaperClicked() {
+      this.logger({
+        page: "在线考试概览页面",
+        button: "开始考试",
+        action: "点击",
+      });
+      this.goToPaper();
+    },
+    goToPaper() {
       this.$router.replace(
         `/online-exam/exam/${this.$route.params.examId}/examRecordData/${this.examRecordDataId}/order/1`
       );

+ 15 - 1
src/features/OnlineExam/PhoneVerifyForDD.vue

@@ -5,6 +5,7 @@
     :mask-closable="false"
     title="请验证预留手机号"
     :footer-hide="true"
+    :z-index="2000"
   >
     <div
       style="display: grid; grid-template-rows: 40px 40px 40px; font-size: 20px"
@@ -34,7 +35,6 @@
         <i-button
           style="margin: 0; margin-right: 5px"
           class="qm-primary-button"
-          :disabled="!code"
           @click="verify"
         >
           验证
@@ -81,6 +81,11 @@ export default {
   },
   methods: {
     async getCode() {
+      this.logger({
+        page: "PhoneVerifyForDD",
+        button: "发送验证码按钮",
+        action: "点击",
+      });
       try {
         await this.$http.post("/api/ecs_oe_student/sms/sendSmsCodeToStudent");
       } catch (error) {
@@ -101,6 +106,11 @@ export default {
       }, 1000);
     },
     async verify() {
+      this.logger({
+        page: "PhoneVerifyForDD",
+        button: "验证按钮",
+        action: "点击",
+      });
       try {
         await this.$http.post(
           `/api/ecs_oe_student/sms/checkSmsCode?phoneNumber=${this.user.phoneNumber}&code=${this.code}`
@@ -113,6 +123,10 @@ export default {
           duration: 15,
           closable: true,
         });
+        this.logger({
+          page: "PhoneVerifyForDD",
+          action: "验证手机号接口失败,请重试!",
+        });
       }
     },
   },

+ 219 - 0
src/features/OnlineExam/PrivacyDialog.vue

@@ -0,0 +1,219 @@
+<template>
+  <Modal
+    v-model="privacyModal"
+    :closable="false"
+    :mask-closable="false"
+    title="隐私条款"
+    :footer-hide="true"
+    :z-index="3000"
+  >
+    <div style="font-size: 16px">
+      <div class="privacy-content">
+        <p style="text-indent: 2em">
+          本产品尊重并保护所有服务用户的个人隐私权。为了给您提供更准确、更有个性化的服务,会按照本隐私权政策的规定使用和披露您的个人信息。但将以高度的勤勉、审慎义务对待这些信息。除本隐私权政策规定外,在未征得您事先许可的情况下,本产品不会将这些信息对外披露或向第三方提供(法律法规规定应当披露或提供的除外)。本产品会不时更新本隐私权政策。您在同意本产品服务使用协议之时,即视为您已经同意本隐私权政策全部内容。本隐私权政策属于本产品服务使用协议不可分割的一部分。
+        </p>
+
+        <p>1.适用范围</p>
+
+        <p>1) 在您注册使用本产品帐号时,根据本产品要求提供的个人注册信息;</p>
+
+        <p>
+          2)
+          在您使用本产品网络服务,或访问本产品网页时,本产品自动接收并记录的您的访问设备信息,包括但不限于您的、IP地址、浏览器的类型、使用的语言、访问日期和时间、软硬件特征信息及您访问的网页记录等数据;
+        </p>
+
+        <p>3) 本产品通过合法途径从商业伙伴处取得的用户个人数据;</p>
+
+        <p>4) 您在使用本产品平台进行考试身份验证时采集的人脸信息</p>
+
+        <p>5) 您在使用本产品平台提供的搜索服务时输入的关键字信息;</p>
+
+        <p>2.信息使用</p>
+
+        <p>
+          1)
+          本产品不会向任何无关第三方提供、出售、出租、分享或交易您的个人信息,除非事先得到您的许可,或该第三方和本产品(含本产品关联公司)单独或共同为您提供服务,且在该服务结束后,其将被禁止访问包括其以前能够访问的所有这些资料。
+        </p>
+
+        <p>
+          2)
+          本产品亦不允许任何第三方以任何手段收集、编辑、出售或者无偿传播您的个人信息。任何本产品平台用户如从事上述活动,一经发现,本产品有权立即终止与该用户的服务协议。
+        </p>
+
+        <p>
+          3)
+          为服务用户的目的,本产品可能通过使用您的个人信息,向您提供您感兴趣的信息,包括但不限于向您发出产品和服务信息,或者与本产品合作伙伴共享信息以便他们向您发送有关其产品和服务的信息(后者需要您的事先同意)。
+        </p>
+
+        <p>3.信息披露</p>
+
+        <p>
+          在如下情况下,本产品将依据您的个人意愿或法律的规定全部或部分的披露您的个人信息:
+        </p>
+
+        <p>1) 经您事先同意,向第三方披露;</p>
+
+        <p>2) 为提供您所要求的产品和服务,而必须和第三方分享您的个人信息;</p>
+
+        <p>
+          3)
+          根据法律的有关规定,或者行政或司法机构的要求,向第三方或者行政、司法机构披露;
+        </p>
+
+        <p>
+          4)
+          如您出现违反中国有关法律、法规或者本产品服务协议或相关规则的情况,需要向第三方披露;
+        </p>
+
+        <p>5) 您自行向社会公众公开的个人信息;</p>
+
+        <p>
+          6)
+          从合法公开披露的信息中收集个人信息的,如合法的新闻报道、政府信息公开等渠道。根据法律规定,共享、转让经去标识化处理的个人信息,且确保数据接收方无法复原并重新识别个人信息主体的,不属于个人信息的对外共享、转让及公开披露行为,对此类数据的保存及处理将无需另行向您通知并征得您的同意。
+        </p>
+
+        <p>4.信息存储和交换</p>
+
+        <p>
+          本产品在中华人民共和国境内收集的有关您的信息和资料将保存在本产品及(或)其关联公司位于中华人民共和国境内的服务器上;针对境外情况,您的个人信息可能会被转移到您使用产品或服务所在国家/地区的境外管辖区,或者受到来自这些管辖区的访问。
+        </p>
+
+        <p>5.Cookie的使用</p>
+
+        <p>
+          1)
+          在您未拒绝接受Cookie的情况下,本产品会在您的访问设备上设定或读取Cookie,以便您能登录或使用依赖于Cookie的本产品平台服务或功能。本产品使用Cookie可为您提供更加周到的个性化服务。
+        </p>
+
+        <p>
+          2)
+          您有权选择接受或拒绝接受Cookie。您可以通过修改浏览器设置的方式拒绝接受Cookie。但如果您选择拒绝接受Cookie,则您可能无法登录或使用依赖于Cookie的本产品网络服务或功能。
+        </p>
+
+        <p>3) 通过本产品所设Cookie所取得的有关信息,将适用本政策。</p>
+
+        <p>6.信息安全</p>
+
+        <p>
+          1)
+          本产品帐号均有安全保护功能,请妥善保管您的用户名及密码信息。本产品会采用符合业界标准的安全防护措施,包括建立合理的制度规范、安全技术来防止您的个人信息遭到未经授权的访问使用、修改,避免数据的损坏或丢失。
+        </p>
+
+        <p>
+          2)
+          我们会采取一切合理可行的措施,确保未收集无关的个人信息。我们只会在达成本政策所述目的所需的期限内保留您的个人信息,除非需要延长保留期或受到法律的允许。
+        </p>
+
+        <p>
+          3)
+          互联网并非绝对安全的环境,而且电子邮件、即时通讯、及与其他用户的交流方式并未加密,我们强烈建议您不要通过此类方式发送个人信息。请使用复杂密码,协助我们保证您的账号安全。
+        </p>
+
+        <p>
+          4)
+          如您发现自己的个人信息泄密,尤其是本应用用户名及密码发生泄露,请您立即联络本应用客服,以便本应用采取相应措施。
+        </p>
+
+        <p>7.本隐私政策的更改</p>
+
+        <p>
+          1)如果决定更改隐私政策,我们会在本政策中,以及我们认为适当的位置发布这些更改,以便您了解我们如何收集、使用您的个人信息,哪些人可以访问这些信息,以及在什么情况下我们会透露这些信息。
+        </p>
+
+        <p>
+          2)我们保留随时修改本政策的权利,因此请经常查看。如对本政策作出重大更改,我们会通过网站通知的形式告知。
+        </p>
+      </div>
+
+      <div style="display: flex; justify-content: center; margin-top: 10px">
+        <i-button
+          style="margin: 0; margin-right: 5px; width: 150px"
+          class="qm-primary-button"
+          :disabled="remainTime > 0"
+          @click="agreeTerms"
+        >
+          同意{{ remainTime ? `(${remainTime}秒)` : "" }}
+        </i-button>
+        <i-button
+          style="margin: 0; margin-right: 5px; width: 150px"
+          class="qm-primary-button"
+          @click="disagreeTerms"
+        >
+          拒绝
+        </i-button>
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<script>
+import { PRIVACY_READ_VERSION_NUMBER } from "@/constants/constants";
+import { mapState as globalMapState } from "vuex";
+
+export default {
+  name: "PrivacyDialog",
+  data() {
+    return {
+      privacyModal: false,
+      remainTime: 0,
+    };
+  },
+  computed: {
+    ...globalMapState(["user"]),
+  },
+  async mounted() {
+    const privacyReadVersionUserId = localStorage.getItem(
+      "privacy-read-version-" + this.user.id
+    );
+    if (privacyReadVersionUserId !== PRIVACY_READ_VERSION_NUMBER) {
+      this.privacyModal = true;
+
+      this.remainTime = 15;
+      let interval = setInterval(() => {
+        this.remainTime--;
+        if (this.remainTime <= 0) {
+          clearInterval(interval);
+        }
+      }, 1000);
+    }
+  },
+  methods: {
+    agreeTerms() {
+      this.logger({
+        page: "PrivacyDialog",
+        button: "同意按钮",
+        action: "点击",
+      });
+      localStorage.setItem(
+        "privacy-read-version-" + this.user.id,
+        PRIVACY_READ_VERSION_NUMBER
+      );
+      this.privacyModal = false;
+    },
+    disagreeTerms() {
+      this.logger({
+        page: "PrivacyDialog",
+        button: "拒绝按钮",
+        action: "点击",
+      });
+      this.logout("?LogoutReason=不同意隐私条款");
+    },
+  },
+};
+</script>
+
+<style scoped>
+.home {
+  margin: 20px;
+}
+.privacy-content {
+  font-size: 14px;
+  width: 100%;
+  height: 400px;
+  overflow-y: scroll;
+  text-align: left;
+}
+.privacy-content p {
+  margin-bottom: 5px;
+}
+</style>

+ 89 - 12
src/features/OnlinePractice/OnlinePracticeList.vue

@@ -5,6 +5,7 @@
         <tr class="list-header qm-primary-strong-text">
           <td>课程</td>
           <td>考试进入时间</td>
+          <td>考试时间周期</td>
           <td>练习次数</td>
           <td>最近正确率</td>
           <td>平均正确率</td>
@@ -19,6 +20,9 @@
             ~ <br />
             {{ course.endTime }}
           </td>
+          <td>
+            <span v-html="cycleDesc(course)" />
+          </td>
           <td>{{ course.practiceCount }}</td>
           <td>{{ course.recentObjectiveAccuracy }}%</td>
           <td>{{ course.aveObjectiveAccuracy }}%</td>
@@ -33,7 +37,8 @@
             >
               <i-button
                 class="qm-primary-button qm-primary-button-padding-fix"
-                :disabled="!courseInBetween(course)"
+                :disabled="disableTheCourse(course) || enterButtonClicked"
+                :title="disableTheCourse(course) ? disableReason(course) : ''"
                 @click="enterPractice(course)"
               >
                 进入练习
@@ -69,7 +74,7 @@ export default {
     },
   },
   data() {
-    return { now: new Date(), selectedCourse: null };
+    return { now: new Date(), selectedCourse: null, enterButtonClicked: false };
   },
   computed: {
     ...globalMapState(["user", "timeDifference"]),
@@ -83,6 +88,26 @@ export default {
   },
   methods: {
     ...mapMutations(["toggleFaceCheckModal"]),
+    cycleDesc(course) {
+      if (!course.examCycleEnabled) {
+        return "";
+      }
+      const weekdayNames = {
+        1: "一",
+        2: "二",
+        3: "三",
+        4: "四",
+        5: "五",
+        6: "六",
+        7: "日",
+      };
+      const weekDesc = course.examCycleWeek.map((v) => "周" + weekdayNames[v]);
+      const timeRangeDesc = course.examCycleTimeRange
+        .map((v) => v.timeRange)
+        .map((v) => v[0] + "~" + v[1])
+        .join("<br>");
+      return weekDesc + "<br>" + timeRangeDesc;
+    },
     getNow() {
       this.now = Date.now() + this.timeDifference;
     },
@@ -92,20 +117,72 @@ export default {
         moment(course.endTime)
       );
     },
-    async enterPractice(course) {
-      const alreadyInExam = await this.checkExamInProgress();
-      if (alreadyInExam) {
-        window._hmt.push(["_trackEvent", "在线练习页面", "断点续考", "进入"]);
-        this.logger({ action: "断点续考", detail: "在线练习页面" });
-        return;
+    disableReason(course) {
+      if (!this.courseInBetween(course)) {
+        return "当前时间不在练习开放时间范围";
+      } else if (course.allowExamCount < 1) {
+        return "无剩余练习次数";
+      } else if (this.countdown > 0) {
+        return "请稍后点击";
+      } else if (!this.courseInCycle(course)) {
+        return "不在练习时间周期内";
+      } else {
+        return "";
       }
-
-      window._hmt.push(["_trackEvent", "在线练习页面", "进入练习"]);
-      this.$router.push(
-        `/online-exam/exam/${course.examId}/overview?examStudentId=${course.examStudentId}`
+    },
+    courseInCycle(course) {
+      if (!course.examCycleEnabled) {
+        return true;
+      }
+      const weekday = moment(this.now).isoWeekday();
+      if (!course.examCycleWeek.includes(weekday)) {
+        return false;
+      }
+      const HHmm = moment(this.now).format("HH:mm");
+      const ranges = course.examCycleTimeRange.map((v) => v.timeRange);
+      // console.log(HHmm, ranges);
+      // console.log(HHmm > "11:00" && HHmm < "23:00");
+      const inRange = ranges.some((v) => HHmm >= v[0] && HHmm <= v[1]);
+      // console.log(inRange);
+      return inRange;
+    },
+    disableTheCourse(course) {
+      return (
+        !this.courseInBetween(course) ||
+        course.allowExamCount < 1 ||
+        this.countdown > 0 ||
+        !this.courseInCycle(course)
       );
     },
+    async enterPractice(course) {
+      this.enterButtonClicked = true;
+      try {
+        this.logger({
+          page: "在线练习页面",
+          button: "进入练习按钮",
+          action: "点击",
+        });
+        const alreadyInExam = await this.checkExamInProgress();
+        if (alreadyInExam) {
+          window._hmt.push(["_trackEvent", "在线练习页面", "断点续考", "进入"]);
+          this.logger({ action: "断点续考", detail: "在线练习页面" });
+          return;
+        }
+
+        window._hmt.push(["_trackEvent", "在线练习页面", "进入练习"]);
+        this.$router.push(
+          `/online-exam/exam/${course.examId}/overview?examStudentId=${course.examStudentId}`
+        );
+      } finally {
+        this.enterButtonClicked = false;
+      }
+    },
     async enterPracticeList(course) {
+      this.logger({
+        page: "在线练习页面",
+        button: "查看详情按钮",
+        action: "点击",
+      });
       this.$router.push(
         `/online-practice/exam/${course.examId}/list?examStudentId=${
           course.examStudentId

+ 5 - 0
src/features/Password/Password.vue

@@ -144,6 +144,11 @@ export default {
   },
   methods: {
     async changePwd() {
+      this.logger({
+        page: "修改密码页面",
+        button: "保存按钮",
+        action: "点击",
+      });
       const valid = await this.$refs["form"].validate();
       if (!valid) {
         return;

+ 5 - 0
src/features/SiteMessage/SiteMessageDetail.vue

@@ -115,6 +115,11 @@ export default {
       }
     },
     goBack() {
+      this.logger({
+        page: "站内消息详情页面",
+        button: "返回按钮",
+        action: "点击",
+      });
       this.$router.push("/site-message" + location.search);
     },
   },

+ 5 - 0
src/features/SiteMessage/SiteMessageHome.vue

@@ -135,6 +135,11 @@ export default {
       }
     },
     async markRead() {
+      this.logger({
+        page: "在线练习页面",
+        button: "标记为已读按钮",
+        action: "点击",
+      });
       // console.log(this.$refs.selection.getSelection());
       const selectIds = this.$refs.selection.getSelection().map((v) => v.id);
       if (selectIds.length === 0) {

+ 8 - 2
src/utils/axios.js

@@ -51,9 +51,12 @@ qmInstance.interceptors.request.use(
     });
     createLog({
       action: "axios request",
-      error: JSON.stringify(error, (key, value) =>
+      errorJSON: JSON.stringify(error, (key, value) =>
         key === "token" ? "" : value
       ),
+      errorName: error.name,
+      errorMessage: error.message,
+      errorStack: error.stack,
     });
     return Promise.resolve(error);
   }
@@ -68,9 +71,12 @@ qmInstance.interceptors.response.use(
     console.log("axios response error: " + JSON.stringify(error));
     createLog({
       action: "axios response",
-      error: JSON.stringify(error, (key, value) =>
+      errorJSON: JSON.stringify(error, (key, value) =>
         key === "token" ? "" : value
       ),
+      errorName: error.name,
+      errorMessage: error.message,
+      errorStack: error.stack,
     });
     if (!error.response) {
       // "Network Error" 网络不通,直接返回

+ 22 - 0
src/utils/deviceInfo.js

@@ -0,0 +1,22 @@
+export default function () {
+  if (typeof nodeRequire == "undefined") {
+    console.log("nodeRequire failed");
+    return {};
+  }
+
+  const os = window.nodeRequire("os");
+  let infos = {};
+  infos.arch = os.arch();
+  infos.platform = os.platform();
+  infos.release = os.release();
+  infos.type = os.type();
+  infos.hostname = os.hostname();
+  infos.totalmem = os.totalmem() / 1024 / 1024 / 1024;
+  infos.freemem = os.freemem() / 1024 / 1024 / 1024;
+
+  infos.cpus = JSON.stringify(os.cpus());
+  infos.networkInterfaces = JSON.stringify(os.networkInterfaces());
+  infos.userInfo = JSON.stringify(os.userInfo());
+  infos.loadavg = JSON.stringify(os.loadavg());
+  return infos;
+}

+ 12 - 4
src/utils/logger.js

@@ -1,6 +1,7 @@
 import "@/utils/loghub-tracking.js";
 import { VUE_APP_SLS_STORE_NAME } from "@/constants/constants";
 import moment from "moment";
+import getDeviceInfos from "./deviceInfo";
 
 const host = "cn-shenzhen.log.aliyuncs.com";
 const project = "examcloud";
@@ -37,6 +38,8 @@ export function createUserDetailLog(logs) {
     const user = store.state.user;
     logger.push("userName", user.displayName);
     logger.push("userId", user.id);
+    logger.push("userIdentityNumber", user.identityNumber);
+    logger.push("userStudentCodeList", user.studentCodeList.join(","));
     logger.push("rootOrgName", user.rootOrgName);
     logger.push("rootOrgId", user.rootOrgId);
     const uuidForEcs = localStorage.getItem("uuidForEcs");
@@ -46,7 +49,12 @@ export function createUserDetailLog(logs) {
     for (let [k, v] of Object.entries(logs)) {
       logger.push(k, v);
     }
-    logger.push("clientDate", moment().format("hh:mm:ss.SSS"));
+    const deviceInfos = getDeviceInfos();
+
+    for (let [k, v] of Object.entries(deviceInfos)) {
+      logger.push(k, v);
+    }
+    logger.push("clientDate", moment().format("YYYY-MM-DD HH:mm:ss.SSS"));
     logger.push("UA", navigator.userAgent);
     logger.logger();
   } catch (error) {
@@ -68,7 +76,7 @@ export function createLog(logs) {
     for (let [k, v] of Object.entries(logs)) {
       logger.push(k, v);
     }
-    logger.push("clientDate", moment().format("hh:mm:ss.SSS"));
+    logger.push("clientDate", moment().format("YYYY-MM-DD HH:mm:ss.SSS"));
     logger.logger();
   } catch (error) {
     console.log(error);
@@ -125,7 +133,7 @@ export function createEncryptLog() {
       return;
     }
 
-    logger.push("clientDate", moment().format("hh:mm:ss.SSS"));
+    logger.push("clientDate", moment().format("YYYY-MM-DD HH:mm:ss.SSS"));
     logger.logger();
   } catch (error) {
     console.log(error);
@@ -176,7 +184,7 @@ export function createEncryptLog() {
 //         return;
 //       }
 //     }
-//     singleLog.clientDate = moment().format("hh:mm:ss.SSS");
+//     singleLog.clientDate = moment().format("HH:mm:ss.SSS");
 //     postLog(__logs__);
 //   } catch (error) {
 //     console.log(error);

+ 30 - 94
src/utils/monitors.js

@@ -2,52 +2,21 @@ import Vue from "vue";
 import { createLog } from "@/utils/logger";
 
 Vue.config.errorHandler = (error) => {
-  // Vue.prototype.$httpNoAuth.post(
-  //   "/api/ecs_core/log/studentClient/" + "debug/007001",
-  //   error.stack.replace(/\n/g, '||||'),
-  //   {
-  //     headers: {
-  //       "Content-Type": "text/plain"
-  //     }
-  //   }
-  // );
   window._hmt.push(["_trackEvent", "Vue组件错误"]);
   createLog({
     action: "Vue组件错误",
     path: window.location,
-    message: error.message,
-    stack: error.stack,
-    error: JSON.stringify(error, (key, value) =>
+    errorJSON: JSON.stringify(error, (key, value) =>
       key === "token" ? "" : value
     ),
+    errorName: error.name,
+    errorMessage: error.message,
+    errorStack: error.stack,
   });
   throw error;
 };
 
-// window.onerror = function(message, source, lineno, colno, error) {
-//   console.log(message);
-//   window._hmt.push([
-//     "_trackEvent",
-//     "全局JS错误",
-//     message,
-//     `source: ${source}\nlineno: ${lineno}\ncolno: ${colno}\nerror: ${
-//       error.stack
-//     }`
-//   ]);
-// };
-
 window.addEventListener("error", function (event) {
-  // Vue.prototype.$httpNoAuth.post(
-  //   "/api/ecs_core/log/studentClient/" + "debug/007002",
-  //  `message: ${event.message}\nsource: ${event.filename}\nlineno: ${event.lineno}\ncolno: ${
-  //   event.colno
-  // }\nerror: ${event.error.stack}`.replace(/\n/g, '||||'),
-  //   {
-  //     headers: {
-  //       "Content-Type": "text/plain"
-  //     }
-  //   }
-  // );
   window._hmt.push([
     "_trackEvent",
     "全局JS错误:" +
@@ -59,63 +28,47 @@ window.addEventListener("error", function (event) {
     action: "全局JS错误",
     page: window.location,
     message: event.message,
-    stack: event.error.stack,
-    error: JSON.stringify(event.error, (key, value) =>
+    errorJSON: JSON.stringify(event.error, (key, value) =>
       key === "token" ? "" : value
     ),
+    errorName: event.error.name,
+    errorMessage: event.error.message,
+    errorStack: event.error.stack,
+    errorFileName: event.error.filename,
+    errorLineNo: event.error.lineno,
+    errorColNo: event.error.colno,
   });
 });
 
 window.addEventListener("unhandledrejection", function (event) {
-  // let detail = "";
-  // if (event.reason.config) {
-  //   detail += "url:" + event.reason.config.url + "\n";
-  //   detail += "data: " + event.reason.config.data + "\n";
-  //   if (event.reason.request && event.reason.request.response) {
-  //     detail += "response: " + event.reason.request.response + "\n";
-  //   }
-  // }
-  // Vue.prototype.$httpNoAuth.post(
-  //   "/api/ecs_core/log/studentClient/" + "debug/007003",
-  //  `reason: ${event.reason}\ndetail: ${detail}`.replace(/\n/g, '||||'),
-  //   {
-  //     headers: {
-  //       "Content-Type": "text/plain"
-  //     }
-  //   }
-  // );
   // 此错误由上传阿里云日志触发,会被重复好几次
   // 改为fetch,阿里云日志的错误不应该触发到这里
-  // try {
-  //   if (
-  //     event.reason &&
-  //     ((event.reason && event.reason.includes("OperationError")) ||
-  //       (event.reason.message &&
-  //         event.reason.message.includes("OperationError")))
-  //   ) {
-  //     return;
-  //   }
-  // } catch (error) {
-  //   return;
-  // }
+
+  // 这会触发Circular JSON error
+  if (event.reason.name === "NavigationDuplicated") {
+    createLog({
+      action: "unhandledrejection错误",
+      page: window.location.pathname,
+      reason: "NavigationDuplicated",
+    });
+    return;
+  }
   console.log(
     "unhandledrejection event",
     event,
     event.reason,
-    event.reason && event.reason.message
+    JSON.stringify(event.reason)
   );
   // 会造成死循环,logger.log 在网络异常的情况下,会有unhandledrejection
-  // createLog({
-  //   action: "unhandledrejection错误",
-  //   page: window.location.pathname,
-  //   reason: event.reason,
-  //   message: event.reason ? event.reason.message : "",
-  // });
+  createLog({
+    action: "unhandledrejection错误",
+    page: window.location.pathname,
+    reason: event.reason,
+    reasonJson: JSON.stringify(event.reason),
+  });
   if (
-    event.reason &&
-    event.reason.message &&
-    (event.reason.message.includes("Box.constructor") ||
-      event.reason.message.includes("Error: toNetInput"))
+    event.reason?.message?.includes("Box.constructor") ||
+    event.reason?.message?.includes("Error: toNetInput")
   ) {
     window._hmt.push([
       "_trackEvent",
@@ -134,22 +87,5 @@ window.addEventListener("unhandledrejection", function (event) {
 
 window.addEventListener("rejectionhandled", function (event) {
   console.log("rejectionhandled"); // 似乎并不触发
-  // let detail = "";
-  // if (event.reason.config) {
-  //   detail += "url:" + event.reason.config.url + "\n";
-  //   detail += "data: " + event.reason.config.data + "\n";
-  //   if (event.reason.request && event.reason.request.response) {
-  //     detail += "response: " + event.reason.request.response + "\n";
-  //   }
-  // }
-  // Vue.prototype.$httpNoAuth.post(
-  //   "/api/ecs_core/log/studentClient/" + "debug/007004",
-  //  `reason: ${event.reason}\ndetail: ${detail}`.replace(/\n/g, '||||'),
-  //   {
-  //     headers: {
-  //       "Content-Type": "text/plain"
-  //     }
-  //   }
-  // );
   window._hmt.push(["_trackEvent", "全局Promise已处理错误", event.reason]);
 });

+ 1 - 0
src/utils/nativeExe.js

@@ -32,6 +32,7 @@ export default function checkRemote(exeName, cb) {
         data,
         stderr: JSON.stringify(stderr),
         stderrConverted: JSON.stringify(stderrConverted),
+        absPath: exeName.includes(":"),
       });
       // 如果相对路径没找到,则通过绝对路径来执行
       if (!exeName.includes(":") && err) {

+ 11 - 4
src/utils/tryLimit.js

@@ -7,12 +7,18 @@ export async function tryLimit({ action, limit = 100 }) {
   try {
     res = await axios.get(
       `https://tcc.qmth.com.cn/rate_limit/prod/${action}/${limit}`,
-      { timeout: 5 * 1000 }
+      { timeout: 10 * 1000 }
     );
     serverPass = res.data.pass;
 
+    createLog({
+      action: "第一次限流调用",
+      limitAction: action,
+      detail: serverPass ? "进入" : "限流",
+    });
     if (!serverPass) {
       // 休眠 1 ~ 5 秒 再试
+      res = null; // 供外部判断
 
       const sleepTime = Math.random() * 4 + 1;
       console.log({ sleepTime, now: Date.now() });
@@ -20,18 +26,19 @@ export async function tryLimit({ action, limit = 100 }) {
       console.log({ sleepTime, now: Date.now() });
       res = await axios.get(
         `https://tcc.qmth.com.cn/rate_limit/prod/${action}/${limit}`,
-        { timeout: 5 * 1000 }
+        { timeout: 10 * 1000 }
       );
       serverPass = res.data.pass;
       createLog({
         action: "限流后自动重试",
+        limitAction: action,
         detail: serverPass ? "进入" : "依然限流",
       });
     }
   } catch (error) {
     console.log(error);
-    window._hmt.push(["_trackEvent", "在线考试列表页面", "获取rateLimit失败"]);
+    window._hmt.push(["_trackEvent", action, "获取rateLimit失败"]);
   }
 
-  return serverPass;
+  return { limitResult: serverPass, serverOk: !!res };
 }

+ 5 - 0
src/views/NotFoundComponent.vue

@@ -17,6 +17,11 @@ export default {
   name: "Page404",
   methods: {
     goLogin() {
+      this.logger({
+        page: "Page404",
+        button: "返回登录页按钮",
+        action: "点击",
+      });
       this.$router.push("/login/");
     },
   },