Forráskód Böngészése

完成考试页面的基本流程

Michael Wang 3 éve
szülő
commit
74a93f7f42

+ 1 - 0
.eslintrc.js

@@ -70,6 +70,7 @@ module.exports = {
         $$: true,
         $message: true,
         $dialog: true,
+        _hmt: true,
       },
     },
   ],

+ 6 - 5
package.json

@@ -16,16 +16,17 @@
   "dependencies": {
     "@chenfengyuan/vue-qrcode": "^2.0.0",
     "@vicons/ionicons5": "^0.12.0",
-    "@vitejs/plugin-legacy": "^1.7.1",
+    "@vitejs/plugin-legacy": "^1.8.0",
     "axios": "^0.26.1",
     "axios-progress-bar": "^1.2.0",
     "axios-retry": "^3.2.4",
+    "face-api.js": "^0.22.2",
     "js-md5": "^0.7.3",
     "js-sls-logger": "^2.0.1",
     "lodash-es": "^4.17.21",
     "moment": "^2.29.1",
     "naive-ui": "^2.27.0",
-    "pinia": "^2.0.12",
+    "pinia": "^2.0.13",
     "qrcode": "^1.5.0",
     "tailwindcss": "^3.0.23",
     "ua-parser-js": "^1.0.2",
@@ -40,7 +41,7 @@
     "@types/ua-parser-js": "^0.7.36",
     "@typescript-eslint/eslint-plugin": "^5.17.0",
     "@typescript-eslint/parser": "^5.17.0",
-    "@vitejs/plugin-vue": "^2.2.4",
+    "@vitejs/plugin-vue": "^2.3.1",
     "autoprefixer": "^10.4.4",
     "eslint": "^8.12.0",
     "eslint-config-prettier": "^8.5.0",
@@ -48,9 +49,9 @@
     "postcss": "^8.4.12",
     "prettier": "^2.6.1",
     "typescript": "^4.6.3",
-    "unplugin-auto-import": "^0.6.8",
+    "unplugin-auto-import": "^0.6.9",
     "unplugin-vue-components": "^0.18.5",
-    "vite": "^2.8.6",
+    "vite": "^2.9.1",
     "vue-eslint-parser": "^8.3.0",
     "vue-tsc": "^0.33.9"
   }

+ 92 - 48
pnpm-lock.yaml

@@ -9,8 +9,8 @@ specifiers:
   '@typescript-eslint/eslint-plugin': ^5.17.0
   '@typescript-eslint/parser': ^5.17.0
   '@vicons/ionicons5': ^0.12.0
-  '@vitejs/plugin-legacy': ^1.7.1
-  '@vitejs/plugin-vue': ^2.2.4
+  '@vitejs/plugin-legacy': ^1.8.0
+  '@vitejs/plugin-vue': ^2.3.1
   autoprefixer: ^10.4.4
   axios: ^0.26.1
   axios-progress-bar: ^1.2.0
@@ -18,22 +18,23 @@ specifiers:
   eslint: ^8.12.0
   eslint-config-prettier: ^8.5.0
   eslint-plugin-vue: ^8.5.0
+  face-api.js: ^0.22.2
   js-md5: ^0.7.3
   js-sls-logger: ^2.0.1
   lodash-es: ^4.17.21
   moment: ^2.29.1
   naive-ui: ^2.27.0
-  pinia: ^2.0.12
+  pinia: ^2.0.13
   postcss: ^8.4.12
   prettier: ^2.6.1
   qrcode: ^1.5.0
   tailwindcss: ^3.0.23
   typescript: ^4.6.3
   ua-parser-js: ^1.0.2
-  unplugin-auto-import: ^0.6.8
+  unplugin-auto-import: ^0.6.9
   unplugin-vue-components: ^0.18.5
   vfonts: ^0.0.3
-  vite: ^2.8.6
+  vite: ^2.9.1
   vue: ^3.2.31
   vue-eslint-parser: ^8.3.0
   vue-router: ^4.0.14
@@ -42,16 +43,17 @@ specifiers:
 dependencies:
   '@chenfengyuan/vue-qrcode': 2.0.0_qrcode@1.5.0+vue@3.2.31
   '@vicons/ionicons5': 0.12.0
-  '@vitejs/plugin-legacy': 1.7.1_vite@2.8.6
+  '@vitejs/plugin-legacy': 1.8.0_vite@2.9.1
   axios: 0.26.1
   axios-progress-bar: 1.2.0_axios@0.26.1
   axios-retry: 3.2.4
+  face-api.js: 0.22.2
   js-md5: 0.7.3
   js-sls-logger: 2.0.1
   lodash-es: 4.17.21
   moment: 2.29.1
   naive-ui: 2.27.0_vue@3.2.31
-  pinia: 2.0.12_typescript@4.6.3+vue@3.2.31
+  pinia: 2.0.13_typescript@4.6.3+vue@3.2.31
   qrcode: 1.5.0
   tailwindcss: 3.0.23_autoprefixer@10.4.4
   ua-parser-js: 1.0.2
@@ -66,7 +68,7 @@ devDependencies:
   '@types/ua-parser-js': 0.7.36
   '@typescript-eslint/eslint-plugin': 5.17.0_689ff565753ecf7c3328c07fad067df5
   '@typescript-eslint/parser': 5.17.0_eslint@8.12.0+typescript@4.6.3
-  '@vitejs/plugin-vue': 2.2.4_vite@2.8.6+vue@3.2.31
+  '@vitejs/plugin-vue': 2.3.1_vite@2.9.1+vue@3.2.31
   autoprefixer: 10.4.4_postcss@8.4.12
   eslint: 8.12.0
   eslint-config-prettier: 8.5.0_eslint@8.12.0
@@ -74,9 +76,9 @@ devDependencies:
   postcss: 8.4.12
   prettier: 2.6.1
   typescript: 4.6.3
-  unplugin-auto-import: 0.6.8_vite@2.8.6
-  unplugin-vue-components: 0.18.5_vite@2.8.6+vue@3.2.31
-  vite: 2.8.6
+  unplugin-auto-import: 0.6.9_vite@2.9.1
+  unplugin-vue-components: 0.18.5_vite@2.9.1+vue@3.2.31
+  vite: 2.9.1
   vue-eslint-parser: 8.3.0_eslint@8.12.0
   vue-tsc: 0.33.9_typescript@4.6.3
 
@@ -212,6 +214,18 @@ packages:
       picomatch: 2.3.1
     dev: true
 
+  /@tensorflow/tfjs-core/1.7.0:
+    resolution: {integrity: sha512-uwQdiklNjqBnHPeseOdG0sGxrI3+d6lybaKu2+ou3ajVeKdPEwpWbgqA6iHjq1iylnOGkgkbbnQ6r2lwkiIIHw==}
+    engines: {yarn: '>= 1.3.2'}
+    dependencies:
+      '@types/offscreencanvas': 2019.3.0
+      '@types/seedrandom': 2.4.27
+      '@types/webgl-ext': 0.0.30
+      '@types/webgl2': 0.0.4
+      node-fetch: 2.1.2
+      seedrandom: 2.4.3
+    dev: false
+
   /@types/jest/27.4.1:
     resolution: {integrity: sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw==}
     dependencies:
@@ -238,14 +252,30 @@ packages:
   /@types/node/17.0.23:
     resolution: {integrity: sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==}
 
+  /@types/offscreencanvas/2019.3.0:
+    resolution: {integrity: sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==}
+    dev: false
+
   /@types/parse-json/4.0.0:
     resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
     dev: false
 
+  /@types/seedrandom/2.4.27:
+    resolution: {integrity: sha512-YvMLqFak/7rt//lPBtEHv3M4sRNA+HGxrhFZ+DQs9K2IkYJbNwVIb8avtJfhDiuaUBX/AW0jnjv48FV8h3u9bQ==}
+    dev: false
+
   /@types/ua-parser-js/0.7.36:
     resolution: {integrity: sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==}
     dev: true
 
+  /@types/webgl-ext/0.0.30:
+    resolution: {integrity: sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg==}
+    dev: false
+
+  /@types/webgl2/0.0.4:
+    resolution: {integrity: sha512-PACt1xdErJbMUOUweSrbVM7gSIYm1vTncW2hF6Os/EeWi6TXYAYMPp+8v6rzHmypE5gHrxaxZNXgMkJVIdZpHw==}
+    dev: false
+
   /@typescript-eslint/eslint-plugin/5.17.0_689ff565753ecf7c3328c07fad067df5:
     resolution: {integrity: sha512-qVstvQilEd89HJk3qcbKt/zZrfBZ+9h2ynpAGlWjWiizA7m/MtLT9RoX6gjtpE500vfIg8jogAkDzdCxbsFASQ==}
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -376,28 +406,28 @@ packages:
     resolution: {integrity: sha512-Iy1EUVRpX0WWxeu1VIReR1zsZLMc4fqpt223czR+Rpnrwu7pt46nbnC2ycO7ItI/uqDLJxnbcMC7FujKs9IfFA==}
     dev: false
 
-  /@vitejs/plugin-legacy/1.7.1_vite@2.8.6:
-    resolution: {integrity: sha512-RqgILXsGpfV7NHodVCdBVau8ss5+ynMXp6JGF/F7nhSy0bnwSQPlMS3KFqh7twfifXK8VuMriqfU4CxOiqmNnA==}
+  /@vitejs/plugin-legacy/1.8.0_vite@2.9.1:
+    resolution: {integrity: sha512-S3+uL1zp8GLUbmJAQk2wQbZLTyISKRFSMBwCFI3XQVRD3OZshqkiPyOKdRiSPlP9HoGz+q90kk+1qPm1tJRqCg==}
     engines: {node: '>=12.0.0'}
     peerDependencies:
       vite: ^2.8.0
     dependencies:
       '@babel/standalone': 7.17.8
       core-js: 3.21.1
-      magic-string: 0.25.9
+      magic-string: 0.26.1
       regenerator-runtime: 0.13.9
       systemjs: 6.12.1
-      vite: 2.8.6
+      vite: 2.9.1
     dev: false
 
-  /@vitejs/plugin-vue/2.2.4_vite@2.8.6+vue@3.2.31:
-    resolution: {integrity: sha512-ev9AOlp0ljCaDkFZF3JwC/pD2N4Hh+r5srl5JHM6BKg5+99jiiK0rE/XaRs3pVm1wzyKkjUy/StBSoXX5fFzcw==}
+  /@vitejs/plugin-vue/2.3.1_vite@2.9.1+vue@3.2.31:
+    resolution: {integrity: sha512-YNzBt8+jt6bSwpt7LP890U1UcTOIZZxfpE5WOJ638PNxSEKOqAi0+FSKS0nVeukfdZ0Ai/H7AFd6k3hayfGZqQ==}
     engines: {node: '>=12.0.0'}
     peerDependencies:
       vite: ^2.5.10
       vue: ^3.2.25
     dependencies:
-      vite: 2.8.6
+      vite: 2.9.1
       vue: 3.2.31
     dev: true
 
@@ -635,7 +665,7 @@ packages:
       postcss: ^8.1.0
     dependencies:
       browserslist: 4.20.2
-      caniuse-lite: 1.0.30001322
+      caniuse-lite: 1.0.30001323
       fraction.js: 4.2.0
       normalize-range: 0.1.2
       picocolors: 1.0.0
@@ -698,8 +728,8 @@ packages:
     engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
     hasBin: true
     dependencies:
-      caniuse-lite: 1.0.30001322
-      electron-to-chromium: 1.4.100
+      caniuse-lite: 1.0.30001323
+      electron-to-chromium: 1.4.103
       escalade: 3.1.1
       node-releases: 2.0.2
       picocolors: 1.0.0
@@ -726,8 +756,8 @@ packages:
     engines: {node: '>=6'}
     dev: false
 
-  /caniuse-lite/1.0.30001322:
-    resolution: {integrity: sha512-neRmrmIrCGuMnxGSoh+x7zYtQFFgnSY2jaomjU56sCkTA6JINqQrxutF459JpWcWRajvoyn95sOXq4Pqrnyjew==}
+  /caniuse-lite/1.0.30001323:
+    resolution: {integrity: sha512-e4BF2RlCVELKx8+RmklSEIVub1TWrmdhvA5kEUueummz1XyySW0DVk+3x9HyhU9MuWTa2BhqLgEuEmUwASAdCA==}
     dev: true
 
   /chalk/2.4.2:
@@ -844,8 +874,8 @@ packages:
     resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==}
     dev: false
 
-  /date-fns-tz/1.3.1_date-fns@2.28.0:
-    resolution: {integrity: sha512-Uy+wph6HcQ0IG8TWbVyXicgDmB1zdvb0CoIknZQaxiTun4uSfxLR+8gSTC2C3KCLq+0fEIuEtJ/ORDRIn6doQw==}
+  /date-fns-tz/1.3.2_date-fns@2.28.0:
+    resolution: {integrity: sha512-xSU97ayDLQpi8Db0bR3gmVdn32p5PP8Y0mr20gQd0n/F54hdGRfdnwrr0hJ7j3nTs/Nji1Qr+kf1A2EtisPC9w==}
     peerDependencies:
       date-fns: '>=2.0.0'
     dependencies:
@@ -923,8 +953,8 @@ packages:
       esutils: 2.0.3
     dev: true
 
-  /electron-to-chromium/1.4.100:
-    resolution: {integrity: sha512-pNrSE2naf8fizl6/Uxq8UbKb8hU9EiYW4OzCYswosXoLV5NTMOUVKECNzDaHiUubsPq/kAckOzZd7zd8S8CHVw==}
+  /electron-to-chromium/1.4.103:
+    resolution: {integrity: sha512-c/uKWR1Z/W30Wy/sx3dkZoj4BijbXX85QKWu9jJfjho3LBAXNEGAEW3oWiGb+dotA6C6BzCTxL2/aLes7jlUeg==}
     dev: true
 
   /emoji-regex/8.0.0:
@@ -1313,6 +1343,13 @@ packages:
     resolution: {integrity: sha512-tmiT1YUVqFjTY+BSBOAskL83xNx41iUfpvKP6Gcd/xMHjg3mnER98jXGXJyKnxCG19uPc6EhZiUC+MUyvoqCtw==}
     dev: false
 
+  /face-api.js/0.22.2:
+    resolution: {integrity: sha512-9Bbv/yaBRTKCXjiDqzryeKhYxmgSjJ7ukvOvEBy6krA0Ah/vNBlsf7iBNfJljWiPA8Tys1/MnB3lyP2Hfmsuyw==}
+    dependencies:
+      '@tensorflow/tfjs-core': 1.7.0
+      tslib: 1.14.1
+    dev: false
+
   /fast-deep-equal/3.1.3:
     resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
     dev: true
@@ -1691,7 +1728,6 @@ packages:
     engines: {node: '>=12'}
     dependencies:
       sourcemap-codec: 1.4.8
-    dev: true
 
   /merge2/1.4.1:
     resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
@@ -1741,7 +1777,7 @@ packages:
       async-validator: 4.0.7
       css-render: 0.15.9
       date-fns: 2.28.0
-      date-fns-tz: 1.3.1_date-fns@2.28.0
+      date-fns-tz: 1.3.2_date-fns@2.28.0
       evtd: 0.2.3
       highlight.js: 11.5.0
       lodash: 4.17.21
@@ -1764,6 +1800,11 @@ packages:
     resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
     dev: true
 
+  /node-fetch/2.1.2:
+    resolution: {integrity: sha512-IHLHYskTc2arMYsHZH82PVX8CSKT5lzb7AXeyO06QnjGDKtkv+pv3mEki6S7reB/x1QPo+YPxQRNEVgR5V/w3Q==}
+    engines: {node: 4.x || >=6.0.0}
+    dev: false
+
   /node-releases/2.0.2:
     resolution: {integrity: sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==}
     dev: true
@@ -1869,8 +1910,8 @@ packages:
     resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
     engines: {node: '>=8.6'}
 
-  /pinia/2.0.12_typescript@4.6.3+vue@3.2.31:
-    resolution: {integrity: sha512-tUeuYGFrLU5irmGyRAIxp35q1OTcZ8sKpGT4XkPeVcG35W4R6cfXDbCGexzmVqH5lTQJJTXXbNGutIu9yS5yew==}
+  /pinia/2.0.13_typescript@4.6.3+vue@3.2.31:
+    resolution: {integrity: sha512-B7rSqm1xNpwcPMnqns8/gVBfbbi7lWTByzS6aPZ4JOXSJD4Y531rZHDCoYWBwLyHY/8hWnXljgiXp6rRyrofcw==}
     peerDependencies:
       '@vue/composition-api': ^1.4.0
       typescript: '>=4.4.4'
@@ -1884,7 +1925,7 @@ packages:
       '@vue/devtools-api': 6.1.4
       typescript: 4.6.3
       vue: 3.2.31
-      vue-demi: 0.12.4_vue@3.2.31
+      vue-demi: 0.12.5_vue@3.2.31
     dev: false
 
   /pngjs/5.0.0:
@@ -1926,11 +1967,11 @@ packages:
       postcss: ^8.2.14
     dependencies:
       postcss: 8.4.12
-      postcss-selector-parser: 6.0.9
+      postcss-selector-parser: 6.0.10
     dev: false
 
-  /postcss-selector-parser/6.0.9:
-    resolution: {integrity: sha512-UO3SgnZOVTwu4kyLR22UQ1xZh086RyNZppb7lLAKBFK8a32ttG5i87Y/P3+2bRSjZNyJ1B7hfFNo273tKe9YxQ==}
+  /postcss-selector-parser/6.0.10:
+    resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
     engines: {node: '>=4'}
     dependencies:
       cssesc: 3.0.0
@@ -2079,6 +2120,10 @@ packages:
     dependencies:
       queue-microtask: 1.2.3
 
+  /seedrandom/2.4.3:
+    resolution: {integrity: sha512-2CkZ9Wn2dS4mMUWQaXLsOAfGD+irMlLEeSP3cMxpGbgyOOzJGFa+MWCOMTOCMyZinHRPxyOj/S/C57li/1to6Q==}
+    dev: false
+
   /seemly/0.3.3:
     resolution: {integrity: sha512-mAyqemz41e9HiZPMXAn7NtTExJgztwco5cdZjrt/iViU/oFeav+Q8K1c93M/tIZZ00QkT65JMr4xXQk7Vv5hWQ==}
     dependencies:
@@ -2191,7 +2236,7 @@ packages:
       postcss-js: 4.0.0_postcss@8.4.12
       postcss-load-config: 3.1.4_postcss@8.4.12
       postcss-nested: 5.0.6_postcss@8.4.12
-      postcss-selector-parser: 6.0.9
+      postcss-selector-parser: 6.0.10
       postcss-value-parser: 4.2.0
       quick-lru: 5.1.1
       resolve: 1.22.0
@@ -2219,7 +2264,6 @@ packages:
 
   /tslib/1.14.1:
     resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
-    dev: true
 
   /tsutils/3.21.0_typescript@4.6.3:
     resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
@@ -2253,8 +2297,8 @@ packages:
     resolution: {integrity: sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg==}
     dev: false
 
-  /unplugin-auto-import/0.6.8_vite@2.8.6:
-    resolution: {integrity: sha512-Nko4z1oSXpV/HNLexxKt+QP+BD4uNS9v2ronHnCWpm4uCMUCj2MXaK9XtFuCvCciIbkLd7wVNXZjpR5eeJs9vA==}
+  /unplugin-auto-import/0.6.9_vite@2.9.1:
+    resolution: {integrity: sha512-IqgT7AoRrNQwNhiF/wDH3sMEDX8SqCYBEgJzwdg5441b5aiC5VwZz0J0wYqkaKu89YkZE9DG6rQ2JpFfZv1iiQ==}
     engines: {node: '>=14'}
     peerDependencies:
       '@vueuse/core': '*'
@@ -2267,7 +2311,7 @@ packages:
       local-pkg: 0.4.1
       magic-string: 0.26.1
       resolve: 1.22.0
-      unplugin: 0.4.0_vite@2.8.6
+      unplugin: 0.4.0_vite@2.9.1
     transitivePeerDependencies:
       - esbuild
       - rollup
@@ -2275,7 +2319,7 @@ packages:
       - webpack
     dev: true
 
-  /unplugin-vue-components/0.18.5_vite@2.8.6+vue@3.2.31:
+  /unplugin-vue-components/0.18.5_vite@2.9.1+vue@3.2.31:
     resolution: {integrity: sha512-VPA6z/4pcKRDYtWu1H+FIpV0MADlFKG3q7YMVFzNFC3EhMVZ4WuBJ76490oKyauguNw1T1obLCuxNU9JzJ0oAQ==}
     engines: {node: '>=14'}
     peerDependencies:
@@ -2297,7 +2341,7 @@ packages:
       magic-string: 0.26.1
       minimatch: 5.0.1
       resolve: 1.22.0
-      unplugin: 0.4.0_vite@2.8.6
+      unplugin: 0.4.0_vite@2.9.1
       vue: 3.2.31
     transitivePeerDependencies:
       - esbuild
@@ -2307,7 +2351,7 @@ packages:
       - webpack
     dev: true
 
-  /unplugin/0.4.0_vite@2.8.6:
+  /unplugin/0.4.0_vite@2.9.1:
     resolution: {integrity: sha512-4ScITEmzlz1iZW3tkz+3L1V5k/xMQ6kjgm4lEXKxH0ozd8/OUWfiSA7RMRyrawsvq/t50JIzPpp1UyuSL/AXkA==}
     peerDependencies:
       esbuild: '>=0.13'
@@ -2325,7 +2369,7 @@ packages:
         optional: true
     dependencies:
       chokidar: 3.5.3
-      vite: 2.8.6
+      vite: 2.9.1
       webpack-virtual-modules: 0.4.3
     dev: true
 
@@ -2361,8 +2405,8 @@ packages:
     resolution: {integrity: sha512-nguyw8L6Un8eelg1vQ31vIU2ESxqid7EYmy8V+MDeMaHBqaRSkg3dTBToC1PR00D89UzS/SLkfYPnx0Wf23IQQ==}
     dev: false
 
-  /vite/2.8.6:
-    resolution: {integrity: sha512-e4H0QpludOVKkmOsRyqQ7LTcMUDF3mcgyNU4lmi0B5JUbe0ZxeBBl8VoZ8Y6Rfn9eFKYtdXNPcYK97ZwH+K2ug==}
+  /vite/2.9.1:
+    resolution: {integrity: sha512-vSlsSdOYGcYEJfkQ/NeLXgnRv5zZfpAsdztkIrs7AZHV8RCMZQkwjo4DS5BnrYTqoWqLoUe1Cah4aVO4oNNqCQ==}
     engines: {node: '>=12.2.0'}
     hasBin: true
     peerDependencies:
@@ -2411,8 +2455,8 @@ packages:
     resolution: {integrity: sha512-EcswR2S8bpR7fD0YPeS7r2xXExrScVMxg4MedACaWHEtx9ftCF/qHG1xGkolzTPcEmjTavCQgbVzHUIdTMzFGA==}
     dev: true
 
-  /vue-demi/0.12.4_vue@3.2.31:
-    resolution: {integrity: sha512-ztPDkFt0TSUdoq1ZI6oD730vgztBkiByhUW7L1cOTebiSBqSYfSQgnhYakYigBkyAybqCTH7h44yZuDJf2xILQ==}
+  /vue-demi/0.12.5_vue@3.2.31:
+    resolution: {integrity: sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==}
     engines: {node: '>=12'}
     hasBin: true
     requiresBuild: true

+ 18 - 5
src/App.vue

@@ -54,9 +54,22 @@ watchEffect(() => {
   </n-message-provider>
   <div
     v-if="spinning"
-    style="position: absolute; top: 0; left: 0; width: 100vw; height: 100vh"
+    style="
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100vw;
+      height: 100vh;
+      color: white;
+      font-size: 20px;
+    "
   >
-    <n-spin :show="spinning" size="large" class="global-mask fade-in"> </n-spin>
+    <n-spin
+      :show="spinning"
+      :description="store.spinMessage"
+      size="large"
+      class="global-mask fade-in"
+    />
   </div>
 </template>
 
@@ -82,18 +95,18 @@ watchEffect(() => {
   animation-delay: 1.5s;
   animation-iteration-count: 1;
   animation-timing-function: ease-in;
-  animation-duration: 60s;
+  animation-duration: 15000s;
 }
 
 @keyframes fadeInOpacity {
   0% {
-    opacity: 0;
+    opacity: 0.6;
   }
   10% {
     opacity: 0.7;
   }
   100% {
-    opacity: 0.7;
+    opacity: 0.8;
   }
 }
 </style>

+ 75 - 0
src/features/OnlineExam/Examing/ArrowNavView.vue

@@ -0,0 +1,75 @@
+<script setup lang="ts">
+import { store } from "@/store/store";
+import { useRoute } from "vue-router";
+
+const route = useRoute();
+
+// 借用是0的时候不显示此button
+const previousQuestionOrder = $computed(() => {
+  const order = +route.params.order;
+  return order > 1 ? order - 1 : 0;
+});
+
+const nextQuestionOrder = $computed(() => {
+  const order = +route.params.order;
+  return order < store.exam.examQuestionList.length ? order + 1 : 0;
+});
+</script>
+
+<template>
+  <div class="arrow-container">
+    <div class="prev">
+      <template v-if="previousQuestionOrder">
+        <router-link
+          class="qm-primary-button"
+          :to="{
+            path: `/online-exam/exam/${route.params.examId}/examRecordData/${route.params.examRecordDataId}/order/${previousQuestionOrder}`,
+          }"
+          ondragstart="return false;"
+        >
+          上一题
+        </router-link>
+      </template>
+
+      <template v-else>
+        <div>上一题</div>
+      </template>
+    </div>
+    <div class="tips">A、B、C、D来勾选选项。<!-- Y、N来勾选判断题。 --></div>
+    <div class="next">
+      <template v-if="nextQuestionOrder">
+        <router-link
+          class="qm-primary-button"
+          ondragstart="return false;"
+          :to="{
+            path: `/online-exam/exam/${route.params.examId}/examRecordData/${route.params.examRecordDataId}/order/${nextQuestionOrder}`,
+          }"
+        >
+          下一题
+        </router-link>
+      </template>
+
+      <template v-else>
+        <div>下一题</div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.arrow-container {
+  display: grid;
+  grid-template-columns: 120px 1fr 120px;
+  align-items: center;
+  justify-items: center;
+
+  border-top: 1px solid #eeeeee;
+}
+.prev,
+.tips,
+.next {
+  display: grid;
+  align-items: center;
+  justify-items: center;
+}
+</style>

+ 889 - 0
src/features/OnlineExam/Examing/ExamingHome.vue

@@ -0,0 +1,889 @@
+<script setup lang="ts">
+import RemainTime from "./RemainTime.vue";
+import OverallProgress from "./OverallProgress.vue";
+import QuestionFilters from "./QuestionFilters.vue";
+// import QuestionView from "./QuestionView.vue";
+import ArrowNavView from "./ArrowNavView.vue";
+import QuestionNavView from "./QuestionNavView.vue";
+import FaceTracking from "./FaceTracking.vue";
+// import FaceId from "./FaceId.vue";
+// import FaceMotion from "./FaceMotion/FaceMotion";
+import FaceRecognition from "../FaceRecognition.vue";
+// import { openWS, closeWsWithoutReconnect } from "./ws.js";
+
+import { STRICT_CHECK_HOSTS } from "@/constants/constants";
+import { httpApp } from "@/plugins/axiosApp";
+import { useTimers } from "@/setups/useTimers";
+import { checkMainExe } from "@/utils/nativeMethods";
+import { showLogout } from "@/utils/utils";
+import { onBeforeUpdate, onMounted } from "vue";
+import { useRoute } from "vue-router";
+import { store } from "@/store/store";
+import { useRemoteAppChecker } from "@/features/UserLogin/useRemoteAppChecker";
+import { ExamQuestion, PaperStruct, Store } from "@/types/student-client";
+import router from "@/router";
+
+type PRACTICE_TYPE = "IN_PRACTICE" | "NO_ANSWER";
+
+let loading = $ref(true);
+const route = useRoute();
+const examId = route.params.examId;
+const examRecordDataId = route.params.examRecordDataId;
+
+let courseName = $ref("");
+
+onBeforeUpdate(() => {
+  _hmt.push(["_trackEvent", "答题页面", "题目切换"]);
+  void answerAllQuestions();
+});
+
+// computed: {
+//   ...mapState([
+//     "exam",
+//     "paperStruct",
+//     "examQuestionList",
+//     "snapNow",
+//     "snapProcessingCount",
+//     "remainTime",
+//     "questionAnswerFileUrl",
+//     "uploadModalVisible",
+//     "exceedSwitchCount",
+//   ]),
+//   previousQuestionOrder: (vm) => {
+//     if (vm.examQuestion().order > 1) {
+//       return vm.examQuestion().order - 1;
+//     } else {
+//       return null;
+//     }
+//   },
+//   nextQuestionOrder: (vm) => {
+//     if (vm.examQuestion().order < vm.examQuestionList.length) {
+//       return vm.examQuestion().order + 1;
+//     } else {
+//       return null;
+//     }
+//   },
+// },
+
+// watch: {
+//   $route: function () {
+//     this.examQuestion();
+//   },
+//   questionAnswerFileUrl(value) {
+//     // console.log(this.examQuestion.studentAnswer);
+//     // console.log("watch", value);
+//     const examRecordDataId = examRecordDataId;
+//     const that = this;
+//     for (const q of value) {
+//       if (!q.saved) {
+//         let acknowledgeStatus = "CONFIRMED";
+
+//         // 目前只针对音频题有丢弃的可能
+//         if (
+//           q.transferFileType === "PIC" &&
+//           (q.order != this.$route.params.order || !this.uploadModalVisible)
+//         ) {
+//           acknowledgeStatus = "DISCARDED";
+//         }
+//         httpApp
+//           .post(
+//             "/api/ecs_oe_student/examControl/saveUploadedFileAcknowledgeStatus",
+//             {
+//               examRecordDataId,
+//               filePath: q.fileUrl,
+//               order: q.order,
+//               acknowledgeStatus,
+//             }
+//           )
+//           .then(() => {
+//             if (q.transferFileType === "AUDIO") {
+//               that.updateExamQuestion({
+//                 order: q.order,
+//                 studentAnswer: q.fileUrl,
+//               });
+//             } else if (
+//               acknowledgeStatus === "CONFIRMED" &&
+//               q.transferFileType === "PIC"
+//             ) {
+//               that.updatePicture(q);
+//             }
+//             q.saved = true;
+//             if (acknowledgeStatus === "CONFIRMED")
+//               this.$Message.info({
+//                 content: "小程序作答已更新",
+//                 duration: 5,
+//                 closable: true,
+//               });
+//           })
+//           .catch(() => {
+//             this.$Message.error({
+//               content: "更新小程序答案失败!",
+//               duration: 15,
+//               closable: true,
+//             });
+//           });
+//       }
+//     }
+//   },
+//   exceedSwitchCount(val) {
+//     if (val) {
+//       this.logger({ action: "切屏超出次数自动交卷" });
+//       this.realSubmitPaper();
+//     }
+//   },
+//   // examQuestionList(val, oldVal) {
+//   //   // console.log(val, oldVal);
+//   // }
+//   remainTime(val) {
+//     if (val === 5 * 60 * 1000) {
+//       this.reaminModalCreated = true;
+//       this.$Modal.info({
+//         render: () => (
+//           <div>
+//             <h3>温馨提醒</h3>
+//             <div style="margin-top: 20px; margin-left: 20px; flex: 1">
+//               <div style="margin-bottom: 1.5em">
+//                 还有<span style="font-weight: bold; color: red;"> 五 </span>
+//                 分钟即将结束本场考试,请合理分配时间!
+//               </div>
+//             </div>
+//           </div>
+//         ),
+//         onOk: () => {
+//           this.reaminModalClosed = true;
+//         },
+//       });
+//     } else if (val === 5 * 60 * 1000 - 10 * 1000) {
+//       if (this.reaminModalCreated && !this.reaminModalClosed) {
+//         this.$Modal.remove();
+//       }
+//     }
+//   },
+// },
+
+let pageLoadTimeout = $ref(false);
+const { addTimeout, addInterval } = useTimers();
+addTimeout(() => (pageLoadTimeout = true), 30 * 1000);
+
+// 10秒检查是否有更改需要提交答案
+addInterval(() => answerAllQuestions(), 5 * 1000);
+
+addTimeout(() => {
+  if (STRICT_CHECK_HOSTS.includes(window.location.hostname)) {
+    if (!checkMainExe()) {
+      void httpApp.post("/api/ecs_oe_student/client/exam/process/discipline");
+      logger({ cnl: ["server"], act: "答题页面discipline" });
+    }
+  }
+}, 60 * 1000);
+
+onMounted(async () => {
+  logger({
+    cnl: ["server", "local"],
+    pgn: "答题页面",
+    act: "进入答题页面-created",
+    pgu: "AUTO",
+  });
+
+  try {
+    await initData();
+    loading = false;
+  } catch (error) {
+    logger({
+      cnl: ["server"],
+      pgn: "答题页面",
+      act: "获取考试和试卷信息失败,退出登录",
+    });
+    showLogout("获取考试和试卷信息失败,退出登录");
+    return;
+  }
+
+  logger({
+    cnl: ["server"],
+    pgu: "AUTO",
+    act: "考试开始",
+    dtl: "数据初始化完成",
+  });
+});
+
+// beforeDestroy() {
+//   clearInterval(this.initSnapInterval);
+//   clearInterval(this.snapInterval);
+//   clearTimeout(this.faceIdMsgTimeout);
+//   clearTimeout(this.faceIdDivTimeout);
+//   closeWsWithoutReconnect();
+//   this.updateExamState({
+//     exam: null,
+//     paperStruct: null,
+//     examQuestionList: null,
+//     questionAnswerFileUrl: [],
+//     pictureAnswer: {},
+//     snapNow: false,
+//     snapProcessingCount: 0,
+//     exceedSwitchCount: false,
+//   });
+//   // TODO: 是否是个错误点?this.$Modal 不存在?
+//   this.$Modal.remove();
+//   // 避免macos上下塘动。避免产生滚动条。
+//   document.body.classList.toggle("hide-body-scroll", false);
+// },
+
+// methods: {
+//   ...mapMutations([
+//     "updateExamState",
+//     "updateExamQuestion",
+//     "toggleSnapNow",
+//     "updateExamResult",
+//     "resetExamQuestionDirty",
+//     "updatePicture",
+//   ]),
+async function initData() {
+  logger({ cnl: ["server", "local"], pgn: "答题页面", act: "before initData" });
+  const [
+    { data: weixinAnswerEnabled },
+    { data: faceCheckEnabled },
+    { data: faceLivenessEnabled },
+    { data: examProp },
+    { data: exam },
+    { data: paperStruct },
+    { data: examQuestionListOrig },
+    { data: _courseName },
+  ] = await Promise.all([
+    httpApp.get<boolean>(
+      "/api/ecs_exam_work/exam/weixinAnswerEnabled/" + examId,
+      {
+        "axios-retry": { retries: 4 },
+        noErrorMessage: true,
+      }
+    ),
+    httpApp.get<boolean>("/api/ecs_exam_work/exam/faceCheckEnabled/" + examId, {
+      "axios-retry": { retries: 4 },
+      noErrorMessage: true,
+    }),
+    httpApp.get<boolean>(
+      "/api/ecs_exam_work/exam/identificationOfLivingEnabled/" + examId,
+      { "axios-retry": { retries: 4 }, noErrorMessage: true }
+    ),
+    // 实际上后台都是字符串 {PRACTICE_TYPE: string | null; FREEZE_TIME: string; SNAPSHOT_INTERVAL: string;  }
+    httpApp.get<{
+      PRACTICE_TYPE: string | null;
+      FREEZE_TIME: number | null;
+      SNAPSHOT_INTERVAL: number;
+    }>(
+      "/api/ecs_exam_work/exam/getExamPropertyFromCacheByStudentSession/" +
+        examId +
+        `/SNAPSHOT_INTERVAL,PRACTICE_TYPE,FREEZE_TIME`,
+      { "axios-retry": { retries: 4 }, noErrorMessage: true }
+    ),
+    httpApp.get<Store["exam"]>("/api/ecs_exam_work/exam/" + examId, {
+      "axios-retry": { retries: 4 },
+      noErrorMessage: true,
+    }),
+    httpApp.get<PaperStruct>(
+      "/api/ecs_oe_student/examRecordPaperStruct/getExamRecordPaperStruct?examRecordDataId=" +
+        examRecordDataId,
+      { "axios-retry": { retries: 4 }, noErrorMessage: true }
+    ),
+    httpApp.get<ExamQuestion[]>(
+      "/api/ecs_oe_student/examQuestion/findExamQuestionList",
+      {
+        "axios-retry": { retries: 4 },
+        noErrorMessage: true,
+      }
+    ),
+    httpApp.get<string>(
+      "/api/ecs_oe_student/examControl/courseName/" + examRecordDataId,
+      { "axios-retry": { retries: 4 }, noErrorMessage: true }
+    ),
+  ]);
+  courseName = _courseName;
+
+  // 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({
+    cnl: ["server", "local"],
+    pgn: "答题页面",
+    dtl: `end${typeof Object.fromEntries === "function" ? " " : " "}initData`,
+  });
+  logger({
+    cnl: ["server", "local"],
+    act: "答题页面dimension",
+    ext: {
+      scrollX: window.scrollX,
+      scrollY: window.scrollY,
+      width: window.screen.width,
+      height: window.screen.height,
+      screenX: window.screen.availWidth,
+      screenY: window.screen.availHeight,
+      clientWidth: document.documentElement.clientWidth,
+      clientHeight: document.documentElement.clientHeight,
+      windowInnerWidth: window.innerWidth,
+      windowInnerHeight: window.innerHeight,
+      windowOuterWidth: window.outerWidth,
+      windowOuterHeight: window.outerHeight,
+      // 是否全屏
+      equal1:
+        "dimesion1" +
+        (window.screen.width === window.outerWidth &&
+          window.screen.height === window.outerHeight),
+      // 是否打开了调试窗口
+      equal2:
+        "dimesion2" +
+        (window.innerWidth === window.outerWidth &&
+          window.innerHeight === window.outerHeight),
+    },
+  });
+
+  if (exam.examType === "PRACTICE") {
+    exam.practiceType = examProp.PRACTICE_TYPE as PRACTICE_TYPE;
+  }
+
+  exam.freezeTime = JSON.parse("" + examProp.FREEZE_TIME);
+  examProp.SNAPSHOT_INTERVAL = JSON.parse("" + examProp.SNAPSHOT_INTERVAL);
+
+  exam.WEIXIN_ANSWER_ENABLED = weixinAnswerEnabled;
+
+  // if (faceCheckEnabled) {
+  //   let initSnapshotTrialTimes = 0;
+  //   this.initSnapInterval = setInterval(() => {
+  //     const video = document.getElementById("video");
+  //     const videoStartFailed =
+  //       !video || video.readyState !== 4 || !video.srcObject.active;
+  //     if (videoStartFailed && initSnapshotTrialTimes < 5) {
+  //       initSnapshotTrialTimes++;
+  //       this.logger({
+  //         action: "答题页面",
+  //         detail:
+  //           "进入考试后60秒内抓拍-" + `(第${initSnapshotTrialTimes}次尝试)`,
+  //       });
+  //     } else {
+  //       // 超过6次后,强行抓拍,如果抓拍不成功,则会因抓拍不成功而退出。
+  //       clearInterval(this.initSnapInterval);
+
+  //       if (videoStartFailed) {
+  //         this.logger({
+  //           action: "答题页面",
+  //           detail: "摄像头没有正常启用-进入考试抓拍",
+  //         });
+  //         this.$Message.error({
+  //           content: "摄像头没有正常启用",
+  //           duration: 5,
+  //           closable: true,
+  //         });
+  //         window._hmt.push([
+  //           "_trackEvent",
+  //           "摄像头框",
+  //           "摄像头状态",
+  //           "摄像头没有正常启用-进入考试抓拍",
+  //         ]);
+
+  //         this.logout("?LogoutReason=" + "摄像头没有正常启用-退出");
+  //       } else {
+  //         this.logger({
+  //           action: "答题页面",
+  //           detail:
+  //             "进入考试后60秒内抓拍-" +
+  //             `(第${initSnapshotTrialTimes + 1}次尝试成功)`,
+  //         });
+  //         this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
+  //       }
+  //     }
+  //   }, 10 * 1000);
+
+  //   //       let initSnapshotTrialTimes = 0;
+  //   // const initSnapshot = setTimeout(() => {
+  //   //   if (this.exam || initSnapshotTrialTimes < 6) {
+  //   //     this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
+  //   //   } else {
+  //   //     setTimeout(() => initSnapshot(), 5 * 1000);
+  //   //   }
+  //   // }, 5 * 1000);
+
+  //   if (examProp.SNAPSHOT_INTERVAL) {
+  //     // 考务设置抓拍间隔
+  //     this.snapInterval = setInterval(() => {
+  //       this.logger({
+  //         action: "答题页面",
+  //         detail: "定时抓拍",
+  //         SNAPSHOT_INTERVAL: examProp.SNAPSHOT_INTERVAL,
+  //       });
+  //       this.toggleSnapNow();
+  //     },  examProp.SNAPSHOT_INTERVAL * 60 * 1000);
+  //   }
+  // }
+
+  logger({
+    cnl: ["server", "local"],
+    pgn: "答题页面",
+    ext: {
+      examRecordDataId: examRecordDataId,
+      faceCheckEnabled: faceCheckEnabled,
+      faceLivenessEnabled: faceLivenessEnabled,
+      WEIXIN_ANSWER_ENABLED: exam.WEIXIN_ANSWER_ENABLED,
+      SNAPSHOT_INTERVAL: examProp.SNAPSHOT_INTERVAL,
+      PRACTICE_TYPE: examProp.PRACTICE_TYPE,
+      FREEZE_TIME: examProp.FREEZE_TIME,
+    },
+  });
+
+  // parentQuestionBody
+  //     questionUnitWrapperList
+  //         questionBody  => from examQuestionList
+  //         questionUnitList =>
+  //         studentAnswer
+  //         rightAnswer
+
+  // init subNumber
+  let questionId: string | null = null;
+  let i = 1;
+
+  examQuestionList = examQuestionList.map((eq) => {
+    if (questionId == eq.questionId) {
+      eq.subNumber = i++;
+    } else {
+      i = 1;
+      questionId = eq.questionId;
+      eq.subNumber = i++;
+    }
+    return eq;
+  });
+
+  let groupOrder = 1;
+  let mainNumber = 0;
+  examQuestionList = examQuestionList.map((eq) => {
+    if (mainNumber == eq.mainNumber) {
+      eq.inGroupOrder = groupOrder++;
+    } else {
+      mainNumber = eq.mainNumber;
+      groupOrder = 1;
+      eq.inGroupOrder = groupOrder++;
+    }
+
+    const questionWrapperList =
+      paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1]
+        .questionWrapperList;
+    const groupName =
+      paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1].groupName;
+    const groupTotal = questionWrapperList.reduce(
+      (accumulator, questionWrapper) =>
+        accumulator + questionWrapper.questionUnitWrapperList.length,
+      0
+    );
+
+    eq.groupName = groupName;
+    eq.groupTotal = groupTotal;
+    return eq;
+  });
+
+  store.exam.examQuestionList = examQuestionList.map((eq) => {
+    const paperStructQuestion = paperStruct.defaultPaper.questionGroupList[
+      eq.mainNumber - 1
+    ].questionWrapperList.find((q) => q.questionId === eq.questionId);
+    return Object.assign(eq, {
+      limitedPlayTimes: paperStructQuestion!.limitedPlayTimes,
+    });
+  });
+
+  Object.assign(store.exam, exam);
+  store.exam.paperStruct = paperStruct;
+  // TODO: 此处类型待优化
+  store.exam.allAudioPlayTimes =
+    JSON.parse(
+      store.exam.examQuestionList[0].audioPlayTimes as unknown as string
+    ) || [];
+
+  // this.updateExamState({
+  //   exam: exam,
+  //   paperStruct: paperStruct,
+  //   examQuestionList: examQuestionList,
+  //   allAudioPlayTimes: JSON.parse(examQuestionList[0].audioPlayTimes) || [],
+  //   questionAnswerFileUrl: [],
+  //   pictureAnswer: {},
+  // });
+  // console.log(examQuestionList);
+  // console.log(examQuestionList.find(v => v.answerType === "SINGLE_AUDIO"));
+
+  // const shouldOpenWS = exam.WEIXIN_ANSWER_ENABLED;
+
+  // if (shouldOpenWS) {
+  //   // console.log("have single");
+  //   const examRecordDataId = examRecordDataId;
+  //   openWS({ examRecordDataId });
+  // }
+}
+
+// async function updateQuestion(next) {
+//   // 初始化套题的答案,为回填部分选项做准备
+//   // for (let q of this.examQuestionList) {
+//   //   if (q.subQuestionList.length > 0) {
+//   //     q.studentAnswer = [];
+//   //     for (let sq of q.subQuestionList) {
+//   //       q.studentAnswer.push(sq.studentAnswer);
+//   //     }
+//   //   }
+//   // }
+
+//   next && next();
+//   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 });
+  });
+}
+
+type Answer = {
+  order: number;
+  studentAnswer: string;
+  audioPlayTimes: { audioName: string; times: number }[];
+  isSign: boolean;
+};
+async function answerAllQuestions(ignoreDirty?: boolean): Promise<boolean> {
+  const answers: Answer[] = store.exam.examQuestionList
+    .filter((eq) => (ignoreDirty ? true : eq.dirty))
+    .filter((eq) => eq.getQuestionContent)
+    .map((eq) => {
+      return Object.assign(
+        {
+          order: eq.order,
+          studentAnswer: eq.studentAnswer,
+        },
+        eq.audioPlayTimes && { audioPlayTimes: eq.audioPlayTimes },
+        eq.isSign && { isSign: eq.isSign }
+      ) as Answer;
+    });
+  if (answers.length > 0) {
+    try {
+      await httpApp.post(
+        "/api/ecs_oe_student/examQuestion/submitQuestionAnswer",
+        answers
+      );
+      resetExamQuestionDirty();
+    } catch (error) {
+      logger({
+        cnl: ["server", "local"],
+        pgu: "AUTO",
+        act: "提交答案失败",
+        possibleError: error,
+      });
+      $message.error("提交答案失败");
+      return false;
+    }
+  }
+  // 提交成功,返回true,供最后提交时判断。自动提交失败,不暂停。
+  return true;
+}
+
+async function submitPaper() {
+  logger({ cnl: ["server", "local", "console"], act: "学生点击交卷" });
+  try {
+    // 交卷前强制提交所有答案
+    const ret = await answerAllQuestions(true);
+    if (!ret) {
+      // 提交答案失败,停止交卷逻辑。
+      return;
+    }
+  } catch (error) {
+    return;
+  }
+
+  if (
+    store.exam.freezeTime &&
+    store.exam.remainTime >
+      (store.exam.duration - store.exam.freezeTime) * 60 * 1000
+  ) {
+    $message.info(`考试开始${store.exam.freezeTime}分钟后才允许交卷。`);
+    return;
+  }
+
+  const answered = store.exam.examQuestionList.filter(
+    (q) => q.studentAnswer !== null
+  ).length;
+  const unanswered = store.exam.examQuestionList.filter(
+    (q) => q.studentAnswer === null
+  ).length;
+  const signed = store.exam.examQuestionList.filter((q) => q.isSign).length;
+  const showConfirmTime = Date.now();
+  $dialog.info({
+    title: "确认交卷",
+    content: `<p>已答题目:${answered}</p><p>未答题目:${unanswered}</p><p>标记题目:${signed}</p>`,
+    positiveText: "确定",
+    onPositiveClick: () => {
+      void realSubmitPaper(showConfirmTime);
+    },
+  });
+}
+
+function realSubmitPaper(showConfirmTime = 0) {
+  store.increaseGlobalMaskCount("realSubmitPaper");
+  store.spinMessage = "正在交卷,请耐心等待...";
+  logger({ cnl: ["server"], act: "正在交卷,请耐心等待..." });
+  if (store.exam.faceCheckEnabled) {
+    logger({ cnl: ["server"], act: "交卷前抓拍" });
+    // this.toggleSnapNow();
+  }
+  // 确保抓拍指令在交卷前执行,同时确保5秒间隔提交答案的指令执行了
+  let delay = 5 - (Date.now() - showConfirmTime) / 1000;
+  if (delay < 0) {
+    // 如果用户已经看确认框超过5秒,或者不是由确认框进来的,不延迟
+    delay = 0;
+  }
+  // 给抓拍照片多一秒处理时间
+  delay = delay + 1;
+  // 和下行注释的sleep语句不是一样的。sleep之后还可以执行。加上clearTimeout则可拒绝。
+  // await new Promise(resolve => setTimeout(() => resolve(), delay * 1000));
+  addTimeout(() => {
+    store.decreaseGlobalMaskCount("realSubmitPaper");
+    store.spinMessage = "";
+    void router.push({
+      name: "SubmitPaper",
+      params: { examId, examRecordDataId },
+    });
+  }, delay * 1000);
+}
+
+function shouldSubmitPaper() {
+  logger({ cnl: ["server"], act: "时间到自动交卷" });
+  void router.push({
+    name: "SubmitPaper",
+    params: { examId, examRecordDataId },
+  });
+}
+
+// const examQuestion = $computed(() =>
+//   store.exam.examQuestionList?.find(
+//     (eq) => eq.order == route.params.order /*number == string*/
+//   )
+// );
+
+function reloadPage() {
+  logger({
+    cnl: ["server", "local"],
+    pgn: "答题页面",
+    act: "点击重试按钮",
+    dtl: "答题页面加载失败",
+  });
+  window.location.reload();
+}
+
+const {
+  disableLoginBtnBecauseRemoteApp: disableExamingBecauseRemoteApp,
+  checkRemoteAppTxt: checkRemoteApp,
+} = useRemoteAppChecker();
+console.log({
+  disableExamingBecauseRemoteApp: disableExamingBecauseRemoteApp.value,
+});
+
+function checkRemoteAppClicked() {
+  logger({ cnl: ["server"], pgu: "AUTO", act: "点击确认已关闭远程桌面软件" });
+  void checkRemoteApp();
+}
+
+// 3分钟检测是否有远程桌面软件在运行
+addInterval(() => checkRemoteApp(), 3 * 60 * 1000);
+</script>
+
+<template>
+  <div v-if="!loading" class="container">
+    <div class="header">
+      <RemainTime @onEndtime="shouldSubmitPaper"></RemainTime>
+      <div style="display: flex; flex-direction: column">
+        <div style="margin-bottom: 12px">{{ courseName }}</div>
+        <OverallProgress></OverallProgress>
+      </div>
+      <div>
+        {{ store.user.displayName }} -&nbsp;
+        {{ store.user.studentCodeList.join(",") }}
+      </div>
+      <QuestionFilters></QuestionFilters>
+      <n-button type="success" @click="submitPaper">交卷</n-button>
+    </div>
+    <div id="examing-home-question" class="main">
+      <!-- <QuestionView :examQuestion="examQuestion()"></QuestionView> -->
+      <ArrowNavView></ArrowNavView>
+    </div>
+    <div :class="['side']">
+      <div :class="['question-nav']">
+        <QuestionNavView />
+      </div>
+      <div v-if="store.exam.faceCheckEnabled" class="camera">
+        <FaceRecognition
+          width="400"
+          height="300"
+          :showRecognizeButton="false"
+        />
+      </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> -->
+    <FaceTracking v-if="store.exam.faceCheckEnabled" />
+    <div
+      v-if="disableExamingBecauseRemoteApp"
+      style="
+        top: 0;
+        left: 0;
+        width: 100vw;
+        height: 100vh;
+        background-color: rgba(77, 77, 77, 0.95);
+        z-index: 100;
+        position: absolute;
+      "
+    >
+      <div
+        class="tw-flex tw-flex-col tw-justify-center tw-items-center tw-text-center tw-h-full"
+      >
+        <h3 class="tw-my-8 tw-text-2xl">请关闭远程桌面软件后再进行考试!</h3>
+        <n-button type="success" @click="checkRemoteAppClicked">
+          确认已关闭远程桌面软件
+        </n-button>
+      </div>
+    </div>
+  </div>
+  <div v-else class="tw-text-center tw-my-4 tw-text-lg">
+    正在等待数据返回...
+    <br />
+    <n-button v-if="pageLoadTimeout" type="success" @click="reloadPage">
+      重试
+    </n-button>
+  </div>
+</template>
+
+<style scoped>
+.container {
+  display: grid;
+  grid-template-areas:
+    "header header"
+    "main side";
+  grid-template-rows: 80px minmax(0, 1fr);
+  grid-template-columns: 1fr 400px;
+
+  height: 100vh;
+  width: 100vw;
+}
+
+.header {
+  display: grid;
+  align-items: center;
+  justify-items: center;
+  grid-template-columns: 200px 280px 1fr 300px 100px;
+  grid-area: header;
+  height: 80px;
+  background-color: #f5f5f5;
+}
+
+.main {
+  display: grid;
+  grid-area: main;
+  grid-template-rows: 1fr 50px;
+}
+
+.side {
+  display: grid;
+  grid-area: side;
+  grid-template-rows: 1fr;
+  background-color: #f5f5f5;
+}
+
+.question-nav {
+  overflow-y: scroll;
+}
+
+.camera {
+  z-index: 100;
+  height: 300px;
+}
+
+@media screen and (max-height: 768px) {
+  .container {
+    grid-template-rows: 50px minmax(0, 1fr);
+  }
+  .header {
+    height: 50px;
+  }
+}
+
+@media screen and (max-width: 960px) {
+  .header {
+    overflow-x: scroll;
+  }
+}
+</style>
+
+<style>
+#examing-home-question img {
+  max-width: 100%;
+  height: auto !important;
+}
+
+.hide-body-scroll {
+  overflow: hidden !important;
+}
+</style>

+ 403 - 0
src/features/OnlineExam/Examing/FaceTracking.vue

@@ -0,0 +1,403 @@
+<script setup lang="ts">
+import * as faceapi from "face-api.js";
+import { FACE_API_MODEL_PATH } from "@/constants/constants";
+import { isThisMachineOwnByStudent } from "@/utils/utils";
+import { onMounted } from "vue";
+import { useTimers } from "@/setups/useTimers";
+
+const { addTimeout, addInterval } = useTimers();
+// window.faceapi = faceapi;
+
+// const os = (function() {
+//   const ua = navigator.userAgent.toLowerCase();
+//   return {
+//     isWin2K: /windows nt 5.0/.test(ua),
+//     isXP: /windows nt 5.1/.test(ua),
+//     isVista: /windows nt 6.0/.test(ua),
+//     isWin7: /windows nt 6.1/.test(ua),
+//     isWin8: /windows nt 6.2/.test(ua),
+//     isWin81: /windows nt 6.3/.test(ua),
+//     isWin10: /windows nt 10.0/.test(ua),
+//   };
+// })();
+
+let __cache4WebglAvailable: boolean | null = null;
+function webgl_available() {
+  if (__cache4WebglAvailable !== null) return __cache4WebglAvailable;
+
+  var canvas = document.createElement("canvas");
+  var gl = canvas.getContext("webgl");
+  __cache4WebglAvailable = !!(gl && gl instanceof WebGLRenderingContext);
+  return __cache4WebglAvailable;
+}
+
+let __cache4TensorFlowWebPackStatus: number | boolean | null = null;
+function tensorFlowWebPackStatus() {
+  if (__cache4TensorFlowWebPackStatus !== null)
+    return __cache4TensorFlowWebPackStatus;
+
+  try {
+    __cache4TensorFlowWebPackStatus = faceapi.tf.ENV.get("WEBGL_PACK");
+  } finally {
+    if (__cache4TensorFlowWebPackStatus !== true) {
+      __cache4TensorFlowWebPackStatus = false;
+    }
+  }
+  return __cache4TensorFlowWebPackStatus;
+}
+
+// function getCPUModel() {
+//   if (typeof nodeRequire != "undefined") {
+//     var os = window.nodeRequire("os");
+//     const cpus = os.cpus();
+//     if (cpus.length > 0) {
+//       return cpus[0].model;
+//     }
+//   }
+//   return "null";
+// }
+
+// if (os.isWin7) alert("是win7");
+// if (os.isWin10) alert("是win10");
+
+let __inputSize = 128;
+let __isDoingFaceLiveness = false;
+let disableFaceTracking = false;
+
+async function detectTest() {
+  const inputSizeList = [128, 160, 224, 320, 416, 512, 608];
+  const succRate = [0, 0, 0, 0, 0, 0, 0];
+  const detectTimes = 6;
+
+  try {
+    const detectStartTime = performance.now();
+    const videoEl = <HTMLVideoElement>document.getElementById("video");
+    if (!videoEl) return;
+    const options = new faceapi.TinyFaceDetectorOptions({
+      inputSize: 128,
+      scoreThreshold: 0.5,
+    });
+    const result: any = await Promise.race([
+      faceapi.detectAllFaces(videoEl, options),
+      new Promise((resolve) => setTimeout(resolve, 10 * 1000)),
+    ]);
+    const detectEndTime = performance.now();
+    if (
+      !result ||
+      !result.length ||
+      detectStartTime - detectEndTime > 2 * 1000
+    ) {
+      disableFaceTracking = true;
+      _hmt.push(["_trackEvent", "答题页面", "启动检测耗时过长:停止实时"]);
+      logger({
+        cnl: ["server"],
+        act: "实时人脸检测",
+        dtl: "启动检测耗时过长:停止实时",
+        ext: {
+          result: JSON.stringify(result),
+          cost: detectStartTime - detectEndTime,
+        },
+      });
+      return;
+    }
+  } catch (error) {
+    console.log(error);
+    disableFaceTracking = true;
+    _hmt.push(["_trackEvent", "答题页面", "启动检测错误:停止实时"]);
+    logger({
+      cnl: ["server"],
+      act: "启动检测错误:停止实时",
+      possibleError: error,
+    });
+    return;
+  }
+
+  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) {
+        console.log("正在活检,暂停实时人脸");
+        await new Promise((resolve) => setTimeout(resolve, 120 * 1000));
+      }
+      const inputSize = inputSizeList[idx];
+      const videoEl = <HTMLVideoElement>document.getElementById("video");
+      if (!videoEl) return;
+      try {
+        const detectStartTime = performance.now();
+        const options = new faceapi.TinyFaceDetectorOptions({
+          inputSize: inputSize,
+          scoreThreshold: 0.5,
+        });
+        // const result = await faceapi.detectAllFaces(videoEl, options);
+        // console.log(result);
+        const result: any = await Promise.race([
+          faceapi.detectAllFaces(videoEl, options),
+          new Promise((resolve) => setTimeout(resolve, 10 * 1000)),
+        ]);
+        const detectEndTime = performance.now();
+        if (detectStartTime - detectEndTime > 0.2 * 1000) {
+          disableFaceTracking = true;
+          _hmt.push(["_trackEvent", "答题页面", "单次检测耗时过长:停止实时"]);
+          logger({
+            cnl: ["server"],
+            act: "单次检测耗时过长:停止实时",
+            ext: {
+              cost: detectStartTime - detectEndTime,
+            },
+          });
+          return;
+        }
+
+        if (result && result.length >= 1) {
+          console.log(`inputSize: ${inputSize} ${result.length}`);
+          succRate[idx]++;
+        } else {
+          console.log(`inputSize: ${inputSize} 检测失败`);
+        }
+      } catch (error) {
+        console.log(error);
+        console.log(`inputSize: ${inputSize} 检测失败-异常`);
+      }
+    }
+
+    if (succRate[idx] === detectTimes) {
+      console.log(`inputSize: ${inputSizeList[idx]} 提前选中`);
+      break;
+    }
+  }
+
+  console.log({ succRate });
+  const max = Math.max(...succRate);
+
+  const idx = succRate.indexOf(max);
+
+  __inputSize = inputSizeList[idx];
+  logger({
+    cnl: ["server", "local"],
+    pgn: "实时人脸检测",
+    act: "最好的 inputSize 为:" + __inputSize,
+  });
+
+  return __inputSize;
+}
+
+function getFaceDetectorOptions() {
+  return new faceapi.TinyFaceDetectorOptions({
+    inputSize: __inputSize || 128,
+    scoreThreshold: 0.5,
+  });
+
+  // return new faceapi.SsdMobilenetv1Options({ minConfidence: 0.8 });
+  // return new faceapi.MtcnnOptions({ minFaceSize: 200, scaleFactor: 0.8 });
+}
+
+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);
+  await faceapi.loadFaceLandmarkModel(FACE_API_MODEL_PATH);
+  faceapi.tf.ENV.set("WEBGL_PACK", false);
+
+  let trackStarted = false;
+
+  async function trackHead() {
+    const video = <HTMLVideoElement>document.getElementById("video");
+    if (
+      video &&
+      video.readyState === 4 &&
+      faceapi.nets.tinyFaceDetector.params
+    ) {
+      trackStarted = true;
+    } else {
+      return;
+    }
+    console.log("start tracking ... ");
+    await detectTest();
+
+    await detectFaces();
+  }
+  const trackHeadInterval = addInterval(() => {
+    if (trackStarted) {
+      clearInterval(trackHeadInterval);
+    } else {
+      void trackHead();
+    }
+  }, 1000);
+});
+// beforeDestroy() {
+//   clearTimeout(this.warningTimeout);
+//   clearTimeout(this.detectFacesTimeout);
+// },
+
+let singleTimeUsage = 0;
+let multipleTimeUsage = 0;
+let showWaringTime = Date.now();
+
+let failTimes = 0;
+
+let detectFacesTimeout: number;
+let warningTimeout: number;
+async function detectFaces() {
+  if (
+    disableFaceTracking ||
+    singleTimeUsage > 10 * 1000 ||
+    multipleTimeUsage > 0.5 * 1000
+  ) {
+    logger({
+      cnl: ["server"],
+      act: "关闭实时人脸检测,因为耗时过长",
+      ext: { multipleTimeUsage },
+    });
+    _hmt.push(["_trackEvent", "答题页面", "关闭实时人脸检测,因为耗时过长"]);
+    return;
+  }
+  // FIXME: 接收活体进行中的事件
+  // if (this.isDoingFaceLiveness) {
+  //   logger({
+  //     cnl: ["server"],
+  //     pgn: "实时人脸检测",
+  //     act: "正在活检,暂停实时人脸",
+  //   });
+  //   clearTimeout(detectFacesTimeout);
+  //   detectFacesTimeout = addTimeout(() => void detectFaces(), 10 * 1000);
+  //   return;
+  // }
+
+  const videoEl = <HTMLVideoElement>document.getElementById("video");
+  // var canvas = document.createElement("canvas");
+  // canvas.width = 133;
+  // canvas.height = 100;
+
+  // var context = canvas.getContext("2d");
+  // context.drawImage(videoEl, 0, 0, 133, 100);
+  const detectStartTime = performance.now();
+  // this.___vWidth =
+  //   this.___vWidth ||
+  //   document.getElementById("video-container").clientWidth;
+
+  const options = getFaceDetectorOptions();
+  let result;
+
+  try {
+    result = await faceapi
+      // .detectSingleFace(videoEl, options)
+      .detectAllFaces(videoEl, options);
+  } catch (e) {
+    _hmt.push(["_trackEvent", "答题页面", "实时人脸检测失败"]);
+    logger({ cnl: ["server"], act: "实时人脸检测失败", possibleError: e });
+    throw e;
+  }
+  // console.log(result);
+
+  const detectEndTime = performance.now();
+  logger({
+    cnl: ["server", "console"],
+    pgn: "实时人脸检测",
+    ext: {
+      WebGL: webgl_available(),
+      WEBGL_PACK: tensorFlowWebPackStatus(),
+      "single detect time": detectEndTime - detectStartTime,
+      resultLen: result.length,
+    },
+  });
+  singleTimeUsage = detectEndTime - detectStartTime;
+
+  if (detectTimeArray.length < 6) {
+    // 仅捕获一部分检测次数
+    detectTimeArray.push(detectEndTime - detectStartTime);
+  }
+  if (detectTimeArray.length === 6) {
+    detectTimeArray.shift();
+    const avg =
+      detectTimeArray.reduce((a, b) => a + b, 0) / detectTimeArray.length;
+    const roundAvg = Math.round(avg / 100) * 100;
+    logger({
+      cnl: ["server"],
+      pgn: "实时人脸检测",
+      ext: {
+        roundAvg: roundAvg + "ms",
+        computer: isThisMachineOwnByStudent() ? "学生电脑" : "学习中心电脑",
+      },
+    });
+    console.log(detectTimeArray);
+    detectTimeArray.push(0, 0); // 避免再次达到push条件和上传条件
+
+    // FIXME: 上线初期停止统计此类信息,过于零散
+    // const roundAvg100 = Math.round(avg / 100) * 100;
+    // const osType = os.isWin7 ? "win7" : os.isWin10 ? "win10" : "other";
+    // const stats = `webgl: ${webgl_available()}; tf_backend: ${faceapi.tf.getBackend()}; os: ${osType}; cpu: ${getCPUModel()}`;
+    // window._hmt.push([
+    //   "_trackEvent",
+    //   "答题页面",
+    //   "实时人脸检测统计" + roundAvg100 + "ms",
+    //   stats,
+    // ]);
+
+    multipleTimeUsage = roundAvg;
+  }
+  // init this.showWaringTime
+  showWaringTime = showWaringTime || Date.now();
+
+  if (result.length >= 2 && Date.now() - showWaringTime > 20 * 1000) {
+    showWaringTime = Date.now();
+    $message.warning("请独立完成考试");
+  }
+
+  if (result.length === 0 && Date.now() - showWaringTime > 20 * 1000) {
+    showWaringTime = Date.now();
+    $message.warning("请调整坐姿,诚信考试");
+    failTimes = failTimes || failTimes++;
+  }
+
+  if (
+    (!result || result.length !== 1) &&
+    !videoEl.classList.contains("video-warning")
+  ) {
+    videoEl.classList.add("video-warning");
+    clearTimeout(warningTimeout);
+    warningTimeout = addTimeout(function () {
+      videoEl.classList.remove("video-warning");
+    }, 3000);
+  }
+
+  clearTimeout(detectFacesTimeout);
+  detectFacesTimeout = addTimeout(async () => {
+    if (failTimes > 5) {
+      $message.warning("请保持正确坐姿,确保脸部在摄像头内,背景无强光。");
+      failTimes = 1;
+      await detectTest();
+    }
+    await detectFaces();
+  }, 60 * 1000);
+}
+</script>
+
+<template>
+  <div></div>
+</template>
+
+<style>
+@keyframes warning-people {
+  0% {
+    /* border: solid 5px white; */
+    box-shadow: 0 0 20px white;
+  }
+  100% {
+    /* border: solid 5px red; */
+    box-shadow: 0 0 20px gold;
+  }
+}
+
+.video-warning {
+  animation: warning-people 3s infinite;
+}
+</style>

+ 53 - 0
src/features/OnlineExam/Examing/OverallProgress.vue

@@ -0,0 +1,53 @@
+<script setup lang="ts">
+import { store } from "@/store/store";
+
+const progressNum = $computed(() => {
+  return (
+    100 *
+    (store.exam.examQuestionList.filter((q) => q.studentAnswer !== null)
+      .length /
+      store.exam.examQuestionList.length)
+  );
+});
+const progress = $computed(() => {
+  return `${
+    store.exam.examQuestionList.filter((q) => q.studentAnswer !== null).length
+  } / ${store.exam.examQuestionList.length}`;
+});
+</script>
+
+<template>
+  <div class="progress-container">
+    <n-progress
+      type="line"
+      :percentage="progressNum"
+      :indicatorPlacement="'inside'"
+      processing
+    />
+    <span>{{ progress }}</span>
+  </div>
+</template>
+
+<style scoped>
+.progress-container {
+  display: grid;
+  justify-self: flex-start;
+
+  align-items: center;
+  justify-items: center;
+  grid-template-columns: 200px 50px;
+  width: 250px;
+}
+</style>
+
+<style>
+.progress-container .ivu-progress-inner {
+  background-color: #02ffff;
+  background-color: white;
+  border-radius: 0px;
+}
+
+.progress-container .ivu-progress-bg {
+  border-radius: 0px;
+}
+</style>

+ 88 - 0
src/features/OnlineExam/Examing/QuestionFilters.vue

@@ -0,0 +1,88 @@
+<script setup lang="ts">
+import { store } from "@/store/store";
+
+// 初始化
+store.exam.questionFilterType = "ALL";
+
+const all = $computed(() => store.exam.examQuestionList.length);
+const answered = $computed(
+  () =>
+    store.exam.examQuestionList.filter((q) => q.studentAnswer !== null).length
+);
+const signed = $computed(
+  () => store.exam.examQuestionList.filter((q) => q.isSign).length
+);
+const unanswered = $computed(
+  () =>
+    store.exam.examQuestionList.filter((q) => q.studentAnswer === null).length
+);
+</script>
+
+<template>
+  <div class="q-filters">
+    <div
+      :class="store.exam.questionFilterType == 'ALL' && 'selected-type'"
+      @click="store.exam.questionFilterType = 'ALL'"
+    >
+      全部 <span class="all-type">{{ all }}</span>
+    </div>
+    <div
+      :class="store.exam.questionFilterType == 'ANSWERED' && 'selected-type'"
+      @click="store.exam.questionFilterType = 'ANSWERED'"
+    >
+      已答 <span class="answered-type">{{ answered }}</span>
+    </div>
+    <div
+      :class="store.exam.questionFilterType == 'SIGNED' && 'selected-type'"
+      @click="store.exam.questionFilterType = 'SIGNED'"
+    >
+      标记 <span class="signed-type">{{ signed }}</span>
+    </div>
+    <div
+      :class="store.exam.questionFilterType == 'UNANSWERED' && 'selected-type'"
+      @click="store.exam.questionFilterType = 'UNANSWERED'"
+    >
+      未答 <span class="unanswered-type">{{ unanswered }}</span>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.q-filters {
+  display: grid;
+  grid-template-columns: 1fr 1fr 1fr 1fr;
+  cursor: pointer;
+}
+
+.q-filters > div {
+  border-radius: 6px;
+  padding: 5px 5px;
+  /* margin: 0 5px; */
+}
+
+.q-filters > div > span {
+  padding: 0 3px;
+  border-radius: 4px;
+  margin: 0 2px;
+}
+
+.selected-type {
+  background-color: white;
+}
+
+.all-type {
+  background-color: #eeeeee;
+}
+
+.answered-type {
+  background-color: #13bb8a;
+}
+
+.signed-type {
+  background-color: #ffcc00;
+}
+
+.unanswered-type {
+  background-color: rgb(255, 107, 76);
+}
+</style>

+ 168 - 0
src/features/OnlineExam/Examing/QuestionNavView.vue

@@ -0,0 +1,168 @@
+<script setup lang="ts">
+import { store } from "@/store/store";
+import { ExamQuestion } from "@/types/student-client";
+import { toChineseNumber } from "@/utils/utils";
+import { useRoute } from "vue-router";
+
+const route = useRoute();
+
+function getQuestionNum(section: number, index: number): ExamQuestion {
+  let total = 0;
+  for (var i = 0; i < section; i++) {
+    // questionGroupList [
+    //   {
+    //     questionWrapperList [
+    //       questionUnitWrapperList{questionId} [  // 套题只有一个questionId
+    //         questionUnit  // 套题这里为数组大于1。
+    //       ]
+    //     ]
+    //   }
+    // ]
+    total += store.exam.paperStruct.defaultPaper.questionGroupList[
+      i
+    ].questionWrapperList.reduce((accumulator, questionWrapper) => {
+      // console.log(questionWrapper);
+      return accumulator + questionWrapper.questionUnitWrapperList.length;
+    }, 0);
+  }
+  return store.exam.examQuestionList[total + index];
+}
+
+function isSelectedQuestion(section: number, index2: number) {
+  let q = getQuestionNum(section, index2);
+
+  if (store.exam.questionFilterType === "ALL") return true;
+  else if (
+    store.exam.questionFilterType === "ANSWERED" &&
+    q.studentAnswer !== null
+  ) {
+    return true;
+  } else if (store.exam.questionFilterType === "SIGNED" && q.isSign) {
+    return true;
+  } else if (
+    store.exam.questionFilterType === "UNANSWERED" &&
+    q.studentAnswer === null
+  ) {
+    return true;
+  }
+  return false;
+}
+
+function itemClass(section: number, index2: number) {
+  const eq = getQuestionNum(section, index2);
+  const order = +route.params.order;
+  const isCurrentQuestion = order === eq.order; // 故意用的 ==
+  return {
+    item: true,
+    "current-question": isCurrentQuestion,
+    "star-question": eq.isSign,
+    "is-answered": eq.studentAnswer !== null,
+  };
+}
+
+function sectionQuestions(section: number) {
+  return store.exam.examQuestionList.filter(
+    (q) => q.mainNumber === section + 1
+  );
+}
+</script>
+
+<template>
+  <div style="padding-bottom: 10px">
+    <div
+      v-for="(struct, section) in store.exam.paperStruct.defaultPaper
+        .questionGroupList"
+      :key="section"
+      class="section"
+    >
+      <div class="title">
+        {{
+          `${toChineseNumber(section + 1)}、${struct.groupName}(${
+            struct.groupScore
+          }分)`
+        }}
+      </div>
+      <div class="list">
+        <template v-for="(_, index2) in sectionQuestions(section)">
+          <template v-if="isSelectedQuestion(section, index2)">
+            <router-link
+              :key="index2"
+              :class="itemClass(section, index2)"
+              ondragstart="return false;"
+              :to="{
+                path: `/online-exam/exam/${
+                  $route.params.examId
+                }/examRecordData/${$route.params.examRecordDataId}/order/${
+                  getQuestionNum(section, index2).order
+                }`,
+              }"
+            >
+              {{ index2 + 1 }}
+            </router-link>
+          </template>
+        </template>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.section {
+  display: grid;
+  align-items: flex-start;
+  justify-items: flex-start;
+  margin: 0 20px;
+  margin-bottom: 20px;
+}
+.title {
+  margin-bottom: 5px;
+  text-align: left;
+}
+.list {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  text-align: center;
+}
+.item {
+  border-radius: 50%;
+  border: 1px solid #cccccc;
+  width: 30px;
+  height: 30px;
+  line-height: 30px;
+  margin-right: 5px;
+  margin-bottom: 5px;
+}
+.is-answered {
+  background-color: #13bb8a;
+}
+.is-answered > a {
+  color: white;
+}
+.current-question {
+  box-shadow: 0 0 15px gold;
+  background-color: #a8bcf7;
+  transform: scale(1.2);
+}
+.current-question > a {
+  color: black;
+}
+.star-question {
+  background-color: #ffcc00;
+}
+.star-question > a {
+  color: white;
+}
+
+@media screen and (max-height: 768px) {
+  .section {
+    margin-bottom: 10px;
+  }
+}
+
+@media screen and (max-height: 720px) {
+  .section {
+    margin-bottom: 5px;
+  }
+}
+</style>

+ 194 - 0
src/features/OnlineExam/Examing/RemainTime.vue

@@ -0,0 +1,194 @@
+<script setup lang="ts">
+import { httpApp } from "@/plugins/axiosApp";
+import { useTimers } from "@/setups/useTimers";
+import { store } from "@/store/store";
+import { showLogout } from "@/utils/utils";
+import axios, { Canceler } from "axios";
+import moment from "moment";
+import { onMounted, onUnmounted } from "vue";
+import { useRoute } from "vue-router";
+
+const emit = defineEmits<{ (e: "on-endtime"): void }>();
+
+const route = useRoute();
+let remainTime: number | null = $ref(null);
+let enhancedRemainTimeStyle = $ref(true);
+
+const remainTimeFormatted = $computed(() =>
+  remainTime ? moment.utc(remainTime).format("HH:mm:ss") : "计算中"
+);
+
+const { addTimeout, addInterval } = useTimers();
+
+const updateTimeFromServerId = addInterval(
+  () => getRemainTimeFromServer(),
+  60 * 1000
+);
+
+const updateRemainTimeId = addInterval(() => {
+  if (typeof remainTime === "number") {
+    if (remainTime > 0) {
+      remainTime = remainTime - 1000;
+    }
+    // 剩余时间永远不应该为负数,否则会显示 "23:59:59"
+    if (remainTime < 0) {
+      remainTime = 0;
+      emit("on-endtime");
+    }
+    store.exam.remainTime = remainTime;
+  }
+}, 1000);
+
+addTimeout(() => (enhancedRemainTimeStyle = false), 10 * 1000);
+
+onMounted(async () => {
+  try {
+    await httpApp.post(
+      "/api/ecs_oe_student/examControl/startAnswer?examRecordDataId=" +
+        route.params.examRecordDataId,
+      { "axios-retry": { retries: 4 }, noErrorMessage: true }
+    );
+  } catch (error) {
+    const msg = "/startAnswer 出错";
+    logger({ cnl: ["server"], pgu: "AUTO", act: msg, possibleError: error });
+    showLogout("网络异常,请重新登录!");
+    return;
+  }
+
+  await getRemainTimeFromServer();
+});
+
+// 离开此页面时,可能还有心跳请求未返回
+onUnmounted(() => cancelHeartBeat && cancelHeartBeat());
+
+// methods: {
+//   ...mapMutations(["setShouldSubmitPaper", "updateRemainTime"]),
+
+const CancelToken = axios.CancelToken;
+let cancelHeartBeat: Canceler;
+async function getRemainTimeFromServer() {
+  try {
+    logger({ cnl: ["server"], act: "发出心跳" });
+    const res = await httpApp.get(
+      "/api/ecs_oe_student/examControl/examHeartbeat",
+      {
+        "axios-retry": {
+          retries: 10,
+          retryDelay: () => 10 * 1000,
+        },
+        noErrorMessage: true,
+        cancelToken: new CancelToken(function executor(c) {
+          cancelHeartBeat = c;
+        }),
+      }
+    );
+    const rt: number = res.data;
+
+    if (typeof rt !== "number") {
+      logger({
+        cnl: ["server"],
+        key: "不可能的事情发生了",
+        dtl: "服务器返回的心跳结果不是数字",
+        ext: { remainTime: rt },
+      });
+      return;
+    }
+
+    logger({
+      pgu: "AUTO",
+      cnl: ["server"],
+      act: "重置剩余时间:",
+      ext: { remainTime: res.data, diff: remainTime && remainTime - res.data },
+    });
+    remainTime = rt;
+  } catch (error) {
+    const descMaybe: string | undefined = (<any>error).response?.data?.desc;
+    if (descMaybe) {
+      $message.error(descMaybe);
+      logger({ cnl: ["server"], pgu: "AUTO", act: "心跳失败", dtl: descMaybe });
+    }
+    logger({
+      cnl: ["server"],
+      pgu: "AUTO",
+      act: "心跳失败",
+      dtl: descMaybe,
+      possibleError: error,
+    });
+
+    $dialog.error({
+      title: "网络连接异常",
+      content: "退出考试",
+      maskClosable: false,
+      closable: false,
+      positiveText: "确定",
+      onPositiveClick: () => {
+        logger({
+          cnl: ["server", "local"],
+          pgu: "AUTO",
+          act: "用户点击对话框退出",
+        });
+        showLogout("网络连接异常,退出考试");
+      },
+    });
+
+    clearInterval(updateTimeFromServerId);
+    clearInterval(updateRemainTimeId);
+
+    addTimeout(() => {
+      logger({ cnl: ["server", "local"], pgu: "AUTO", act: "90秒后自动退出" });
+      $dialog.destroyAll();
+      showLogout("网络连接异常,退出考试");
+    }, 90 * 1000);
+  }
+}
+</script>
+
+<template>
+  <div class="remain-time">
+    <span style="font-size: 14px">剩余时间</span><br />
+    <span
+      class="enhanced-remain-time"
+      :class="[enhancedRemainTimeStyle && 'animated infinite pulse']"
+    >
+      {{ remainTimeFormatted }}
+    </span>
+  </div>
+</template>
+
+<style scoped>
+.remain-time {
+  font-size: 25px;
+}
+
+.enhanced-remain-time {
+  color: red;
+}
+
+.animated {
+  animation-duration: 1s;
+  animation-fill-mode: both;
+  display: inline-block;
+}
+
+.animated.infinite {
+  animation-iteration-count: infinite;
+}
+
+@keyframes pulse {
+  from {
+    transform: scale3d(1, 1, 1);
+  }
+
+  50% {
+    transform: scale3d(1.05, 1.05, 1.05);
+  }
+
+  to {
+    transform: scale3d(1, 1, 1);
+  }
+}
+
+.pulse {
+  animation-name: pulse;
+}
+</style>

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

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

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

@@ -21,7 +21,7 @@ import { getCapturePhotoYunSign, saveCapturePhoto } from "@/api/login";
 // eslint-disable-next-line vue/no-setup-props-destructure
 const {
   width = 400,
-  height = 3000,
+  height = 300,
   snapNow = false,
   // snapId = 0,
 } = defineProps<{

+ 1 - 1
src/features/OnlinePractice/ExamPaperPreview.vue

@@ -125,7 +125,7 @@ async function getQuestionWrapperMapData(
     });
 
     examQuestionWrapperMap[q.id] = Object.assign(
-      {},
+      { limitedPlayTimes: 0, questionUnitWrapperList: [] },
       { examQuestionList: questionList, questionId: q.id },
       q.masterVersion
     );

+ 51 - 29
src/features/UserLogin/useRemoteAppChecker.ts

@@ -10,6 +10,11 @@ import { watch } from "vue";
 export function useRemoteAppChecker() {
   /** 检测出错则提示;检测通过则将禁用登录按钮的flag置为false */
   async function checkRemoteAppTxt() {
+    if (
+      !QECSConfig.PREVENT_CHEATING_CONFIG.includes("DISABLE_REMOTE_ASSISTANCE")
+    )
+      return;
+
     let applicationNames;
     try {
       const fs: typeof import("fs") = window.nodeRequire("fs");
@@ -38,6 +43,7 @@ export function useRemoteAppChecker() {
       $message.error("系统检测出错(e-01),请退出程序后重试!", {
         duration: 24 * 60 * 60 * 1000,
       });
+      disableLoginBtnBecauseRemoteApp = true;
       return;
     }
 
@@ -70,46 +76,62 @@ export function useRemoteAppChecker() {
       $message.info("在考试期间,请关掉" + names + "软件,诚信考试。", {
         duration: 24 * 60 * 60 * 1000,
       });
+      logger({
+        cnl: ["local", "server"],
+        key: "checkRemoteAppTxt",
+        pgu: "AUTO",
+        dtl: "在考试期间,请关掉" + names + "软件,诚信考试。",
+      });
     }
   }
 
   let disableLoginBtnBecauseRemoteApp = $ref(true);
 
   const QECSConfig = $computed(() => store.QECSConfig);
-  watch(QECSConfig, async () => {
-    if (
-      !QECSConfig.PREVENT_CHEATING_CONFIG.includes("DISABLE_REMOTE_ASSISTANCE")
-    ) {
-      disableLoginBtnBecauseRemoteApp = false;
-      return;
-    }
-
-    if (import.meta.env.DEV) return;
+  watch(
+    QECSConfig,
+    async () => {
+      if (import.meta.env.DEV) {
+        disableLoginBtnBecauseRemoteApp = false;
+        return;
+      }
+      if (
+        !QECSConfig.PREVENT_CHEATING_CONFIG.includes(
+          "DISABLE_REMOTE_ASSISTANCE"
+        )
+      ) {
+        disableLoginBtnBecauseRemoteApp = false;
+        return;
+      }
 
-    let exe = "Project1.exe";
-    if (fileExists("Project2.exe")) {
-      const remoteAppName = REMOTE_APP_NAME;
-      exe = `Project2.exe "${remoteAppName}" `;
-    }
+      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 {
-      fileExists("remoteApplication.txt") &&
-        fs.unlinkSync("remoteApplication.txt");
-    } catch (error) {
-      console.log(error);
-      logger({
-        cnl: ["local", "server"],
-        key: "checkRemoteAppTxt",
-        dtl: "unlink remoteApplication.txt 失败",
-      });
-    }
-    await execLocal(exe);
+      const fs: typeof import("fs") = window.nodeRequire("fs");
+      try {
+        fileExists("remoteApplication.txt") &&
+          fs.unlinkSync("remoteApplication.txt");
+      } catch (error) {
+        console.log(error);
+        logger({
+          cnl: ["local", "server"],
+          pgu: "AUTO",
+          key: "checkRemoteAppTxt",
+          dtl: "unlink remoteApplication.txt 失败",
+        });
+      }
+      await execLocal(exe);
 
-    await checkRemoteAppTxt();
-  });
+      await checkRemoteAppTxt();
+    },
+    { immediate: true }
+  );
 
   return {
     disableLoginBtnBecauseRemoteApp: $$(disableLoginBtnBecauseRemoteApp),
+    checkRemoteAppTxt,
   };
 }

+ 12 - 0
src/router/index.ts

@@ -2,6 +2,8 @@ import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
 import UserLogin from "@/features/UserLogin/UserLogin.vue";
 import MainLayout from "@/components/MainLayout/MainLayout.vue";
 import OnlineExam from "@/features/OnlineExam/OnlineExamHome.vue";
+import ExamingHome from "@/features/OnlineExam/Examing/ExamingHome.vue";
+import SubmitPaper from "@/features/OnlineExam/Examing/SubmitPaper.vue";
 import OnlineExamOverview from "@/features/OnlineExam/OnlineExamOverview/OnlineExamOverview.vue";
 import WelcomePage from "@/features/WelcomePage/WelcomePage.vue";
 import ChangePassword from "@/features/ChangePassword/ChangePassword.vue";
@@ -72,6 +74,16 @@ const routes: RouteRecordRaw[] = [
     name: "OnlineExamOverview",
     component: OnlineExamOverview,
   },
+  {
+    path: "/online-exam/exam/:examId/examRecordData/:examRecordDataId/order/:order",
+    name: "OnlineExamingHome",
+    component: ExamingHome,
+  },
+  {
+    path: "/online-exam/exam/:examId/examRecordData/:examRecordDataId/submit",
+    name: "SubmitPaper",
+    component: SubmitPaper,
+  },
   {
     path: "/:pathMatch(.*)*",
     name: "NotFound",

+ 39 - 0
src/setups/useTimers.ts

@@ -11,24 +11,63 @@ export function useTimers() {
    * 会在 beforeDestory 中自动清除
    * @param {function} fn 要执行的函数
    * @param {number} interval 执行间隔ms
+   *
+   * @returns intervalId 供主动清除
    */
   // eslint-disable-next-line @typescript-eslint/ban-types
   function addInterval(fn: Function, interval: number) {
     const i = setInterval(fn, interval);
     mixin__intervals.push(i);
+    logger({
+      cnl: ["server", "console"],
+      pgu: "AUTO",
+      lvl: "debug",
+      act: "addInterval",
+      ext: {
+        mixin__intervals,
+        mixin__timeouts,
+      },
+    });
+
+    return i;
   }
   /**
    * 会在 beforeDestory 中自动清除
    * @param {function} fn 要执行的函数
    * @param {number} timeout 触发时间ms
+   *
+   * * @returns timeoutId 供主动清除
    */
   // eslint-disable-next-line @typescript-eslint/ban-types
   function addTimeout(fn: Function, timeout: number) {
     const i = setTimeout(fn, timeout);
     mixin__timeouts.push(i);
+
+    logger({
+      cnl: ["server", "console"],
+      pgu: "AUTO",
+      lvl: "debug",
+      act: "addTimeout",
+      ext: {
+        mixin__intervals,
+        mixin__timeouts,
+      },
+    });
+
+    return i;
   }
 
   onUnmounted(() => {
+    logger({
+      cnl: ["server", "console"],
+      pgu: "AUTO",
+      lvl: "debug",
+      act: "clearTimers",
+      ext: {
+        mixin__intervals,
+        mixin__timeouts,
+      },
+    });
     for (const i of mixin__intervals) {
       clearInterval(i);
     }

+ 25 - 4
src/types/student-client.d.ts

@@ -128,6 +128,10 @@ export type Store = {
     examRecordDataId: number;
     /** 考试批次id */
     examId: number;
+    /** 当前试题序号,起始为1,初始为1. 和route里面的order不好算优先级和时机,暂时不用这个字段了 */
+    // order: number;
+    /** 考试类型 */
+    examType: ExamType;
     /** 课程名称 */
     courseName: string;
     /** 是否开启微信作答 */
@@ -146,13 +150,22 @@ export type Store = {
     /** 抓拍间隔(秒) */
     SNAPSHOT_INTERVAL: number;
     /** 考试冻结(秒) */
-    FREEZE_TIME: number;
+    freezeTime: number;
+    /** 考试时长(秒) */
+    duration: number;
+    /** 考试剩余时间(毫秒ms) */
+    remainTime: number;
+    /** 是否开启微信作答方式 */
+    WEIXIN_ANSWER_ENABLED: boolean;
     /** 练习显示答案的类型 IN_PRACTICE:在练习过程中显示答案  NO_ANSWER:练习过程中不显示答案 */
-    PRACTICE_TYPE: "IN_PRACTICE" | "NO_ANSWER";
+    practiceType: "IN_PRACTICE" | "NO_ANSWER";
     /** 试卷结构 */
     paperStruct: PaperStruct;
     /** 试题结构 */
     examQuestionList: ExamQuestion[];
+    /** 试题过滤类型 */
+    questionFilterType: "ALL" | "ANSWERED" | "SIGNED" | "UNANSWERED";
+    allAudioPlayTimes: { audioName: string; times: number }[];
   };
   // /** 考试中的状态 */
   // examing: {};
@@ -162,6 +175,7 @@ export type Store = {
     stream: MediaStream | null;
   };
   globalMaskCount: 0;
+  spinMessage?: string;
 };
 
 type SchoolDomain = `${string}.ecs.qmth.com.cn`;
@@ -339,6 +353,9 @@ export type QuestionWrapperItem = {
   examQuestionList: ExamQuestion[];
   /** 试题小题的内容列表,一般应该是用不到的 */
   questionUnitList: QuestionUnitItem[];
+  /** 小题的列表,学生端用不着,只用到它的length */
+  questionUnitWrapperList: Array<{ id: string }>;
+  limitedPlayTimes: number;
 };
 
 export type PaperStruct = {
@@ -378,8 +395,8 @@ export type ExamQuestion = {
   groupName: string;
   /** 什么的总分??? */
   groupTotal: number;
-  /** 是否被标上星号 */
-  isStarred: boolean;
+  /** 是否被标上星号。 TODO: 改名为 isStarred */
+  isSign: boolean;
   /** 大题号 */
   mainNumber: number;
   /** 小题号 */
@@ -398,6 +415,10 @@ export type ExamQuestion = {
   hasAudio: boolean;
   /** 试题内容。通过网络获取。 */
   questionContent: string;
+  /** 试题内容是否已通过网络获取到。 TODO: 改名为 gotQuestionContent */
+  getQuestionContent: boolean;
+  /** 答案是否已经被用户更新过了 */
+  dirty: boolean;
   /** 只有第一题有此数据,用来像服务器保存音频播放次数 */
   audioPlayTimes: { audioName: string; times: number }[];
 };

+ 1 - 1
src/utils/logger.ts

@@ -97,7 +97,7 @@ export default function createLog(detail: LogDetail) {
     possibleErrorFields
   );
   // FIXME: 后期设置条件开启非log级别的日志,此时全部打回。
-  if (newDetail.lvl !== "log") {
+  if (import.meta.env.PROD && newDetail.lvl !== "log") {
     return;
   }
   if (detail.cnl?.includes("console")) {