ソースを参照

环境检测功能完成

Michael Wang 5 年 前
コミット
85a6c844b2

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

@@ -0,0 +1,572 @@
+<template>
+  <div style="margin: 30px; text-align: left;">
+    <Steps :current="current" size="small">
+      <Step title="时钟"></Step>
+      <Step title="网速"></Step>
+      <Step title="摄像头"></Step>
+      <Step title="声音"></Step>
+      <Step title="微信小程序"></Step>
+      <Step title="检测结果"></Step>
+    </Steps>
+
+    <div v-if="current === 0" 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>{{ timeCurrent }} (北京时间)</td>
+            </tr>
+            <!-- <tr>
+              <td>电脑是否时间准确</td>
+              <td>{{ timeDifference }}</td>
+            </tr> -->
+            <tr>
+              <td>电脑时区</td>
+              <td>{{ timeZone }}</td>
+            </tr>
+            <tr>
+              <td>电脑时钟频率</td>
+              <td>{{ clockRateResult }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <div v-if="current === 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>
+            </tr>
+
+            <tr>
+              <td>电脑当前下载速度</td>
+              <td>{{ network.downlink }}</td>
+            </tr>
+            <tr>
+              <td>电脑当前网络延迟</td>
+              <td>{{ network.rrt }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <div v-show="current === 2" class="section" style="text-align: center">
+      <div>
+        <div style="display: flex;">
+          <video
+            id="video"
+            ref="video"
+            :width="400"
+            :height="300"
+            autoplay
+          ></video>
+
+          <div style="margin-left: 50px; margin-top: 100px;">
+            <Button type="warning" @click="camera = '没有摄像头'">
+              摄像头没有启用
+            </Button>
+            <div style="width: 30px; height: 30px;"></div>
+            <Button type="primary" @click="camera = 'OK'">
+              摄像头正常工作
+            </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>
+            </tr>
+
+            <tr>
+              <td>摄像头没有启用</td>
+              <td>{{ camera }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <div v-show="current === 3" 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
+          />
+
+          <div style="margin-left: 30px; display: flex; ">
+            <Button
+              type="warning"
+              @click="sound = '没有声音'"
+              title="或者听不到声音"
+            >
+              不能播放声音
+            </Button>
+            <div style="width: 30px; height: 30px;"></div>
+            <Button type="primary" @click="sound = 'OK'">
+              能够播放声音
+            </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>
+            </tr>
+
+            <tr>
+              <td>声音</td>
+              <td>{{ sound }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <div v-show="current === 4" class="section">
+      <div>
+        <div style="display: flex;">
+          <div>
+            <div v-if="wechat.qrValue" style="display: flex">
+              <qrcode
+                :value="wechat.qrValue"
+                :options="{ width: 200 }"
+                style="margin-left: -10px;"
+              ></qrcode>
+              <div style="margin-top: 10px;">
+                <div>
+                  请使用<span style="font-weight: 900; color: #1E90FF;"
+                    >微信</span
+                  >扫描二维码后,在微信小程序上录音,并上传文件。
+                </div>
+                <div
+                  v-if="wechat.qrScanned"
+                  style="margin-top: 30px; font-size: 30px;"
+                >
+                  {{ this.wechat.studentAnswer ? "已上传" : "已扫描" }}
+                  <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 ;"
+        >
+          <span class="audio-answer-line-height">答案:</span>
+          <audio
+            class="audio-answer-line-height"
+            v-if="this.wechat.studentAnswer"
+            controls
+            controlsList="nodownload"
+            :src="this.wechat.studentAnswer"
+          />
+          <span v-else class="audio-answer-line-height">未上传文件</span>
+        </div>
+
+        <div style=" margin-top: 30px; display: flex; margin-bottom: 30px;">
+          <Button
+            type="warning"
+            @click="wechat.upload = '不能正确扫描'"
+            title="或者上传不成功"
+          >
+            不能正确扫描,或者上传不成功
+          </Button>
+          <div style="width: 30px; height: 30px;"></div>
+          <Button type="primary" @click="wechat.upload = 'OK'">
+            能够扫描,并上传成功
+          </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>
+            </tr>
+
+            <tr>
+              <td>扫描二维码并上传录音</td>
+              <td>{{ wechat.upload }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <div v-show="current === 5" class="section" style="text-align: center">
+      <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>{{ step1Status }}</td>
+            </tr>
+            <tr>
+              <td>网速</td>
+              <td>{{ step2Status }}</td>
+            </tr>
+            <tr>
+              <td>摄像头</td>
+              <td>{{ step3Status }}</td>
+            </tr>
+            <tr>
+              <td>声音</td>
+              <td>{{ step4Status }}</td>
+            </tr>
+            <tr>
+              <td>微信小程序</td>
+              <td>{{ step5Status }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <div style="margin-top: 30px; text-align: center;">
+      <Button type="primary" @click="previous" :disabled="current === 0">
+        上一步
+      </Button>
+      <div style="width: 30px; height: 1px; display: inline-block;"></div>
+      <Button type="primary" @click="next" :disabled="current === 5">
+        下一步
+      </Button>
+      <div style="width: 30px; height: 1px; display: inline-block;"></div>
+      <Button
+        type="primary"
+        @click="() => this.$router.back()"
+        v-if="current === 5"
+      >
+        返回考试列表
+      </Button>
+    </div>
+  </div>
+</template>
+
+<script>
+import moment from "moment";
+import VueQrcode from "@chenfengyuan/vue-qrcode";
+import {
+  openWS,
+  closeWsWithoutReconnect,
+  getQRCode,
+} from "@/features/OnlineExam/Examing/ws";
+import { createNamespacedHelpers } from "vuex";
+const { mapState } = createNamespacedHelpers("examingHomeModule");
+
+const CLOCK_RATE_TIMEOUT = 10;
+
+export default {
+  name: "check-computer",
+  data() {
+    return {
+      current: 0,
+      // status: ["process", "wait", "wait", "wait", "wait"],
+      nowDate: Date.now(),
+      timeDifference: 0,
+      timeZone:
+        new Date().getTimezoneOffset() / 60 === -8
+          ? "OK"
+          : "请将电脑设置为北京时区",
+      clockRateResult: "检测中",
+      network: {
+        downlink:
+          navigator.connection.downlink > 0.5
+            ? "OK"
+            : `下载速度不佳: ${navigator.connection.downlink}Mb`,
+        rrt:
+          navigator.connection.rtt < 1000
+            ? "OK"
+            : `网络延迟较大${navigator.connection.rtt}`,
+      },
+      camera: "待确认",
+      sound: "待确认",
+      wechat: {
+        qrValue: " ",
+        upload: "待确认",
+        qrScanned: false,
+        studentAnswer: null,
+        examRecordDataId: null,
+      },
+    };
+  },
+  async created() {
+    this.getNowInterval = setInterval(() => {
+      this.nowDate = Date.now();
+    }, 1000);
+    openWS({ examRecordDataId: this.$store.state.user.id });
+    if (!getQRCode(1, "AUDIO", { testEnv: true })) {
+      setTimeout(() => {
+        getQRCode(1, "AUDIO", { testEnv: true });
+      }, 3000);
+    }
+  },
+  async mounted() {
+    let start, end;
+    const networkStart = performance.now();
+    fetch("/oe/login", { Method: "HEAD" }).then(e => {
+      start = moment(e.headers.get("date"));
+      const networkEnd = performance.now();
+
+      this.timeDifference =
+        Math.abs(moment().diff(start) - (networkEnd - networkStart) / 2) <
+        60 * 1000
+          ? "OK"
+          : "与服务器时间差异超过60秒";
+    });
+    this.checkClockRateTimeout = setTimeout(() => {
+      fetch("/oe/login", { Method: "HEAD" }).then(e => {
+        end = moment(e.headers.get("date"));
+        this.clockRateResult =
+          end.diff(start, "seconds") < CLOCK_RATE_TIMEOUT + 2
+            ? "OK"
+            : "时钟过慢";
+      });
+    }, CLOCK_RATE_TIMEOUT * 1000);
+
+    await this.openCamera();
+  },
+  beforeDestroy() {
+    clearInterval(this.getNowInterval);
+    clearTimeout(this.checkClockRateTimeout);
+    closeWsWithoutReconnect();
+  },
+  methods: {
+    previous() {
+      if (this.current >= 0) {
+        this.current -= 1;
+      }
+    },
+    next() {
+      if (this.current < 5) {
+        this.current += 1;
+      }
+    },
+    async openCamera() {
+      const video = this.$refs.video;
+      if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
+        try {
+          console.log("启动摄像头");
+          const stream = await navigator.mediaDevices.getUserMedia({
+            video: {
+              facingMode: "user",
+              // width: 400,
+              // height: this.showRecognizeButton ? 300 : 250
+            },
+          });
+          if (stream) {
+            video.srcObject = stream;
+            try {
+              await video.play();
+            } catch (error) {
+              this.$Message.error({
+                content: "摄像头没有正常启用",
+                duration: 15,
+                closable: true,
+              });
+            }
+          } else {
+            this.$Message.error({
+              content: "没有可用的视频流",
+              duration: 15,
+              closable: true,
+            });
+            window._hmt.push([
+              "_trackEvent",
+              "摄像头框",
+              "摄像头状态",
+              "没有可用的视频流",
+            ]);
+          }
+        } catch (error) {
+          this.$Message.error({
+            content: "无法启用摄像头",
+            duration: 15,
+            closable: true,
+          });
+          window._hmt.push([
+            "_trackEvent",
+            "摄像头框",
+            "摄像头状态",
+            "无法启用摄像头",
+          ]);
+        }
+      } else {
+        this.$Message.error({
+          content: "没有找到可用的摄像头",
+          duration: 15,
+          closable: true,
+        });
+        window._hmt.push([
+          "_trackEvent",
+          "摄像头框",
+          "摄像头状态",
+          "没有找到可用的摄像头",
+        ]);
+      }
+    },
+  },
+  computed: {
+    ...mapState([
+      "questionQrCode",
+      "questionQrCodeScanned",
+      "questionAnswerFileUrl",
+    ]),
+    timeCurrent() {
+      return moment(this.nowDate)
+        .utcOffset("+08:00")
+        .format("YYYY-MM-DD HH:mm:ssZZ");
+    },
+    step1Status() {
+      // if (this.current === 0) {
+      //   return "process";
+      // }
+      // return this.timeDifference === "OK" &&
+      return this.timeZone === "OK" && this.clockRateResult === "OK"
+        ? "OK"
+        : "错误";
+    },
+    step2Status() {
+      // if (this.current <= 1) {
+      //   return "process";
+      // }
+      return this.network.downlink === "OK" && this.network.rrt === "OK"
+        ? "OK"
+        : "错误";
+    },
+    step3Status() {
+      // if (this.current <= 2) {
+      //   return "process";
+      // }
+      // return this.camera === "OK" ? "OK" : "错误";
+      return this.camera;
+    },
+    step4Status() {
+      // if (this.current <= 3) {
+      //   return "process";
+      // }
+      // return this.sound === "OK" ? "OK" : "错误";
+      return this.sound;
+    },
+    step5Status() {
+      // if (this.current <= 4) {
+      //   return "process";
+      // }
+      // return this.wechat.upload === "OK" ? "OK" : "错误";
+      return this.wechat.upload;
+    },
+  },
+  watch: {
+    questionQrCode(value) {
+      // console.log(this.examQuestion.studentAnswer);
+      // console.log("watch", value);
+      this.wechat.qrValue = value.qrCode;
+      this.wechat.examRecordDataId = decodeURIComponent(value.qrCode).match(
+        /&examRecordDataId=(\d+)&/
+      )[1];
+    },
+    questionQrCodeScanned() {
+      // console.log(this.examQuestion.studentAnswer);
+      // console.log("watch", value);
+      this.wechat.qrScanned = true;
+    },
+    questionAnswerFileUrl(value) {
+      // console.log(this.examQuestion.studentAnswer);
+      // console.log("watch", 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;
+              q.saved = true;
+              if (acknowledgeStatus === "CONFIRMED")
+                this.$Message.info({
+                  content: "小程序作答已更新",
+                  duration: 5,
+                  closable: true,
+                });
+            })
+            .catch(() => {
+              this.$Message.error({
+                content: "更新小程序答案失败!",
+                duration: 15,
+                closable: true,
+              });
+            });
+        }
+      }
+    },
+  },
+  components: {
+    qrcode: VueQrcode,
+  },
+};
+</script>
+
+<style scoped>
+.section {
+  margin-top: 10px;
+}
+
+.list {
+  border: 1px solid #eeeeee;
+  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;
+}
+.list .first-td {
+  width: 50%;
+}
+</style>

+ 2 - 19
src/features/OnlineExam/Examing/ws.js

@@ -81,11 +81,6 @@ export function openWS({ examRecordDataId }) {
   };
 }
 
-// function tryWSReconnect() {
-//   // socket.close();
-//   openWS();
-// }
-
 function heartbeat() {
   heartbeatId = setInterval(() => {
     ws.send(
@@ -105,13 +100,14 @@ export function closeWsWithoutReconnect() {
   }
 }
 
-export function getQRCode(order, transferFileType) {
+export function getQRCode(order, transferFileType, testEnv) {
   if (ws.readyState === ws.OPEN) {
     ws.send(
       JSON.stringify({
         eventType: "GET_QR_CODE",
         order,
         transferFileType: transferFileType,
+        ...testEnv,
       })
     );
     return true;
@@ -154,19 +150,6 @@ function processWSMessage(event) {
       console.log("get file url", res);
       window._hmt.push(["_trackEvent", "websocket", "获得音频地址"]);
 
-      // location
-      // var urlOrder = window.location.pathname.split("/").pop();
-      // if (ws.readyState === ws.OPEN && res.data.order != urlOrder) {
-      //   ws.send(
-      //     JSON.stringify({
-      //       eventType: "REJECT_FILE",
-      //       order: res.data.order,
-      //       transferFileType: res.data.transferFileType,
-      //       fileUrl: res.data.fileUrl,
-      //     })
-      //   );
-      // }
-
       store.commit("examingHomeModule/setQuestionFileAnswerUrl", {
         order: res.data.order,
         fileUrl: res.data.fileUrl,

+ 64 - 4
src/features/OnlineExam/OnlineExamList.vue

@@ -11,7 +11,7 @@
           <td style="max-width: 200px">操作</td>
         </tr>
 
-        <tr v-for="course in courses" :key="course.id">
+        <tr v-for="course in courses" :key="course.courseId">
           <td>{{ course.courseName }}</td>
           <td v-if="!isEpcc">{{ course.courseLevel }}</td>
           <td v-if="!isEpcc">{{ course.specialtyName }}</td>
@@ -38,7 +38,7 @@
               <i-poptip
                 v-if="!isEpcc"
                 :trigger="course.isObjScoreView ? 'hover' : 'click'"
-                @on-popper-show="cid = course.id"
+                @on-popper-show="cid = course.courseId"
                 @on-popper-hide="cid = null"
                 placement="left"
                 class="online-exam-list-override-poptip"
@@ -52,7 +52,7 @@
                 </i-button>
                 <ecs-online-exam-result-list
                   slot="content"
-                  :popperShow="cid === course.id"
+                  :popperShow="cid === course.courseId"
                   :examStudentId="course.examStudentId"
                 ></ecs-online-exam-result-list>
               </i-poptip>
@@ -111,7 +111,7 @@ export default {
         moment(course.endTime)
       );
     },
-    async enterExam(course) {
+    async enterExam(course, alreadyChecked) {
       this.spinShow = true;
       this.processingMessage = "正在检测断点续考信息...";
 
@@ -167,6 +167,51 @@ export default {
 
         this.processingMessage = "正在获取考试设置...";
 
+        if (!alreadyChecked) {
+          let checkEnv = null;
+          try {
+            checkEnv = await this.$http.get(
+              "/api/ecs_exam_work/exam/examOrgPropertyFromCache4StudentSession/" +
+                course.examId +
+                `/CHECK_ENVIRONMENT`
+            );
+            if (checkEnv.data.CHECK_ENVIRONMENT === "true") {
+              const skipCheck = await new Promise(resolve => {
+                this.$Modal.confirm({
+                  title: "进行环境检测",
+                  content:
+                    "环境检测可以检测电脑的硬件配置、网络速度和常用操作。环境检测不通过的话,可能影响考试的正常进行。",
+                  okText: "进行检测",
+                  cancelText: "跳过检测",
+                  onOk: () => {
+                    sessionStorage.setItem(
+                      "computer_env_ok_save_course_id",
+                      course.courseId
+                    );
+                    this.$router.push("/check-computer");
+                    resolve();
+                  },
+                  onCancel: () => {
+                    resolve(true);
+                  },
+                });
+              });
+              if (!skipCheck) {
+                this.spinShow = false;
+                return;
+              }
+            }
+          } catch (error) {
+            this.spinShow = false;
+            this.$Message.error({
+              content: "查询考试的环境检测设置属性出错!",
+              duration: 15,
+              closable: true,
+            });
+            return;
+          }
+        }
+
         let faceLiveness = null;
         try {
           faceLiveness = await this.$http.get(
@@ -249,6 +294,21 @@ export default {
       return this.user.schoolDomain === "iepcc-ps.ecs.qmth.com.cn";
     },
   },
+  watch: {
+    courses(value) {
+      if (value.length > 0) {
+        let courseId = sessionStorage.getItem("computer_env_ok_save_course_id");
+        if (courseId !== null) {
+          courseId = +courseId; // 转为数字
+          const course = value.find(v => v.courseId === courseId);
+          if (course) {
+            this.enterExam(course, true);
+            sessionStorage.removeItem("computer_env_ok_save_course_id");
+          }
+        }
+      }
+    },
+  },
   components: {
     "ecs-online-exam-result-list": OnlineExamResultList,
     OnlineExamFaceCheckModal,

+ 4 - 0
src/plugins/iview.js

@@ -23,6 +23,8 @@ import {
   Badge,
   Tabs,
   TabPane,
+  Steps,
+  Step,
 } from "iview";
 Vue.component("Button", Button);
 Vue.component("Form", Form);
@@ -45,6 +47,8 @@ Vue.component("Table", Table);
 Vue.component("Badge", Badge);
 Vue.component("Tabs", Tabs);
 Vue.component("TabPane", TabPane);
+Vue.component("Steps", Steps);
+Vue.component("Step", Step);
 
 Vue.prototype.$Message = Message;
 Vue.prototype.$Modal = Modal;

+ 6 - 1
src/router.js

@@ -15,6 +15,7 @@ import StudentAccess from "./features/Login/StudentAccess.vue";
 import SiteMessageHome from "./features/SiteMessage/SiteMessageHome.vue";
 import SiteMessageDetail from "./features/SiteMessage/SiteMessageDetail.vue";
 import Password from "./features/Password/Password.vue";
+import CheckComputer from "./features/OnlineExam/CheckComputer.vue";
 
 Vue.use(Router);
 let router = new Router({
@@ -92,7 +93,11 @@ let router = new Router({
       name: "Password",
       component: Password,
     },
-
+    {
+      path: "/check-computer",
+      name: "CheckComputer",
+      component: CheckComputer,
+    },
     {
       path: "*",
       component: NotFoundComponent,