Michael Wang преди 3 години
родител
ревизия
0fc20532cb

+ 1 - 0
.eslintrc.js

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

+ 1 - 0
src/api/login.ts

@@ -47,6 +47,7 @@ export async function getCapturePhotoYunSign(signParams: object) {
   return httpApp.get<{
   return httpApp.get<{
     formUrl: string;
     formUrl: string;
     accessUrl: string;
     accessUrl: string;
+    signIdentifier: string;
     formParams: object;
     formParams: object;
   }>("/api/ecs_oe_student/examControl/getCapturePhotoYunSign", {
   }>("/api/ecs_oe_student/examControl/getCapturePhotoYunSign", {
     params: signParams,
     params: signParams,

+ 10 - 0
src/constants/constants.ts

@@ -1,4 +1,14 @@
 export const YYYYMMDDHHmmss = "YYYY-MM-DD HH:mm:ss";
 export const YYYYMMDDHHmmss = "YYYY-MM-DD HH:mm:ss";
+export const WEEKDAY_NAMES: Record<number, string> = {
+  1: "一",
+  2: "二",
+  3: "三",
+  4: "四",
+  5: "五",
+  6: "六",
+  7: "日",
+};
+
 const env = import.meta.env;
 const env = import.meta.env;
 export const VITE_SLS_STORE_NAME = env.VITE_SLS_STORE_NAME as string;
 export const VITE_SLS_STORE_NAME = env.VITE_SLS_STORE_NAME as string;
 export const VITE_CONFIG_FILE_SEVER_URL = env.VITE_CONFIG_FILE_SEVER_URL;
 export const VITE_CONFIG_FILE_SEVER_URL = env.VITE_CONFIG_FILE_SEVER_URL;

+ 993 - 0
src/features/OnlineExam/CheckComputer.vue

@@ -0,0 +1,993 @@
+<script setup lang="ts">
+import moment from "moment";
+import VueQrcode from "@chenfengyuan/vue-qrcode";
+import { onMounted, onUnmounted } from "vue";
+import { useTimers } from "@/setups/useTimers";
+// import PulseLoader from "vue-spinner/src/PulseLoader.vue";
+// import {
+//   openWS,
+//   closeWsWithoutReconnect,
+// } from "@/features/OnlineExam/Examing/ws";
+
+// FIXME: 开启摄像头和websocket库
+
+const emit = defineEmits<{ (e: "on-close"): void }>();
+
+const show = $ref(true);
+// const CLOCK_RATE_TIMEOUT = 10;
+
+let current = $ref(1);
+// @ts-expect-error chrome支持,但还有浏览器不支持,所以没进类型定义
+let downlink = navigator.connection.downlink;
+// @ts-expect-error
+let rtt = navigator.connection.rtt;
+const network = $ref({
+  downlink,
+  downlinkStatus: downlink > 0.5,
+  rrt: rtt,
+  rrtStatus: rtt < 1000,
+});
+
+const time = $ref({
+  currentTimeZone: moment().format("Z"),
+  timeZoneStatus: new Date().getTimezoneOffset() / 60 === -8,
+  clockRateDiff: null,
+  clockRateStateResolved: false,
+  clockRateStatus: false,
+});
+
+const camera = $ref({
+  openCameraResolved: false,
+  openCameraStatus: false,
+  identityStatus: false,
+  identityResolved: false,
+});
+
+const sound = $ref({
+  downloadResolved: false,
+  downloadStatus: false,
+  playedStatusResolved: false,
+  playedStatus: false,
+});
+const wechat = $ref({
+  qrValue: " ",
+  qrScannedResolved: false,
+  qrScanned: false,
+  uploadResolved: false,
+  uploadStatus: false,
+  studentAnswer: null,
+  examRecordDataId: null,
+});
+
+// ...mapState([
+//   "questionQrCode",
+//   "questionQrCodeScanned",
+//   "questionAnswerFileUrl",
+// ]),
+
+// timeCurrent() {
+//   return moment(this.nowDate)
+//     .utcOffset("+08:00")
+//     .format("YYYY-MM-DD HH:mm:ssZZ");
+// },
+
+const step1Status = $computed(() => {
+  return network.downlinkStatus && network.rrtStatus;
+});
+const step2StatusResolved = $computed(() => {
+  return time.clockRateStateResolved;
+});
+const step2Status = $computed(() => {
+  return time.timeZoneStatus && time.clockRateStatus;
+});
+const step3StatusResolved = $computed(() => {
+  return camera.identityResolved && camera.openCameraResolved;
+});
+const step3Status = $computed(() => {
+  return camera.identityStatus && camera.openCameraStatus;
+});
+const step4StatusResolved = $computed(() => {
+  return sound.downloadResolved && sound.playedStatusResolved;
+});
+const step4Status = $computed(() => {
+  return sound.downloadStatus && sound.playedStatus;
+});
+const step5StatusResolved = $computed(() => {
+  return wechat.qrScannedResolved && wechat.uploadResolved;
+});
+const step5Status = $computed(() => {
+  return wechat.qrScanned && wechat.uploadStatus;
+});
+
+// watch(
+//   () => questionQrCodeScanned,
+//   () => {
+//     this.wechat.qrScanned = true;
+//     this.wechat.qrScannedResolved = true;
+//   }
+// );
+// watch(
+//   () => questionAnswerFileUrl,
+//   (value) => {
+//     const examRecordDataId = this.wechat.examRecordDataId;
+//     for (const q of value) {
+//       if (!q.saved) {
+//         let acknowledgeStatus = "CONFIRMED";
+
+//         this.$http
+//           .post(
+//             "/api/ecs_oe_student/examControl/saveUploadedFileAcknowledgeStatus",
+//             {
+//               examRecordDataId,
+//               filePath: q.fileUrl,
+//               order: q.order,
+//               acknowledgeStatus,
+//             }
+//           )
+//           .then(() => {
+//             this.wechat.studentAnswer = q.fileUrl;
+//             this.wechat.uploadResolved = true;
+//             this.wechat.uploadStatus = true;
+//             q.saved = true;
+//             if (acknowledgeStatus === "CONFIRMED")
+//               this.$Message.info({
+//                 content: "小程序作答已更新",
+//                 duration: 5,
+//                 closable: true,
+//               });
+//           })
+//           .catch(() => {
+//             this.$Message.error({
+//               content: "更新小程序答案失败!",
+//               duration: 15,
+//               closable: true,
+//             });
+//           });
+//       }
+//     }
+//   }
+// );
+
+let nowDate: number = $ref(0);
+const { addInterval } = useTimers();
+addInterval(() => (nowDate = Date.now()), 1000);
+console.log(nowDate);
+
+onMounted(async () => {
+  // openWS({});
+  // const fetchQR = async () => {
+  //   const examRecordDataId = this.$store.state.user.id;
+  //   const response = await this.$http.post(
+  //     "/api/ecs_oe_student/examControl/getQrCode",
+  //     {
+  //       examRecordDataId,
+  //       order: 1,
+  //       transferFileType: "AUDIO",
+  //       testEnv: true,
+  //     }
+  //   );
+  //   this.wechat.qrValue = response.data;
+  //   const trueExamRecordDataId = decodeURIComponent(response.data).match(
+  //     /&examRecordDataId=(\d+)/
+  //   )[1];
+  //   this.wechat.examRecordDataId = trueExamRecordDataId;
+  // };
+  // if (this.wechat.qrValue) {
+  //   this.getQRCodeTimeout = setTimeout(() => {
+  //     fetchQR();
+  //   }, 3000);
+  // }
+  // let start, end;
+  // fetch("/oe-web/login", { Method: "HEAD" }).then((e) => {
+  //   start = moment(e.headers.get("date"));
+  // });
+  // this.checkClockRateTimeout = setTimeout(() => {
+  //   fetch("/oe-web/login", { Method: "HEAD" }).then((e) => {
+  //     // 可能已经离开这个页面了
+  //     if (!this.time) return;
+  //     end = moment(e.headers.get("date"));
+  //     this.time.clockRateStateResolved = true;
+  //     this.time.clockRateDiff = end.diff(start, "seconds") - CLOCK_RATE_TIMEOUT;
+  //     this.time.clockRateStatus =
+  //       end.diff(start, "seconds") < CLOCK_RATE_TIMEOUT + 2;
+  //   });
+  // }, CLOCK_RATE_TIMEOUT * 1000);
+  // await this.openCamera();
+});
+
+onUnmounted(() => {
+  // clearTimeout(this.checkClockRateTimeout);
+  // clearTimeout(this.getQRCodeTimeout);
+  // if (this.$refs.video.srcObject) {
+  //   this.$refs.video.srcObject.getTracks().forEach(function (track) {
+  //     track.stop();
+  //   });
+  //   this.$refs.video.srcObject = null;
+  // }
+  // closeWsWithoutReconnect();
+});
+
+function previous() {
+  if (current > 1) {
+    current -= 1;
+  }
+}
+function next() {
+  if (current < 6) {
+    current += 1;
+  }
+  if (current === 6) {
+    // window._hmt.push([
+    //   "_trackEvent",
+    //   "环境检测",
+    //   `网络: ${this.step1Status}; 时间: ${this.step2Status}; 摄像头: ${this.step3Status}; 声音: ${this.step4Status}; 小程序: ${this.step5Status};`,
+    // ]);
+  }
+}
+
+// async function openCamera() {
+// const video = this.$refs.video;
+// if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
+//   try {
+//     console.log("启动摄像头");
+//     const stream = await navigator.mediaDevices.getUserMedia({
+//       video: {
+//         facingMode: "user",
+//         resizeMode: "crop-and-scale",
+//         width: 400,
+//         height: 300,
+//       },
+//     });
+//     if (stream) {
+//       video.srcObject = stream;
+//       try {
+//         await video.play();
+//         this.camera.openCameraStatus = true;
+//       } catch (error) {
+//         console.log("摄像头没有正常启用", error);
+//         this.$Message.error({
+//           content: "摄像头没有正常启用: " + error,
+//           duration: 15,
+//           closable: true,
+//         });
+//         window._hmt.push([
+//           "_trackEvent",
+//           "摄像头框-环境检测",
+//           "摄像头状态",
+//           "摄像头没有正常启用: " + error,
+//         ]);
+//       }
+//     } else {
+//       this.$Message.error({
+//         content: "没有可用的视频流",
+//         duration: 15,
+//         closable: true,
+//       });
+//       window._hmt.push([
+//         "_trackEvent",
+//         "摄像头框-环境检测",
+//         "摄像头状态",
+//         "没有可用的视频流",
+//       ]);
+//     }
+//   } catch (error) {
+//     console.log("无法启用摄像头", error);
+//     let errMsg;
+//     if (error.name || error.message) {
+//       errMsg = `${error.name} ${error.message}`;
+//     } else {
+//       errMsg = error;
+//     }
+//     this.$Message.error({
+//       content: "无法启用摄像头: " + errMsg,
+//       duration: 15,
+//       closable: true,
+//     });
+//     window._hmt.push([
+//       "_trackEvent",
+//       "摄像头框-环境检测",
+//       "摄像头状态",
+//       "无法启用摄像头" + errMsg,
+//     ]);
+//   } finally {
+//     this.camera.openCameraResolved = true;
+//   }
+// } else {
+//   this.$Message.error({
+//     content: "没有找到可用的摄像头",
+//     duration: 15,
+//     closable: true,
+//   });
+//   window._hmt.push([
+//     "_trackEvent",
+//     "摄像头框-环境检测",
+//     "摄像头状态",
+//     "没有找到可用的摄像头",
+//   ]);
+// }
+// this.camera.openCameraResolved = true;
+// }
+</script>
+
+<template>
+  <n-modal
+    title="环境检测"
+    :closable="false"
+    :show="show"
+    preset="card"
+    style="width: 800px"
+  >
+    <div style="max-width: 800px; margin: 30px auto">
+      <n-steps :current="current" size="small">
+        <n-step title="网速"></n-step>
+        <n-step title="时钟"></n-step>
+        <n-step title="摄像头"></n-step>
+        <n-step title="声音"></n-step>
+        <n-step title="微信小程序"></n-step>
+        <n-step title="检测结果"></n-step>
+      </n-steps>
+
+      <div v-if="current === 1" key="1" class="section">
+        <div class="list">
+          <table>
+            <tbody class="list-row">
+              <tr class="list-header qm-primary-strong-text">
+                <td class="first-td">检查项</td>
+                <td>值</td>
+                <td>状态</td>
+              </tr>
+
+              <tr>
+                <td>电脑当前下载速度</td>
+                <td>{{ network.downlink }}Mb</td>
+                <td>
+                  <div v-if="network.downlinkStatus">
+                    <n-icon class="pass-check" type="md-checkmark" />
+                  </div>
+                  <div v-else>
+                    <n-icon
+                      class="fail-cross"
+                      title="下载速度不佳"
+                      type="md-close"
+                    />
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <td>电脑当前网络延迟</td>
+                <td>{{ network.rrt }}毫秒</td>
+                <td>
+                  <div v-if="network.rrtStatus">
+                    <n-icon class="pass-check" type="md-checkmark" />
+                  </div>
+                  <div v-else>
+                    <n-icon
+                      class="fail-cross"
+                      title="网络延迟较大"
+                      type="md-close"
+                    />
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <div v-if="current === 2" key="2" class="section">
+        <div class="list">
+          <table>
+            <tbody class="list-row">
+              <tr class="list-header qm-primary-strong-text">
+                <td class="first-td">检查项</td>
+                <td>值</td>
+                <td>状态</td>
+              </tr>
+
+              <!-- <tr>
+              <td>电脑当前时间</td>
+              <td>{{ timeCurrent }} (北京时间)</td>
+              <td></td>
+            </tr> -->
+              <!-- <tr>
+              <td>电脑是否时间准确</td>
+              <td>{{ timeDifference }}</td>
+            </tr> -->
+              <tr>
+                <td>电脑时区</td>
+                <td>{{ time.currentTimeZone }}</td>
+                <td>
+                  <div v-if="time.timeZoneStatus">
+                    <n-icon class="pass-check" type="md-checkmark" />
+                  </div>
+                  <div v-else>
+                    <n-icon
+                      class="fail-cross"
+                      title="请将电脑设置为北京时区"
+                      type="md-close"
+                    />
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <td>电脑时钟频率</td>
+                <td>
+                  <div v-if="time.clockRateStateResolved">
+                    {{ (time.clockRateDiff ?? 0) > 3 ? "时钟过慢" : "正常" }}
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+                <td>
+                  <div v-if="time.clockRateStateResolved">
+                    <n-icon
+                      v-if="time.clockRateStatus"
+                      class="pass-check"
+                      type="md-checkmark"
+                    />
+                    <n-icon
+                      v-else
+                      class="fail-cross"
+                      title="请更换电脑"
+                      type="md-close"
+                    />
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <div v-show="current === 3" key="3" class="section">
+        <div>
+          <div style="display: flex">
+            <video
+              id="video"
+              ref="video"
+              width="400"
+              height="300"
+              autoplay
+            ></video>
+
+            <div
+              v-if="camera.openCameraResolved && camera.openCameraStatus"
+              style="margin-left: 50px; margin-top: 100px"
+            >
+              <n-button
+                type="warning"
+                @click="
+                  camera.identityResolved = true;
+                  camera.identityStatus = false;
+                "
+              >
+                图像中不是电脑操作者本人
+              </n-button>
+              <div style="width: 30px; height: 30px"></div>
+              <n-button
+                type="primary"
+                @click="
+                  camera.identityResolved = true;
+                  camera.identityStatus = true;
+                "
+              >
+                图像中是电脑操作者本人&nbsp;&nbsp;&nbsp;&nbsp;
+              </n-button>
+            </div>
+          </div>
+        </div>
+
+        <div class="list">
+          <table>
+            <tbody class="list-row">
+              <tr class="list-header qm-primary-strong-text">
+                <td class="first-td">检查项</td>
+                <td>值</td>
+                <td>状态</td>
+              </tr>
+
+              <tr>
+                <td>摄像头正常启用</td>
+                <td>
+                  <div v-if="camera.openCameraResolved">
+                    {{ camera.openCameraStatus ? "正常" : "请检查摄像头" }}
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+                <td>
+                  <div v-if="camera.openCameraResolved">
+                    <div v-if="camera.openCameraStatus">
+                      <n-icon class="pass-check" type="md-checkmark" />
+                    </div>
+                    <div v-else>
+                      <n-icon
+                        class="fail-cross"
+                        title="请检查摄像头"
+                        type="md-close"
+                      />
+                    </div>
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <td>视频显示的是电脑操作者本人</td>
+                <td>
+                  <div
+                    v-if="
+                      (camera.openCameraResolved && !camera.openCameraStatus) ||
+                      camera.identityResolved
+                    "
+                  >
+                    {{ camera.identityStatus ? "正常" : "请检查摄像头" }}
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+                <td>
+                  <div
+                    v-if="
+                      (camera.openCameraResolved && !camera.openCameraStatus) ||
+                      camera.identityResolved
+                    "
+                  >
+                    <div v-if="camera.identityStatus">
+                      <n-icon class="pass-check" type="md-checkmark" />
+                    </div>
+                    <div v-else>
+                      <n-icon
+                        class="fail-cross"
+                        title="请检查摄像头"
+                        type="md-close"
+                      />
+                    </div>
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <div
+        v-show="current === 4"
+        key="4"
+        class="section"
+        style="text-align: center"
+      >
+        <div>
+          <div style="display: flex; margin-bottom: 30px">
+            <audio
+              src="https://ecs-static.qmth.com.cn/check-audio.mp3"
+              controls
+              nodownload
+              @loadeddata="
+                sound.downloadResolved = true;
+                sound.downloadStatus = true;
+              "
+              @error="
+                sound.downloadResolved = true;
+                sound.downloadStatus = false;
+              "
+            />
+
+            <div style="margin-left: 30px; display: flex">
+              <n-button
+                type="warning"
+                title="或者听不到声音"
+                @click="
+                  sound.playedStatusResolved = true;
+                  sound.playedStatus = false;
+                "
+              >
+                不能播放声音
+              </n-button>
+              <div style="width: 30px; height: 30px"></div>
+              <n-button
+                type="primary"
+                @click="
+                  sound.playedStatusResolved = true;
+                  sound.playedStatus = true;
+                "
+              >
+                能够播放声音
+              </n-button>
+            </div>
+          </div>
+        </div>
+
+        <div class="list">
+          <table>
+            <tbody class="list-row">
+              <tr class="list-header qm-primary-strong-text">
+                <td class="first-td">检查项</td>
+                <td>值</td>
+                <td>状态</td>
+              </tr>
+
+              <tr>
+                <td>文件下载</td>
+                <td>
+                  <div v-if="sound.downloadResolved">
+                    {{ sound.downloadStatus ? "正常" : "出错" }}
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+                <td>
+                  <div v-if="sound.downloadResolved">
+                    <div v-if="sound.downloadStatus">
+                      <n-icon class="pass-check" type="md-checkmark" />
+                    </div>
+                    <div v-else>
+                      <n-icon
+                        class="fail-cross"
+                        title="下载出错"
+                        type="md-close"
+                      />
+                    </div>
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <td>声音播放</td>
+                <td>
+                  <div v-if="sound.playedStatusResolved">
+                    {{ sound.playedStatus ? "正常" : "出错" }}
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+                <td>
+                  <div v-if="sound.playedStatusResolved">
+                    <div v-if="sound.playedStatus">
+                      <n-icon class="pass-check" type="md-checkmark" />
+                    </div>
+                    <div v-else>
+                      <n-icon
+                        class="fail-cross"
+                        title="不能播放声音"
+                        type="md-close"
+                      />
+                    </div>
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <div v-show="current === 5" key="5" class="section">
+        <div>
+          <div style="display: flex">
+            <div>
+              <div v-if="wechat.qrValue" style="display: flex">
+                <VueQrcode
+                  :value="wechat.qrValue"
+                  :options="{ width: 200 }"
+                  style="margin-left: -10px"
+                ></VueQrcode>
+                <div style="margin-top: 10px">
+                  <div style="font-size: 30px">
+                    请使用<span style="font-weight: 900; color: #1e90ff"
+                      >微信</span
+                    >扫描二维码后,在微信小程序上录音,并上传文件。
+                  </div>
+                  <div
+                    v-if="wechat.qrScanned"
+                    style="margin-top: 30px; font-size: 30px"
+                  >
+                    {{ wechat.studentAnswer ? "已上传" : "已扫描" }}
+                    <n-icon type="md-checkmark" />
+                  </div>
+                </div>
+              </div>
+              <div v-else>正在获取二维码...</div>
+            </div>
+          </div>
+
+          <div
+            class="audio-answer audio-answer-line-height"
+            style="margin-top: 20px; text-align: left"
+          >
+            <span class="audio-answer-line-height">上传文件:</span>
+            <audio
+              v-if="wechat.studentAnswer"
+              class="audio-answer-line-height"
+              controls
+              controlsList="nodownload"
+              :src="wechat.studentAnswer"
+            />
+            <span v-else class="audio-answer-line-height">未上传文件</span>
+          </div>
+
+          <div style="margin-top: 30px; display: flex; margin-bottom: 30px">
+            <n-button
+              type="warning"
+              title="扫码不成功"
+              @click="
+                wechat.qrScannedResolved = true;
+                wechat.qrScanned = false;
+              "
+            >
+              不能正确扫描二维码
+            </n-button>
+            <div style="width: 30px; height: 30px"></div>
+            <n-button
+              type="warning"
+              title="上传不成功"
+              @click="
+                wechat.uploadResolved = true;
+                wechat.uploadStatus = false;
+              "
+            >
+              上传不成功
+            </n-button>
+          </div>
+        </div>
+
+        <div class="list">
+          <table>
+            <tbody class="list-row">
+              <tr class="list-header qm-primary-strong-text">
+                <td class="first-td">检查项</td>
+                <td>值</td>
+                <td>状态</td>
+              </tr>
+
+              <tr>
+                <td>扫描二维码</td>
+                <td>
+                  <div v-if="wechat.qrScannedResolved">
+                    {{ wechat.qrScanned ? "正常" : "出错" }}
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+                <td>
+                  <div v-if="wechat.qrScannedResolved">
+                    <div v-if="wechat.qrScanned">
+                      <n-icon class="pass-check" type="md-checkmark" />
+                    </div>
+                    <div v-else>
+                      <n-icon
+                        class="fail-cross"
+                        title="扫描出错"
+                        type="md-close"
+                      />
+                    </div>
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+              </tr>
+
+              <tr>
+                <td>上传录音</td>
+                <td>
+                  <div v-if="wechat.uploadResolved">
+                    {{ wechat.uploadStatus ? "正常" : "出错" }}
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+                <td>
+                  <div v-if="wechat.uploadResolved">
+                    <div v-if="wechat.uploadStatus">
+                      <n-icon class="pass-check" type="md-checkmark" />
+                    </div>
+                    <div v-else>
+                      <n-icon class="fail-cross" type="md-close" />
+                    </div>
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <div v-show="current === 6" key="6" class="section">
+        <div class="list">
+          <table>
+            <tbody class="list-row">
+              <tr class="list-header qm-primary-strong-text">
+                <td class="first-td">检查项</td>
+                <td>结果</td>
+              </tr>
+
+              <tr>
+                <td>网速</td>
+                <td>
+                  <div v-if="step1Status">
+                    <n-icon class="pass-check" type="md-checkmark" />
+                  </div>
+                  <div v-else>
+                    <n-icon class="fail-cross" type="md-close" />
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <td>时钟</td>
+                <td>
+                  <div v-if="step2StatusResolved">
+                    <div v-if="step2Status">
+                      <n-icon class="pass-check" type="md-checkmark" />
+                    </div>
+                    <div v-else>
+                      <n-icon class="fail-cross" type="md-close" />
+                    </div>
+                  </div>
+                  <div v-else>
+                    <n-spin size="medium" />
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <td>摄像头</td>
+                <td>
+                  <div v-if="step3StatusResolved">
+                    <div v-if="step3Status">
+                      <n-icon class="pass-check" type="md-checkmark" />
+                    </div>
+                    <div v-else>
+                      <n-icon class="fail-cross" type="md-close" />
+                    </div>
+                  </div>
+                  <div v-else class="fail-cross">
+                    请在“摄像头”步骤进行人工确认!
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <td>声音</td>
+                <td>
+                  <div v-if="step4StatusResolved">
+                    <div v-if="step4Status">
+                      <n-icon class="pass-check" type="md-checkmark" />
+                    </div>
+                    <div v-else>
+                      <n-icon class="fail-cross" type="md-close" />
+                    </div>
+                  </div>
+                  <div v-else class="fail-cross">
+                    请在“声音”步骤进行人工确认!
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <td>微信小程序</td>
+                <td>
+                  <div v-if="step5StatusResolved">
+                    <div v-if="step5Status">
+                      <n-icon class="pass-check" type="md-checkmark" />
+                    </div>
+                    <div v-else>
+                      <n-icon class="fail-cross" type="md-close" />
+                    </div>
+                  </div>
+                  <div v-else class="fail-cross">
+                    请在“微信小程序”步骤进行人工确认!
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+
+        <div style="color: red">
+          <div v-if="!step1Status" key="a">
+            检查网络是否连接,路由器是否正常工作。
+          </div>
+          <div v-if="step2StatusResolved && !step2Status" key="b">
+            请调整电脑时间和社区与北京时间一致。
+          </div>
+          <div v-if="step3StatusResolved && !step3Status" key="c">
+            请确认摄像头连接线正常,能正常工作,关闭杀毒软件、关闭摄像头滤镜软件;请确认您的电脑是否为双摄摄像头,启用的摄像头是否正确。
+          </div>
+          <div v-if="step4StatusResolved && !step4Status" key="d">
+            请确认音箱连接正常,调整音量开关及大小。
+          </div>
+          <div v-if="step5StatusResolved && !step5Status" key="e">
+            请确认微信已登录并连接网络。
+          </div>
+
+          <div
+            v-if="
+              !step1Status ||
+              (step2StatusResolved && !step2Status) ||
+              (step3StatusResolved && !step3Status) ||
+              (step4StatusResolved && !step4Status) ||
+              (step5StatusResolved && !step5Status)
+            "
+            key="f"
+          >
+            请按提示检查并调试,调试后可再次进行环境检测。
+          </div>
+        </div>
+      </div>
+
+      <div style="margin-top: 30px; text-align: center">
+        <n-button type="primary" :disabled="current === 1" @click="previous">
+          上一步
+        </n-button>
+        <div style="width: 30px; height: 1px; display: inline-block"></div>
+        <n-button type="primary" :disabled="current === 6" @click="next">
+          下一步
+        </n-button>
+        <div style="width: 30px; height: 1px; display: inline-block"></div>
+        <n-button
+          v-if="current === 6"
+          key="xxx"
+          type="primary"
+          @click="() => emit('on-close')"
+        >
+          进入考试
+        </n-button>
+      </div>
+    </div>
+  </n-modal>
+</template>
+<style scoped>
+.section {
+  margin-top: 10px;
+}
+
+.list {
+  border: 1px solid #eeeeee;
+  border-radius: 6px;
+  text-align: center;
+}
+
+.list table {
+  width: 100%;
+  border-collapse: collapse !important;
+  border-spacing: 0;
+}
+.list td {
+  border: 1px solid #eeeeee;
+  border-radius: 6px;
+  border-collapse: separate !important;
+  padding: 10px;
+}
+.list .first-td {
+  width: 50%;
+}
+
+.pass-check {
+  font-size: 20px;
+  color: green;
+}
+
+.fail-cross {
+  font-size: 20px;
+  color: red;
+}
+</style>

+ 69 - 0
src/features/OnlineExam/CommittmentDialog.vue

@@ -0,0 +1,69 @@
+<script setup lang="ts">
+// TODO: https://github.com/vuejs/rfcs/discussions/369  defineProps deconstructure retain reactivity
+// eslint-disable-next-line vue/no-setup-props-destructure
+const { content = "" } = defineProps<{ content: string }>();
+
+const emit = defineEmits<{ (e: "on-commit-result", v: boolean): void }>();
+
+let show = $ref(true);
+
+function onOk() {
+  show = false;
+  emit("on-commit-result", true);
+  logger({ cnl: ["local"], pgn: "考生承诺书", dtl: "承诺同意" });
+}
+function onDecline() {
+  show = false;
+  emit("on-commit-result", false);
+  logger({ cnl: ["server"], pgn: "考生承诺书", dtl: "拒绝承诺" });
+}
+</script>
+
+<template>
+  <n-modal
+    title="考生承诺书"
+    :closable="false"
+    :show="show"
+    preset="card"
+    style="width: 600px"
+  >
+    <div style="font-size: 16px">
+      <div class="privacy-content" v-html="content"></div>
+
+      <div style="tw-flex tw-justify-center tw-mt-2">
+        <div class="tw-flex tw-justify-center tw-mt-2">
+          <n-button
+            type="success"
+            style="margin-right: 5px; min-width: 120px"
+            @click="onOk"
+          >
+            同意
+          </n-button>
+          <n-button
+            type="success"
+            style="margin-right: 5px; min-width: 120px"
+            @click="onDecline"
+          >
+            拒绝
+          </n-button>
+        </div>
+      </div>
+    </div>
+  </n-modal>
+</template>
+
+<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>

+ 669 - 0
src/features/OnlineExam/FaceRecognition.vue

@@ -0,0 +1,669 @@
+<script setup lang="ts">
+import MD5 from "js-md5";
+import { onMounted, watchEffect } from "vue";
+import { getMediaStream } from "@/utils/camera";
+import { httpApp } from "@/plugins/axiosApp";
+import { showLogout } from "@/utils/utils";
+import { getCapturePhotoYunSign, saveCapturePhoto } from "@/api/login";
+
+// FIXME: 开启异步抓拍
+/**
+ * 上层通过showRecognizeButton来控制是否是同步比对
+ *
+ * 同步比对通过onRecognizeResult得到人脸比对结果
+ *
+ * 异步比对通过snapNow来控制是否该进行比对,什么时候进行,以什么频率频率进行,均由上层控制
+ * 异步比对同时传递一个snapId(time),供上层识别和计数
+ * 可能存在多个异步比对的任务同时进行
+ */
+
+// TODO: https://github.com/vuejs/rfcs/discussions/369  defineProps deconstructure retain reactivity
+// eslint-disable-next-line vue/no-setup-props-destructure
+const {
+  width = 400,
+  height = 3000,
+  snapNow = false,
+  // snapId = 0,
+} = defineProps<{
+  width: string;
+  height: string;
+  showRecognizeButton: boolean;
+  snapNow?: boolean;
+  // snapId: number;
+}>();
+
+const emit = defineEmits<{
+  (
+    e: "on-recognize-result",
+    v: { isPassed: boolean; isStranger: boolean }
+  ): void;
+}>();
+
+let snapBtnDisabled = $ref(true);
+let btnText = $ref("开始识别");
+
+watchEffect(() => {
+  if (snapNow) {
+    // snapAsync(snapId)
+  }
+});
+
+onMounted(async () => {
+  await openCamera();
+});
+
+const video = $ref<HTMLVideoElement>();
+
+async function openCamera() {
+  const _openStartTime = Date.now();
+  const stream = await getMediaStream();
+  video.srcObject = stream;
+  try {
+    await video.play();
+  } catch (error) {
+    console.log(error);
+    if (error instanceof Error) {
+      if (error.name == "AbortError") {
+        logger({
+          cnl: ["server"],
+          act: "video.paly",
+          dtl: "AbortError and retry",
+        });
+        await video.play();
+        logger({
+          cnl: ["server"],
+          act: "摄像头没有正常启用: AbortError 重试成功",
+        });
+      } else if (error.name == "NotSupportedError") {
+        logger({
+          cnl: ["server"],
+          act: "摄像头没有正常启用",
+          ejn: JSON.stringify(error),
+          ext: {
+            errorName: error.name,
+            errorMessage: error.message,
+            errorStack: error.stack,
+          },
+        });
+        $message.error("摄像头没有正常启用: " + error);
+      } else {
+        throw error;
+      }
+    } else {
+      logger({
+        cnl: ["server"],
+        act: "video.play",
+        dtl: "not an Error",
+        stk: error + "",
+      });
+    }
+    throw error;
+  }
+  snapBtnDisabled = false;
+  const _openEndTime = Date.now();
+  logger({
+    cnl: ["server"],
+    act: "摄像头打开耗时",
+    ext: { cost: _openEndTime - _openStartTime },
+  });
+}
+
+// async function snapAsync() {
+//   try {
+//     logger({ cnl: ["server"], act: "定时抓拍开始" });
+//     const examRecordDataId = this.$route.params.examRecordDataId;
+//     const captureBlob = await getSnapShot({ compareSync: false });
+//     logger({ cnl: ["server"], act: "抓拍照片的大小:" + captureBlob.size });
+//     void videoStartPlay();
+//     console.log("抓拍照片的大小:" + captureBlob.size);
+//     if (captureBlob.size < 48 * 48 || captureBlob.size >= 2 * 1024 * 1024) {
+//       // 经查以前记录,不完整图片均为8192大小。此处设置小于10KB的图片为未抓拍成功
+//       // 检查百度统计的记录后,这里的图片大小可能小于8192,也可能是有效的数据,所以降低图片大小的要求为face++的要求
+//       logger({
+//         cnl: ["server"],
+//         act: "摄像头异常",
+//         dtl: "定时抓拍照片大小异常",
+//         ext: { blobSize: captureBlob.size },
+//       });
+//       throw new Error("定时抓拍照片大小异常");
+//     }
+//     const startTime = Date.now();
+//     const [captureFilePath, signIdentifier] = await this.uploadToServer(
+//       captureBlob
+//     );
+//     const endTime = Date.now();
+//     logger({
+//       cnl: ["server"],
+//       act: "定时抓拍上传",
+//       ext: { cost: endTime - startTime },
+//     });
+//     await this.faceCompare(captureFilePath, signIdentifier, examRecordDataId);
+//     logger({
+//       cnl: ["server"],
+//       act: "定时抓拍比对",
+//       dtl: "定时抓拍流程成功",
+//       ext: { cost: Date.now() - endTime, signIdentifier },
+//     });
+//   } catch (error) {
+//     if (!(error instanceof Error)) {
+//       logger({
+//         cnl: ["server"],
+//         act: "snapAsync",
+//         dtl: "not an Error",
+//         stk: error + "",
+//       });
+//       return;
+//     }
+//     logger({
+//       cnl: ["server"],
+//       act: "定时抓拍流程失败",
+//       ejn: JSON.stringify(error),
+//       stk: error.stack,
+//       ext: {
+//         errorName: error.name,
+//         errorMessage: error.message,
+//         firstSnap: (this.lastSnapTime ? "(非初次抓拍)" : "") + "将再次抓拍",
+//       },
+//     });
+//     this.retrySnapTimeout = setTimeout(() => {
+//       this.logger({
+//         action: "答题页面",
+//         detail: "定时抓拍流程失败后重试",
+//       });
+//       this.toggleSnapNow();
+//     }, 60 * 1000);
+//   } finally {
+//     this.videoStartPlay();
+//     this.decreaseSnapCount();
+//   }
+// }
+
+async function videoStartPlay() {
+  if (video && video.paused) {
+    await video.play().catch((e) => {
+      if (!(e instanceof Error)) {
+        logger({
+          cnl: ["server"],
+          act: "videoStartPlay",
+          dtl: "not an Error",
+          stk: e + "",
+        });
+      } else {
+        logger({
+          cnl: ["server"],
+          act: "restart video play",
+          stk: e.stack,
+          ejn: JSON.stringify(e),
+        });
+      }
+      throw e;
+    });
+  }
+}
+
+async function snap() {
+  logger({
+    cnl: ["server"],
+    act: "同步人脸比对",
+    dtl: "点击开始识别按钮",
+  });
+
+  $message.destroyAll();
+  try {
+    snapBtnDisabled = true;
+    btnText = "拍照中...";
+    const captureBlob = await getSnapShot(true);
+    console.log("抓拍照片大小", captureBlob.size);
+    if (captureBlob.size < 48 * 48 || captureBlob.size >= 2 * 1024 * 1024) {
+      $message.error("抓拍照片太小!");
+
+      logger({
+        cnl: ["server"],
+        act: "摄像头异常",
+        dtl: "抓拍照片大小异常",
+        ext: { blobSize: captureBlob.size },
+      });
+      throw new Error("抓拍照片大小异常");
+    }
+    void videoStartPlay();
+    btnText = "上传照片中...";
+    const [captureFilePath, signIdentifier] = await uploadToServer(captureBlob);
+    btnText = "人脸比对中...";
+    await faceCompareSync(captureFilePath, signIdentifier);
+    logger({
+      cnl: ["server"],
+      act: "同步比对照片详细日志",
+      ext: {
+        fileUrl: captureFilePath,
+        signIdentifier,
+      },
+    });
+  } catch (error) {
+    console.log("同步照片比对流程失败");
+    throw error;
+  } finally {
+    void videoStartPlay();
+    btnText = "开始识别";
+    // 避免人脸识别功能被大量重复点击
+    await new Promise((resolve) => setTimeout(resolve, 3000));
+    snapBtnDisabled = false;
+  }
+}
+async function getSnapShot(compareSync: boolean): Promise<Blob> {
+  return new Promise((resolve, reject) => {
+    if (video.readyState !== 4 || !(video.srcObject as MediaStream).active) {
+      $message.error("摄像头没有正常启用");
+      logger({
+        cnl: ["server"],
+        pgu: "AUTO",
+        act: "getSnapShot",
+        dtl: "摄像头没有正常启用",
+        // (!compareSync && this.lastSnapTime ? "-退出(非初次抓拍)" : ""),
+      });
+      reject("摄像头没有正常启用");
+      if (!compareSync) {
+        showLogout("摄像头没有正常启用");
+      }
+      return;
+    }
+    video.pause();
+    const canvas = document.createElement("canvas");
+    canvas.width = 220;
+    canvas.height = 165;
+
+    const context = canvas.getContext("2d");
+    context?.drawImage(video, 0, 0, 220, 165);
+
+    canvas.toBlob((blob) => resolve(blob!), "image/png", 0.95);
+  });
+}
+// 用来比对两次抓拍照片的md5是否一样
+let __previousPhotoMD5 = "";
+async function uploadToServer(captureBlob: Blob): Promise<[string, string]> {
+  async function blobToArray(blob: Blob): Promise<ArrayBuffer> {
+    return new Promise((resolve) => {
+      var reader = new FileReader();
+      reader.addEventListener("loadend", function () {
+        // reader.result contains the contents of blob as a typed array
+        resolve(reader.result as ArrayBuffer);
+      });
+      reader.readAsArrayBuffer(blob);
+    });
+  }
+  //保存抓拍照片到服务器
+  let resultUrl, signIdentifier;
+  try {
+    const buffer = await blobToArray(captureBlob);
+
+    const fileMd5Base64 = window.btoa(
+      String.fromCharCode(...MD5.digest(buffer))
+    );
+    if (fileMd5Base64 === __previousPhotoMD5) {
+      logger({
+        cnl: ["server"],
+        pgu: "AUTO",
+        act: "uploadToServer",
+        key: "抓拍照片异常",
+        stk: "两次fileMd5Base64一样,疑似摄像头卡住",
+      });
+    }
+    __previousPhotoMD5 = fileMd5Base64;
+
+    const res = await getCapturePhotoYunSign({ fileSuffix: "png" });
+
+    try {
+      const saveRes = await saveCapturePhoto(
+        res.data.formUrl,
+        res.data.formParams,
+        { file: captureBlob }
+      );
+      if (saveRes.headers["content-md5"] != fileMd5Base64) {
+        logger({
+          cnl: ["server"],
+          dtl: "抓拍照片保存失败--alioss content-md5 mismatch",
+          ext: {
+            fileMd5Base64,
+            "content-md5": saveRes.headers["content-md5"],
+          },
+        });
+        throw new Error("图片校验失败");
+      }
+      __previousPhotoMD5 = fileMd5Base64;
+    } catch (error) {
+      logger({
+        cnl: ["server"],
+        dtl: "抓拍照片保存失败",
+        possibleError: error,
+      });
+      throw error;
+    }
+
+    // console.log(response);
+    resultUrl = res.data.accessUrl;
+    signIdentifier = res.data.signIdentifier;
+    logger({
+      cnl: ["server"],
+      dtl: "抓拍照片保存成功",
+      ext: { resultUrl, fileMd5Base64 },
+    });
+  } catch (e) {
+    console.log(e);
+    logger({
+      cnl: ["server"],
+      pgu: "AUTO",
+      dtl: "保存抓拍照片到服务器失败!",
+      possibleError: e,
+    });
+    $message.error("抓拍照片保存失败!");
+    throw new Error("抓拍照片保存失败!");
+  }
+
+  return [resultUrl, signIdentifier];
+}
+
+async function faceCompareSync(
+  captureFilePath: string,
+  signIdentifier: string
+) {
+  try {
+    logger({ cnl: ["server"], act: "同步比对开始" });
+    const res = await httpApp.post(
+      "/api/ecs_oe_student_face/examCaptureQueue/compareFaceSync?signIdentifier=" +
+        signIdentifier +
+        "&fileUrl=" +
+        encodeURIComponent(captureFilePath)
+    );
+    logger({
+      cnl: ["server"],
+      act: "同步比对api成功",
+      ext: {
+        isPass: res.data.isPass,
+        isStranger: res.data.isStranger,
+        errorMsg: res.data.errorMsg,
+      },
+    });
+    emit("on-recognize-result", {
+      isPassed: res.data.isPass,
+      isStranger: res.data.isStranger,
+    });
+  } catch (e) {
+    console.log(e);
+    // this.$Message.error(e.message);
+    logger({
+      cnl: ["server"],
+      act: "同步比对失败",
+      possibleError: e,
+    });
+    throw new Error("同步照片比较失败!");
+  }
+}
+// async function faceCompare(
+//   captureFilePath: string,
+//   signIdentifier: string,
+//   examRecordDataId: number
+// ) {
+//   try {
+//     let cameraInfos;
+//     let hasVirtualCamera = false;
+//     if (typeof nodeRequire != "undefined") {
+//       try {
+//         var fs = window.nodeRequire("fs");
+//         if (fs.existsSync("multiCamera.exe")) {
+//           await new Promise((resolve, reject) => {
+//             window.nodeRequire("node-cmd").get("multiCamera.exe", () => {
+//               try {
+//                 cameraInfos = fs.readFileSync("CameraInfo.txt", "utf-8");
+//                 // cameraInfos =
+//                 //   '[{"detail":"@device:pnp:?display#int3470#4&300121c4&0&uid13424#{65e8773d-8f56-11d0-a3b9-00a0c9223196}{9c5f415a-02cd-4e28-aeb7-811cb317dd64}","name":"HP Truevision 5MP Front","pid":"13424","vid":"3470"},{"detail":"@device:pnp:?display#int3470#4&300121c4&0&uid13424#{65e8773d-8f56-11d0-a3b9-00a0c9223196}{a6c1c503-01f1-4767-a229-00a0b223162f}","name":"HP Truevision 8MP Rear","pid":"13424","vid":"3470"},{"detail":"@device:pnp:?usb#vid_8086&pid_0a80&mi_04#6&28913c47&0&0004#{65e8773d-8f56-11d0-a3b9-00a0c9223196}global","name":"Intel(R) RealSense(TM) 3D Camera (R200) RGB","pid":"0a80","vid":"8086"},{"detail":"@device:pnp:?usb#vid_8086&pid_0a80&mi_00#6&28913c47&0&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}global","name":"Intel(R) RealSense(TM) 3D Camera (R200) Left-Right","pid":"0a80","vid":"8086"},{"detail":"@device:pnp:?usb#vid_8086&pid_0a80&mi_02#6&28913c47&0&0002#{65e8773d-8f56-11d0-a3b9-00a0c9223196}global","name":"Intel(R) RealSense(TM) 3D Camera (R200) Depth","pid":"0a80","vid":"8086"}]';
+//                 if (cameraInfos && cameraInfos.trim()) {
+//                   cameraInfos = cameraInfos.trim();
+//                   cameraInfos = cameraInfos.replace(/\r\n/g, "");
+//                   cameraInfos = cameraInfos.replace(/\n/g, "");
+//                   console.log(cameraInfos);
+//                   this.logger({
+//                     page: "摄像头框",
+//                     cameraInfos,
+//                   });
+//                 }
+//                 if (cameraInfos.includes('""')) {
+//                   hasVirtualCamera = true;
+//                 }
+//                 // multiCamera.exe 1.0.1
+//                 if (cameraInfos.includes("cameraInfo")) {
+//                   cameraInfos = JSON.stringify(
+//                     JSON.parse(cameraInfos).cameraInfo
+//                   );
+//                 }
+//                 if (cameraInfos.length >= 800) {
+//                   this.logger({
+//                     page: "摄像头框",
+//                     type: "虚拟摄像头-cameraInfos超长",
+//                     cameraInfos: cameraInfos,
+//                   });
+//                   let ary = JSON.parse(cameraInfos);
+//                   // 相同pid&vid仅保留一个
+//                   const pidAndVidCollector = [];
+//                   ary = ary.filter((c) => {
+//                     const pv = c.pid + "|" + c.vid;
+//                     const res = pidAndVidCollector.includes(pv);
+//                     pidAndVidCollector.push(pv);
+//                     return !res;
+//                   });
+//                   cameraInfos = JSON.stringify(ary);
+//                   console.log("摄像头检测超长:", "去除重复pid&vid");
+//                   console.log(cameraInfos);
+//                   if (cameraInfos.length >= 800) {
+//                     cameraInfos = JSON.stringify(
+//                       JSON.parse(cameraInfos).map((v) => {
+//                         return {
+//                           pid: v.pid,
+//                           vid: v.pid,
+//                           detail: "omitted",
+//                           name: v.name,
+//                         };
+//                       })
+//                     );
+//                     console.log("摄像头检测超长:", "去除detail");
+//                     console.log(cameraInfos);
+//                   }
+//                   if (cameraInfos.length >= 800) {
+//                     console.log("摄像头检测超长:", "精简后还是超长");
+//                     this.logger({
+//                       page: "摄像头框",
+//                       type: "虚拟摄像头-精简后还是超长",
+//                       cameraInfos: cameraInfos,
+//                     });
+//                     console.log(cameraInfos);
+//                   }
+//                 }
+//                 resolve();
+//               } catch (error) {
+//                 this.logger({
+//                   page: "摄像头框",
+//                   type: "虚拟摄像头-读取摄像头列表失败",
+//                   errorJSON: JSON.stringify(error, (key, value) =>
+//                     key === "token" ? "" : value
+//                   ),
+//                   errorName: error.name,
+//                   errorMessage: error.message,
+//                   errorStack: error.stack,
+//                 });
+//                 window._hmt.push([
+//                   "_trackEvent",
+//                   "摄像头框",
+//                   "虚拟摄像头-读取摄像头列表失败",
+//                 ]);
+//                 reject("读取摄像头列表失败");
+//               }
+//             });
+//           });
+//         }
+//       } catch (error) {
+//         console.log(error);
+//       }
+//     }
+
+//     let body = {
+//       fileUrl: captureFilePath,
+//       signIdentifier,
+//       examRecordDataId,
+//     };
+
+//     if (cameraInfos) {
+//       body.cameraInfos = cameraInfos;
+//       body.hasVirtualCamera = hasVirtualCamera;
+
+//       this.logger({
+//         action: "抓拍照片详细日志",
+//         fileUrl: captureFilePath,
+//         signIdentifier,
+//         examRecordDataId,
+//         cameraInfos,
+//         hasVirtualCamera,
+//         duplicateMD5: this.__duplicateMD5,
+//       });
+//     }
+//     const res = await this.$http.post(
+//       "/api/ecs_oe_student_face/examCaptureQueue/uploadExamCapture",
+//       body
+//     );
+//     const fileName = res.data;
+//     try {
+//       await this.showSnapResult(fileName, examRecordDataId);
+//     } catch (error) {
+//       this.logger({
+//         page: "摄像头框",
+//         action: "设置获取抓拍结果失败!",
+//         errorJSON: JSON.stringify(error, (key, value) =>
+//           key === "token" ? "" : value
+//         ),
+//         errorName: error.name,
+//         errorMessage: error.message,
+//         errorStack: error.stack,
+//       });
+//       this.$Message.error({
+//         content: "设置获取抓拍结果失败!",
+//         duration: 15,
+//         closable: true,
+//       });
+//     }
+//   } catch (e) {
+//     console.log(e);
+//     this.logger({
+//       page: "摄像头框",
+//       action: "faceCompare失败",
+//       error: e.response ? e.response.data.desc : e,
+//     });
+//     window._hmt.push([
+//       "_trackEvent",
+//       "摄像头框",
+//       "faceCompare失败",
+//       e.response ? e.response.data.desc : e,
+//     ]);
+//     // this.$Message.error(e.message);
+//     throw new Error("异步比较抓拍照片失败");
+//   }
+// }
+// async function showSnapResult(fileName, examRecordDataId) {
+//   if (!fileName) return; // 交卷后提交照片会得不到照片名称
+//   if (this.$route.name !== "OnlineExamingHome") {
+//     // 非考试页,不显示结果,也不继续查询
+//     return;
+//   }
+
+//   try {
+//     // 获取抓拍结果
+//     const snapRes =
+//       (
+//         await this.$http.get(
+//           "/api/ecs_oe_student_face/examCaptureQueue/getExamCaptureResult?fileName=" +
+//             fileName +
+//             "&examRecordDataId=" +
+//             examRecordDataId
+//         )
+//       ).data || {};
+//     if (snapRes.isCompleted) {
+//       if (snapRes.isStranger) {
+//         this.$Message.error({
+//           content: "请独立完成考试",
+//           duration: 5,
+//           closable: true,
+//         });
+//       } else if (!snapRes.isPass) {
+//         this.$Message.error({
+//           content: "请调整坐姿,诚信考试",
+//           duration: 5,
+//           closable: true,
+//         });
+//       }
+//     } else {
+//       this.showSnapResultTimeout = setTimeout(
+//         this.showSnapResult.bind(this, fileName, examRecordDataId),
+//         30 * 1000
+//       );
+//     }
+//   } catch (e) {
+//     console.log(e);
+//     if (this.$route.name !== "OnlineExamingHome") {
+//       // 非考试页,不显示结果,也不继续查询
+//       return;
+//     }
+//     this.$Message.error(e.message);
+//     throw e.message;
+//   }
+// }
+</script>
+
+<template>
+  <div>
+    <video
+      id="video"
+      ref="video"
+      :width="width"
+      :height="height"
+      autoplay
+    ></video>
+    <div v-if="showRecognizeButton" class="btn-container">
+      <button
+        class="verify-button"
+        :class="[snapBtnDisabled && 'disable-verify-button']"
+        :disabled="snapBtnDisabled"
+        @click="snap"
+      >
+        {{ btnText }}
+      </button>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.btn-container {
+  position: absolute;
+  width: 400px;
+  text-align: center;
+  margin-top: -50px;
+  color: #232323;
+}
+.verify-button {
+  font-size: 16px;
+  background-color: #ffcc00;
+  display: inline-block;
+  padding: 6px 16px;
+  border-radius: 6px;
+}
+
+.verify-button:hover {
+  color: #444444;
+  cursor: pointer;
+}
+
+.disable-verify-button {
+  background-color: #f7f7f7;
+  color: #c5c8ce;
+}
+
+.disable-verify-button:hover {
+  cursor: not-allowed;
+  color: #c5c8ce;
+}
+</style>

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

@@ -0,0 +1,93 @@
+<script setup lang="ts">
+import FaceRecognition from "./FaceRecognition.vue";
+import { store } from "@/store/store";
+
+const emit = defineEmits<{
+  /** 由上层去判断是否是强制,此"passed" event仅代表照片被后台比对了,
+   * isRealStudent代表照片是否为学生本人 */
+  (e: "passed", isRealStudent: boolean): void;
+  (e: "close"): void;
+}>();
+const userPhoto = store.user.photoPath;
+
+function closeModal() {
+  logger({
+    cnl: ["server"],
+    pgn: "OnlineExamFaceCheckModal",
+    act: "点击关闭按钮",
+  });
+  emit("close");
+}
+function getFaceRecognitionResult(res: {
+  isPassed: boolean;
+  isStranger: boolean;
+}) {
+  $message.info("人脸比对" + (res.isPassed ? "成功" : "失败"));
+  emit("passed", res.isPassed);
+}
+</script>
+
+<template>
+  <n-modal
+    :show="true"
+    style="width: 900px"
+    title="人脸识别"
+    preset="card"
+    @close="closeModal"
+  >
+    <div class="tw-flex">
+      <div class="avatar" :style="{ backgroundImage: `url('${userPhoto}')` }">
+        <div
+          class="avatar-info"
+          style="text-align: center; margin-top: 260px; color: white"
+        >
+          <span class="photo-hint">
+            {{ userPhoto ? "我的底照" : "无底照" }}
+          </span>
+        </div>
+      </div>
+      <div class="camera tw-mx-1">
+        <FaceRecognition
+          width="400"
+          height="300"
+          :showRecognizeButton="true"
+          @onRecognizeResult="getFaceRecognitionResult"
+        />
+      </div>
+      <div class="verify-desc qm-primary-text">
+        <h4 class="qm-big-text" style="font-weight: bold">操作提示:</h4>
+        <p>1.请先确保摄像头设备已连接并能正常工作;</p>
+        <p>2.请保持光源充足,不要逆光操作;</p>
+        <p>
+          3.请保证脸部正面面向摄像头,并适当调整姿势保证整个脸部能够进入左侧识别画面;
+        </p>
+        <p>4.系统识别通过后,将自动跳转进入考试界面;</p>
+      </div>
+    </div>
+  </n-modal>
+</template>
+
+<style scoped>
+.avatar {
+  background: center no-repeat;
+  background-size: cover;
+  min-width: 200px;
+  height: 300px;
+}
+
+.photo-hint {
+  background-color: rgba(0, 0, 0, 0.5);
+  display: inline-block;
+  padding: 6px 16px;
+  border-radius: 6px;
+}
+
+.camera {
+  min-width: 400px;
+}
+
+.verify-desc {
+  padding: 0 1em;
+  line-height: 1.8em;
+}
+</style>

+ 7 - 25
src/features/OnlineExam/OnlineExamHome.vue

@@ -1,15 +1,12 @@
 <script setup lang="ts">
 <script setup lang="ts">
-// import EcsOnlineList from "./OnlineExamList.vue";
-
 import { httpApp } from "@/plugins/axiosApp";
 import { httpApp } from "@/plugins/axiosApp";
 import { ExamType, OnlineExam } from "@/types/student-client";
 import { ExamType, OnlineExam } from "@/types/student-client";
 import { onMounted } from "vue";
 import { onMounted } from "vue";
+import OnlineExamList from "./OnlineExamList.vue";
 
 
 // TODO: https://github.com/vuejs/rfcs/discussions/369  defineProps deconstructure retain reactivity
 // TODO: https://github.com/vuejs/rfcs/discussions/369  defineProps deconstructure retain reactivity
 // eslint-disable-next-line vue/no-setup-props-destructure
 // eslint-disable-next-line vue/no-setup-props-destructure
-const { examType = "ONLINE" } = defineProps<{
-  examType?: ExamType;
-}>();
+const { examType = "ONLINE" } = defineProps<{ examType?: ExamType }>();
 
 
 let courses: OnlineExam[] = $ref([]);
 let courses: OnlineExam[] = $ref([]);
 let endCourses: OnlineExam[] = $ref([]);
 let endCourses: OnlineExam[] = $ref([]);
@@ -81,28 +78,13 @@ async function getData() {
 </script>
 </script>
 
 
 <template>
 <template>
-  <!-- <div class="home">
-    <ecs-online-list
+  <div class="part-box">
+    <OnlineExamList
       :courses="courses"
       :courses="courses"
       :endCourses="endCourses"
       :endCourses="endCourses"
       :examType="examType"
       :examType="examType"
-    ></ecs-online-list>
-  </div>  -->
-  <div>{{ courses }} {{ endCourses }}</div>
+    />
+  </div>
 </template>
 </template>
 
 
-<style scoped>
-.home {
-  margin: 20px;
-}
-.welcome-modal {
-  display: flex;
-  margin-top: 26px;
-  margin-left: 20px;
-}
-.smile-png {
-  width: 40px;
-  height: 40px;
-  background: url(./smile-icon.png);
-}
-</style>
+<style scoped></style>

+ 251 - 0
src/features/OnlineExam/OnlineExamList.vue

@@ -0,0 +1,251 @@
+<script lang="ts" setup>
+import moment from "moment";
+import { ExamType, OnlineExam } from "@/types/student-client";
+import { store } from "@/store/store";
+import StartExamModal from "./StartExamModal.vue";
+import { tryLimit } from "@/utils/tryLimit";
+import { useTimers } from "@/setups/useTimers";
+import { WEEKDAY_NAMES } from "@/constants/constants";
+import OnlineExamResultList from "./OnlineExamResultList.vue";
+
+// TODO: https://github.com/vuejs/rfcs/discussions/369  defineProps deconstructure retain reactivity
+// eslint-disable-next-line vue/no-setup-props-destructure
+const {
+  courses = [],
+  endCourses = [],
+  examType = "ONLINE",
+} = defineProps<{
+  courses: OnlineExam[];
+  endCourses: OnlineExam[];
+  examType?: ExamType;
+}>();
+
+let selectedCourse: OnlineExam | null = $ref(null);
+let selectExamState: "EXAMING" | "EXAM_END" = $ref("EXAMING");
+
+const selectedCourseList = $computed(() =>
+  selectExamState === "EXAMING" ? courses : endCourses
+);
+
+function cycleDesc(course: OnlineExam) {
+  if (!course.examCycleEnabled) {
+    return "";
+  }
+  const weekDesc = course.examCycleWeek.map((v) => "周" + WEEKDAY_NAMES[v]);
+  const timeRangeDesc = course.examCycleTimeRange
+    .map((v) => v.timeRange)
+    .map((v) => v[0] + "~" + v[1])
+    .join("<br>");
+  return weekDesc + "<br>" + timeRangeDesc;
+}
+
+function courseInBetween(course: OnlineExam) {
+  return moment(store.getTimeStamp).isBetween(
+    moment(course.startTime),
+    moment(course.endTime)
+  );
+}
+
+function disableReason(course: OnlineExam) {
+  if (!courseInBetween(course)) {
+    return "当前时间不在考试开放时间范围";
+  } else if (course.allowExamCount < 1) {
+    return "无剩余考试次数";
+  } else if (!courseInCycle(course)) {
+    return "不在考试时间周期内";
+  } else if (buttonCountDown > 0) {
+    return "请稍等";
+  } else {
+    return "";
+  }
+}
+
+function courseInCycle(course: OnlineExam) {
+  if (!course.examCycleEnabled) {
+    return true;
+  }
+  const weekday = moment(store.getTimeStamp).isoWeekday();
+  if (!course.examCycleWeek.includes(weekday)) {
+    return false;
+  }
+  const HHmm = moment(store.getTimeStamp).format("HH:mm");
+  const ranges = course.examCycleTimeRange.map((v) => v.timeRange);
+  const inRange = ranges.some((v) => HHmm >= v[0] && HHmm <= v[1]);
+  return inRange;
+}
+
+function disableTheCourse(course: OnlineExam) {
+  return (
+    !courseInBetween(course) ||
+    course.allowExamCount < 1 ||
+    !courseInCycle(course) ||
+    buttonCountDown > 0
+  );
+}
+
+const { addInterval } = useTimers();
+let buttonCountDown = $ref(0);
+
+addInterval(() => buttonCountDown--, 1000);
+
+async function raceEnter(course: OnlineExam) {
+  logger({
+    cnl: ["server"],
+    pgu: "AUTO",
+    act: "点击进入考试按钮",
+  });
+  const minutesAfterCourseStart = Math.floor(
+    moment(store.getTimeStamp).diff(moment(course.startTime), "seconds") / 60
+  );
+
+  store.increaseGlobalMaskCount("raceEnter");
+  const { limitResult, serverOk } = await tryLimit({
+    action: "startExam",
+    limit: 100,
+  });
+  logger({
+    cnl: ["server"],
+    pgu: "AUTO",
+    key: "开考限流API call",
+  });
+  store.decreaseGlobalMaskCount("raceEnter");
+
+  if (limitResult) {
+    logger({
+      cnl: ["server"],
+      pgu: "AUTO",
+      act: "开考未限流",
+      dtl: "限流-" + minutesAfterCourseStart + "分进入",
+    });
+
+    selectedCourse = course;
+  } else {
+    logger({
+      cnl: ["server"],
+      pgu: "AUTO",
+      act: "开考被限流",
+      dtl: "限流-" + minutesAfterCourseStart + "分被限流",
+      ext: { serverOk },
+    });
+    $dialog.warning({
+      title: "提示",
+      content: "网络繁忙,请稍后再试。",
+      onClose: () => {
+        buttonCountDown = 3;
+      },
+    });
+  }
+}
+</script>
+
+<template>
+  <div class="list">
+    <div style="display: flex; margin-bottom: 10px">
+      <n-button
+        type="success"
+        :class="selectExamState != 'EXAMING' && 'button-unselected'"
+        @click="selectExamState = 'EXAMING'"
+      >
+        待考列表
+      </n-button>
+      <n-button
+        v-if="examType === 'ONLINE'"
+        type="success"
+        :class="selectExamState != 'EXAM_END' && 'button-unselected'"
+        @click="selectExamState = 'EXAM_END'"
+      >
+        已结束考试
+      </n-button>
+    </div>
+
+    <table>
+      <tbody class="list-row">
+        <tr class="list-header qm-primary-strong-text">
+          <td>课程</td>
+          <td key="cc">层次</td>
+          <td key="zy">专业</td>
+          <td>考试进入时间</td>
+          <td>考试时间周期</td>
+          <td>剩余考试次数</td>
+          <td style="max-width: 200px">操作</td>
+        </tr>
+
+        <tr
+          v-for="course in selectedCourseList"
+          :key="course.examId + course.courseId"
+        >
+          <td>{{ course.courseName }}</td>
+          <td key="cc">{{ course.courseLevel }}</td>
+          <td key="zy">{{ course.specialtyName }}</td>
+          <td>
+            {{ course.startTime }} <br />
+            ~ <br />
+            {{ course.endTime }}
+          </td>
+          <td>
+            <span v-html="cycleDesc(course)" />
+          </td>
+          <td>{{ course.allowExamCount }}</td>
+          <td style="min-width: 180px">
+            <div
+              style="
+                display: grid;
+                grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
+                grid-gap: 10px;
+              "
+            >
+              <n-button
+                v-if="selectExamState == 'EXAMING'"
+                type="success"
+                :disabled="disableTheCourse(course)"
+                :title="disableTheCourse(course) ? disableReason(course) : ''"
+                @click="raceEnter(course)"
+              >
+                进入考试
+                {{ buttonCountDown > 0 ? `(${buttonCountDown}s)` : "" }}
+              </n-button>
+              <div></div>
+              <n-popover trigger="hover" placement="left-start" :delay="300">
+                <template #trigger>
+                  <n-button :disabled="!course.isObjScoreView" type="success">
+                    客观分
+                  </n-button>
+                </template>
+                <OnlineExamResultList :examStudentId="course.examStudentId" />
+              </n-popover>
+            </div>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+  <StartExamModal
+    v-if="selectedCourse"
+    :course="selectedCourse"
+    @onUnselectCourse="selectedCourse = null"
+  />
+</template>
+
+<style scoped>
+.list {
+  border: 1px solid #eeeeee;
+  border-top: none;
+  border-radius: 6px;
+}
+
+.list table {
+  width: 100%;
+  border-collapse: collapse !important;
+  border-spacing: 0;
+}
+.list td {
+  border: 1px solid #eeeeee;
+  border-radius: 6px;
+  border-collapse: separate !important;
+  padding: 10px;
+}
+.button-unselected {
+  color: #9e9f9e;
+  background-color: white;
+}
+</style>

+ 87 - 0
src/features/OnlineExam/OnlineExamResultList.vue

@@ -0,0 +1,87 @@
+<script setup lang="ts">
+import { httpApp } from "@/plugins/axiosApp";
+import { onMounted } from "vue";
+
+const props = defineProps<{
+  examStudentId: number;
+}>();
+
+let loading = $ref(true);
+let results: Array<{
+  examOrder: number;
+  startTime: string;
+  endTime: string;
+  isExamEnded: boolean;
+  isAuditing: boolean;
+  isIllegality: boolean;
+  objectiveScore: number;
+}> = $ref([]);
+
+onMounted(async () => {
+  try {
+    const res = (
+      await httpApp.get(
+        "/api/branch_ecs_oe_admin/exam/score/queryObjectiveScoreList?examStudentId=" +
+          props.examStudentId
+      )
+    ).data;
+    logger({ cnl: ["server"], act: "查看客观分" });
+    results = res || [];
+    results = results.sort((a, b) => b.examOrder - a.examOrder).slice(0, 10);
+    loading = false;
+  } catch (error) {
+    logger({ cnl: ["server", "console"], possibleError: error });
+    $message.error("查询客观分列表出错!");
+  }
+});
+</script>
+
+<template>
+  <div class="list">
+    <table>
+      <tbody class="list-row">
+        <tr class="list-header qm-primary-strong-text">
+          <td>次数</td>
+          <td>开始时间</td>
+          <td>交卷时间</td>
+          <td>客观分</td>
+        </tr>
+
+        <tr v-for="(result, index) in results" :key="index">
+          <td>{{ result.examOrder }}</td>
+          <td>{{ result.startTime }}</td>
+          <td>{{ result.endTime }}</td>
+          <td v-if="!result.isExamEnded">分数计算中</td>
+          <td v-else-if="result.isAuditing">审核中</td>
+          <td v-else-if="result.isIllegality">违纪</td>
+          <td v-else>{{ result.objectiveScore }}</td>
+        </tr>
+
+        <tr v-if="loading">
+          加载中
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</template>
+
+<style scoped>
+.list {
+  border: 1px solid #eeeeee;
+  border-radius: 6px;
+  max-height: 400px;
+  overflow: auto;
+}
+
+.list table {
+  width: 100%;
+  border-collapse: collapse !important;
+  border-spacing: 0;
+}
+.list td {
+  border: 1px solid #eeeeee;
+  border-radius: 6px;
+  border-collapse: separate !important;
+  padding: 10px;
+}
+</style>

+ 313 - 0
src/features/OnlineExam/StartExamModal.vue

@@ -0,0 +1,313 @@
+<script setup lang="ts">
+import OnlineExamFaceCheckModal from "./OnlineExamFaceCheckModal.vue";
+import CommittmentDialog from "./CommittmentDialog.vue";
+import { OnlineExam } from "@/types/student-client";
+import CheckComputer from "./CheckComputer.vue";
+import { onMounted, onUnmounted, watch } from "vue";
+import { httpApp } from "@/plugins/axiosApp";
+import router from "@/router";
+import { checkExamInProgress } from "../UserLogin/useExamInProgress";
+import { store } from "@/store/store";
+import { closeMediaStream } from "@/utils/camera";
+
+// TODO: https://github.com/vuejs/rfcs/discussions/369  defineProps deconstructure retain reactivity
+// eslint-disable-next-line vue/no-setup-props-destructure
+const { course } = defineProps<{ course: OnlineExam }>();
+
+const emit = defineEmits<{ (e: "on-unselect-course"): void }>();
+
+/** 采用状态机,根据每一步的状态来执行初始化,以及跳转
+ * 驱动步骤向前分两类:
+ * 1. 自动跳转在watch里面
+ * 2. 用户事件跳转,通过组件注册事件
+ */
+const STEPS = [
+  "COMMITTMENT",
+  "CHECK_ENV_1",
+  "CHECK_ENV_2",
+  "CHECK_FACE",
+  "CHECK_FACE_COMMITTMENT",
+] as const;
+
+let curentStep = $ref(-1);
+
+watch(
+  () => curentStep,
+  async (step, oldStep) => {
+    logger({
+      cnl: ["server", "console"],
+      act: "StartExamModal",
+      ext: {
+        currentStep: STEPS[step],
+        oldStep: STEPS[oldStep],
+      },
+    });
+
+    if (STEPS[step] === "CHECK_ENV_1") {
+      if (!(await isGetCheckEnvOk())) {
+        emit("on-unselect-course");
+        return;
+      }
+      if (!serverWantCheckEnv) curentStep++;
+    }
+    if (STEPS[step] === "CHECK_FACE") {
+      if (course.faceEnable) {
+        if (!store.user.photoPath) {
+          logger({ cnl: ["server"], act: "无底照" });
+          $message.info(
+            "本场考试需要进行人脸检测,但是您没有上传底照,请联系老师!"
+          );
+          emit("on-unselect-course");
+          return true;
+        }
+
+        if (!(await isGetFaceLivenessOk())) {
+          emit("on-unselect-course");
+          return true;
+        }
+      } else {
+        curentStep++;
+        await enterExam();
+      }
+    }
+  }
+);
+
+function applyCommittmentResult(isCommitted: boolean) {
+  if (!isCommitted) {
+    emit("on-unselect-course");
+  } else {
+    curentStep++;
+  }
+}
+
+let serverWantCheckEnv = $ref(false);
+function skipCheckEnv() {
+  curentStep += 2;
+}
+function doCheckEnv() {
+  curentStep++;
+}
+
+async function isIPOk(): Promise<boolean> {
+  logger({ cnl: ["server"], act: "正在检测IP合法性..." });
+  try {
+    const ipLimit = (
+      await httpApp.get("/api/ecs_exam_work/exam/ipLimit/" + course.examId)
+    ).data;
+    if (ipLimit.limited) {
+      logger({ cnl: ["server"], act: "IP受限,请到中心指定地点进行考试!" });
+      $message.error("IP受限,请到中心指定地点进行考试!");
+      return false;
+    }
+  } catch (error) {
+    $message.error("查询IP限制出错!");
+    logger({ cnl: ["server"], act: "查询IP限制出错!" });
+    return false;
+  }
+
+  return true;
+}
+
+async function isGetCheckEnvOk(): Promise<boolean> {
+  try {
+    let checkEnv = await httpApp.get(
+      "/api/ecs_exam_work/exam/getExamPropertyFromCacheByStudentSession/" +
+        course.examId +
+        `/CHECK_ENVIRONMENT`
+    );
+
+    if (checkEnv.data.CHECK_ENVIRONMENT === "true") {
+      serverWantCheckEnv = true;
+    }
+  } catch (error) {
+    logger({
+      cnl: ["server"],
+      act: "获取CHECK_ENVIRONMENT失败",
+    });
+    $message.warning("网络错误,请重试!");
+    // 获取不到流程必要的参数,就中断流程
+    return false;
+  }
+
+  return true;
+}
+
+async function isGetFaceLivenessOk(): Promise<boolean> {
+  let faceLiveness = null;
+  try {
+    faceLiveness = await httpApp.get(
+      "/api/ecs_exam_work/exam/identificationOfLivingEnabled/" + course.examId
+    );
+  } catch (error) {
+    $message.error("查询考试的人脸检测设置属性出错!");
+    logger({ cnl: ["server"], act: "查询考试的人脸检测设置属性出错!" });
+    return false;
+  }
+  if (faceLiveness.data) {
+    logger({ cnl: ["server"], act: "正在检测底照是否满足活体检测标准..." });
+    let checkBasePhoto;
+    try {
+      checkBasePhoto = (
+        await httpApp.get(
+          "/api/ecs_oe_student/examFaceLivenessVerify/checkFaceLiveness" +
+            `?${store.user.token}` // 考生采用相同的机器考试,使用不同的请求的缓存
+        )
+      ).data;
+      if (!checkBasePhoto.success) {
+        $message.error("您上传的底照不符合活体检测的要求,请联系老师!");
+        logger({
+          cnl: ["server"],
+          act: "您上传的底照不符合活体检测的要求,请联系老师!",
+        });
+        return false;
+      }
+    } catch (error) {
+      $message.error("网络错误,请稍后重试!");
+      logger({
+        cnl: ["server"],
+        act: "查询检测底照是否满足活体检测标准的接口出错!",
+        possibleError: error,
+      });
+      return false;
+    }
+  }
+
+  return true;
+}
+
+onMounted(async () => {
+  if (!(await isIPOk())) {
+    emit("on-unselect-course");
+    return true;
+  }
+
+  if (await checkExamInProgress().catch(() => true)) {
+    // 检测到有断点,或者断点有异常,退出当前流程
+    emit("on-unselect-course");
+    return;
+  }
+
+  curentStep = 0;
+});
+
+function onFacePassed(isRealStudent: boolean) {
+  if (isRealStudent) {
+    void enterExam();
+  } else {
+    if (!course.faceCheck) curentStep++;
+  }
+}
+
+function onFaceClosedByUser() {
+  emit("on-unselect-course");
+}
+
+function disagreeCommittment() {
+  emit("on-unselect-course");
+}
+
+let passedAllChecks = false;
+async function enterExam() {
+  passedAllChecks = true;
+  await router.push(
+    `/online-exam/exam/${course.examId}/overview?examStudentId=${course.examStudentId}`
+  );
+}
+
+onUnmounted(() => {
+  if (!passedAllChecks) {
+    // debug级别备选。因为初期上线,摄像头比较容易出错,所以保留此日志
+    logger({
+      cnl: ["server", "console"],
+      act: "关闭sharedtream",
+      dtl: "!passedAllChecks",
+    });
+    closeMediaStream();
+  } else {
+    logger({
+      cnl: ["server", "console"],
+      act: "保留sharedtream",
+      dtl: "passedAllChecks",
+    });
+  }
+});
+</script>
+
+<template>
+  <CommittmentDialog
+    v-if="STEPS[curentStep] === 'COMMITTMENT'"
+    ref="committmentDialogRef"
+    :content="course.undertaking"
+    @onCommitResult="applyCommittmentResult"
+  />
+
+  <n-modal
+    v-if="STEPS[curentStep] === 'CHECK_ENV_1'"
+    show
+    :closable="false"
+    preset="card"
+    title="进行环境检测"
+    style="width: 600px"
+  >
+    <div>
+      环境检测可以检测电脑的硬件配置、网络速度和常用操作。环境检测不通过的话,可能影响考试的正常进行。
+    </div>
+    <div class="tw-flex tw-justify-center tw-mt-2">
+      <n-button
+        type="success"
+        style="margin-right: 5px; min-width: 120px"
+        @click="skipCheckEnv"
+      >
+        跳过检测
+      </n-button>
+      <n-button
+        type="success"
+        style="margin-right: 5px; min-width: 120px"
+        @click="doCheckEnv"
+      >
+        进行检测
+      </n-button>
+    </div>
+  </n-modal>
+
+  <CheckComputer
+    v-if="STEPS[curentStep] === 'CHECK_ENV_2'"
+    @onClose="curentStep++"
+  />
+
+  <OnlineExamFaceCheckModal
+    v-if="STEPS[curentStep] === 'CHECK_FACE'"
+    @passed="onFacePassed"
+    @close="onFaceClosedByUser"
+  />
+
+  <n-modal
+    v-if="STEPS[curentStep] === 'CHECK_FACE_COMMITTMENT'"
+    :show="true"
+    :closable="false"
+    preset="card"
+    title="郑重承诺"
+    style="width: 600px"
+  >
+    <div>
+      我承诺由本人参加考试,并且同意接受考试监控系统信息审核,一经发现作弊,立即取消本门课程考试成绩。
+    </div>
+    <div class="tw-flex tw-justify-center tw-mt-2">
+      <n-button
+        type="success"
+        style="margin-right: 5px; min-width: 120px"
+        @click="disagreeCommittment"
+      >
+        取消
+      </n-button>
+      <n-button
+        type="success"
+        style="margin-right: 5px; min-width: 120px"
+        @click="enterExam"
+      >
+        确定
+      </n-button>
+    </div>
+  </n-modal>
+</template>

+ 2 - 10
src/features/OnlinePractice/OnlinePractice.vue

@@ -3,6 +3,7 @@ import {
   onlinePracticeCourseListApi,
   onlinePracticeCourseListApi,
   onlinePracticeExamListApi,
   onlinePracticeExamListApi,
 } from "@/api/onlinePractice";
 } from "@/api/onlinePractice";
+import { WEEKDAY_NAMES } from "@/constants/constants";
 import { store } from "@/store/store";
 import { store } from "@/store/store";
 import { PracticeExam } from "@/types/student-client";
 import { PracticeExam } from "@/types/student-client";
 import moment from "moment";
 import moment from "moment";
@@ -70,16 +71,7 @@ function toEnterPracticeList(course: PracticeExam) {
 
 
 // transfer
 // transfer
 function weekDayNameTransfer(week: number): string {
 function weekDayNameTransfer(week: number): string {
-  const weekdayNames: Record<number, string> = {
-    1: "一",
-    2: "二",
-    3: "三",
-    4: "四",
-    5: "五",
-    6: "六",
-    7: "日",
-  };
-  return weekdayNames[week] ? `周${weekdayNames[week]}` : "";
+  return WEEKDAY_NAMES[week] ? `周${WEEKDAY_NAMES[week]}` : "";
 }
 }
 function courseInBetween(course: PracticeExam) {
 function courseInBetween(course: PracticeExam) {
   return moment(nowTime).isBetween(
   return moment(nowTime).isBetween(

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

@@ -185,8 +185,6 @@ type BaseExam = {
   examRecordDataId: number;
   examRecordDataId: number;
   /** 考试类型 */
   /** 考试类型 */
   examType: ExamType;
   examType: ExamType;
-  /** 课程id */
-  courseId: number;
   /** 课程名称 */
   /** 课程名称 */
   courseName: string;
   courseName: string;
   /** 课程层次 */
   /** 课程层次 */
@@ -210,12 +208,16 @@ type ExamCycle = {
 
 
 type OnlineExam = BaseExam &
 type OnlineExam = BaseExam &
   ExamCycle & {
   ExamCycle & {
+    /** 课程id */
+    courseId: number;
     /** 是否显示考生承诺书 */
     /** 是否显示考生承诺书 */
     showUndertaking: boolean;
     showUndertaking: boolean;
     /** 考生承诺书内容 */
     /** 考生承诺书内容 */
     undertaking: string;
     undertaking: string;
     /** 是否启用人脸比对 */
     /** 是否启用人脸比对 */
     faceEnable: boolean;
     faceEnable: boolean;
+    /** 是否启用人脸比对的强制或非强制 */
+    faceCheck: boolean;
     /** 剩余考试次数 */
     /** 剩余考试次数 */
     allowExamCount: number;
     allowExamCount: number;
     /** 是否允许查看客观分 */
     /** 是否允许查看客观分 */
@@ -231,6 +233,8 @@ export type PracticeExam = ExamCycle & {
   examType: ExamType;
   examType: ExamType;
   /** 考生id,用户的每场考试都有一个考生id */
   /** 考生id,用户的每场考试都有一个考生id */
   examStudentId: number;
   examStudentId: number;
+  /** 课程id */
+  courseId: number;
   /** 课程名称 */
   /** 课程名称 */
   courseName: string;
   courseName: string;
   /** 课程code */
   /** 课程code */

+ 135 - 0
src/utils/camera.ts

@@ -0,0 +1,135 @@
+let sharedStream: MediaStream | null = null;
+
+/**
+ * 获得摄像头的视频流,可能抛出异常
+ * 获得的流只能通过本文件中的 closeMediaStream 关闭
+ */
+export async function getMediaStream(): Promise<MediaStream> {
+  if (sharedStream) {
+    logger({ cnl: ["server"], act: "共享sharedStream" });
+    return sharedStream;
+  }
+  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+    logger({
+      cnl: ["local", "server"],
+      pgu: "AUTO",
+      act: "检查navigator.mediaDevices.getUserMedia",
+      key: "不可能的事情发生了",
+    });
+  }
+
+  logger({ cnl: ["server"], pgu: "AUTO", act: "启动摄像头" });
+  logger({
+    cnl: ["server"],
+    pgu: "AUTO",
+    act: "摄像头打开",
+    dtl: "摄像头getSupportedConstraints",
+    ext: {
+      getSupportedConstraints:
+        navigator.mediaDevices.getSupportedConstraints &&
+        JSON.stringify(navigator.mediaDevices.getSupportedConstraints()),
+    },
+  });
+  try {
+    const stream = await navigator.mediaDevices.getUserMedia({
+      video: {
+        facingMode: "user",
+        aspectRatio: 4 / 3,
+        // @ts-expect-error 是支持的属性,应该是typescript的类型还没包含进来
+        resizeMode: "crop-and-scale",
+        width: 640,
+        height: 480,
+      },
+    });
+    if (!stream) {
+      logger({
+        cnl: ["server"],
+        act: "摄像头打开失败",
+        dtl: "没有可用的视频流",
+        key: "不可能的事情发生了",
+      });
+    }
+
+    // logger detail info
+    {
+      const vt0 = stream.getVideoTracks()[0];
+      if (vt0 && vt0.getConstraints && vt0.getSettings) {
+        logger({
+          cnl: ["local", "server"],
+          pgu: "AUTO",
+          ext: {
+            getCapabilities: vt0.getCapabilities(),
+            getConstraints: vt0.getConstraints && vt0.getConstraints(),
+            getSettings: vt0.getSettings && vt0.getSettings(),
+          },
+        });
+      } else {
+        logger({
+          cnl: ["local", "server"],
+          pgu: "AUTO",
+          dtl: "stream.getVideoTracks()[0] failed",
+        });
+      }
+    }
+    sharedStream = stream;
+    return stream;
+  } catch (error) {
+    console.log(error);
+    logger({
+      cnl: ["server"],
+      act: "摄像头打开失败",
+      dtl: "无法启用摄像头",
+      stk: error instanceof Error ? error.message : error + "",
+      ext: { isError: error instanceof Error },
+    });
+
+    if (!(error instanceof Error)) throw new Error("not an error");
+
+    let errMsg;
+    if (error.name || error.message) {
+      errMsg = `${error.name} ${error.message}`;
+    } else {
+      errMsg = error;
+    }
+
+    if (error.name === "NotReadableError") {
+      $message.error("无法启用摄像头: " + error.name + " 请重试!");
+    } else if (
+      error.name === "NotFoundError" ||
+      error.name === "DevicesNotFoundError"
+    ) {
+      $message.error(
+        "无法启用摄像头: " +
+          error.name +
+          " 没有找到合适的摄像头!请重试或更换摄像头!"
+      );
+    } else {
+      $message.error("无法启用摄像头: " + error.name + errMsg);
+    }
+
+    const errorMsgLog =
+      errMsg + (typeof errMsg === "object" ? JSON.stringify(errMsg) : "");
+    logger({
+      cnl: ["server"],
+      act: "摄像头打开失败",
+      dtl: "无法启用摄像头",
+      ejn: JSON.stringify(error),
+      stk: error.stack,
+      ext: {
+        errorName: error.name,
+        errorMessage: error.message,
+        errorMsgLog,
+      },
+    });
+
+    throw error;
+  }
+}
+
+export function closeMediaStream() {
+  logger({ cnl: ["server"], act: "关闭stream" });
+  sharedStream?.getTracks().forEach(function (track) {
+    track.stop();
+  });
+  sharedStream = null;
+}

+ 24 - 2
src/utils/logger.ts

@@ -1,6 +1,6 @@
 import { store } from "@/store/store";
 import { store } from "@/store/store";
 import moment from "moment";
 import moment from "moment";
-import { omit } from "lodash-es";
+import { isNil, omit } from "lodash-es";
 import SlsWebLogger from "js-sls-logger";
 import SlsWebLogger from "js-sls-logger";
 import { VITE_SLS_STORE_NAME } from "@/constants/constants";
 import { VITE_SLS_STORE_NAME } from "@/constants/constants";
 import { electronLog } from "./electronLog";
 import { electronLog } from "./electronLog";
@@ -36,6 +36,12 @@ type LogDetail = {
   aus?: string;
   aus?: string;
   /** error json */
   /** error json */
   ejn?: string;
   ejn?: string;
+  /** error name */
+  ename?: string;
+  /** error message */
+  emsg?: string;
+  /**  可能是个Error对象,如果是Error,这取出标准字段,否则仅转为JSON */
+  possibleError?: any;
   /** 扩展字段的集合。TODO: 提示不允许出去前面的字段 */
   /** 扩展字段的集合。TODO: 提示不允许出去前面的字段 */
   // "not in keyof T" - exclude keyof T from string #42315
   // "not in keyof T" - exclude keyof T from string #42315
   // https://github.com/microsoft/TypeScript/issues/42315
   // https://github.com/microsoft/TypeScript/issues/42315
@@ -52,6 +58,9 @@ type LogDetail = {
  * @param detail.aul - '/api/login'
  * @param detail.aul - '/api/login'
  * @param detail.aus - '500'
  * @param detail.aus - '500'
  * @param detail.ejn - JSON.stringify({a: 0})
  * @param detail.ejn - JSON.stringify({a: 0})
+ * @param detail.ename - error.name
+ * @param detail.emsg - error.message
+ * @param detail.possibleError - possibleError
  * @param detail.ext - {UA: 'chrome 99'}
  * @param detail.ext - {UA: 'chrome 99'}
  */
  */
 export default function createLog(detail: LogDetail) {
 export default function createLog(detail: LogDetail) {
@@ -65,10 +74,23 @@ export default function createLog(detail: LogDetail) {
     ...(user?.id ? { userId: user.id } : {}),
     ...(user?.id ? { userId: user.id } : {}),
     ...(detail?.cnl ? { cnl: detail.cnl } : { cnl: ["console"] }),
     ...(detail?.cnl ? { cnl: detail.cnl } : { cnl: ["console"] }),
   };
   };
+
+  let possibleErrorFields = {};
+  if (detail.possibleError instanceof Error) {
+    possibleErrorFields = {
+      ejn: JSON.stringify(detail.possibleError),
+      ename: detail.possibleError.name,
+      emsg: detail.possibleError.message,
+      stk: detail.possibleError.stack,
+    };
+  } else if (!isNil(detail.possibleError)) {
+    possibleErrorFields = { ejn: JSON.stringify(detail.possibleError) };
+  }
   const newDetail = Object.assign(
   const newDetail = Object.assign(
     defaultFileds,
     defaultFileds,
     omit(detail, "ext"),
     omit(detail, "ext"),
-    detail.ext
+    detail.ext,
+    possibleErrorFields
   );
   );
   // FIXME: 后期设置条件开启非log级别的日志,此时全部打回。
   // FIXME: 后期设置条件开启非log级别的日志,此时全部打回。
   if (newDetail.lvl !== "log") {
   if (newDetail.lvl !== "log") {