瀏覽代碼

1.1页面修改

zhangjie 3 年之前
父節點
當前提交
6ce431404f

+ 3 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "exam-admin",
-  "version": "1.0.0",
+  "version": "1.1.0",
   "private": true,
   "scripts": {
     "serve": "vue-cli-service serve",
@@ -16,6 +16,7 @@
     "start": "vue-cli-service serve"
   },
   "dependencies": {
+    "@chenfengyuan/vue-qrcode": "1.0.2",
     "axios": "^0.19.2",
     "axios-progress-bar": "^1.2.0",
     "axios-retry": "^3.1.8",
@@ -31,7 +32,7 @@
     "moment": "^2.27.0",
     "query-string": "^6.13.1",
     "register-service-worker": "^1.7.1",
-    "trtc-js-sdk": "^4.6.1",
+    "trtc-js-sdk": "^4.12.1",
     "vue": "^2.6.11",
     "vue-awesome": "^4.1.0",
     "vue-echarts": "^5.0.0-beta.0",

二進制
src/assets/icon-error.png


+ 337 - 188
src/features/examwork/ExamManagement/ExamEdit.vue

@@ -197,12 +197,11 @@
           :model="form"
           :rules="rules"
           label-width="180px"
-          inline
           :disabled="disableEdit"
         >
           <el-row class="tab-invililation">
             <h2>开考检测</h2>
-            <el-form-item label="">
+            <el-form-item label="" label-width="0px">
               <div class="d-flex flex-column tab-invililation-radio">
                 <el-radio v-model="form.entryAuthenticationPolicy" label="OFF">
                   安全级别:<span style="color: #202b4b; font-size: 20px;">
@@ -381,152 +380,120 @@
               </el-radio>
             </el-form-item>
           </el-row>
-          <el-row>
-            <el-form-item
-              v-if="form.monitorProxy && enablePrevilleges"
-              label="是否需要视频回放"
-            >
-              <el-radio v-model="form.monitorRecord" label="MIX">是 </el-radio>
-              <el-radio v-model="form.monitorRecord" label="OFF">否 </el-radio>
-            </el-form-item>
-          </el-row>
-          <el-row>
-            <el-form-item v-if="form.monitorProxy" label="电脑&手机监控方案">
-              <el-checkbox-group v-model="form.monitorVideoSource">
-                <div class="d-flex">
-                  <div class="d-flex flex-column justify-content-between">
-                    <el-checkbox label="CLIENT_CAMERA"
-                      >电脑摄像头主机位</el-checkbox
-                    >
-                    <el-checkbox
-                      label="CLIENT_SCREEN"
-                      :disabled="
-                        !form.monitorVideoSource.includes('CLIENT_CAMERA')
-                      "
-                      :title="
-                        !form.monitorVideoSource.includes('CLIENT_CAMERA') &&
-                        '原因:先选择电脑摄像头主机位'
-                      "
-                      >电脑开启录屏</el-checkbox
-                    >
-                    <el-checkbox label="MOBILE_FIRST">手机主机位</el-checkbox>
-                    <el-checkbox
-                      :disabled="
-                        !form.monitorVideoSource.includes('MOBILE_FIRST')
-                      "
-                      :title="
-                        !form.monitorVideoSource.includes('MOBILE_FIRST') &&
-                        '原因:先选择手机主机位'
-                      "
-                      label="MOBILE_SECOND"
-                      >手机辅机位</el-checkbox
-                    >
-                    <span style="color: red; font-size: 12px;"
-                      >*主机位设备负责收音及播放监考提示</span
-                    >
-                  </div>
-                  <div
-                    style="
-                      margin-left: 20px;
-                      padding: 10px;
-                      font-size: 10px;
-                      background: #f0f4f9;
-                      position: relative;
-                    "
-                  >
-                    <div
-                      style="
-                        position: absolute;
-                        width: 108px;
-                        height: 40px;
-                        border-left: 1px dotted grey;
-                        border-bottom: 1px dotted grey;
-                        left: 35px;
-                        top: 115px;
-                        border-radius: 5px;
-                      "
-                      :class="
-                        form.monitorVideoSource.includes('MOBILE_SECOND') &&
-                        'enhance-left-bottom-line'
-                      "
-                    ></div>
-                    <div
-                      style="
-                        position: absolute;
-                        width: 108px;
-                        height: 40px;
-                        border-right: 1px dotted grey;
-                        border-top: 1px dotted grey;
-                        right: 35px;
-                        bottom: 122px;
-                        border-radius: 5px;
-                      "
-                      :class="
-                        form.monitorVideoSource.includes('MOBILE_FIRST') &&
-                        'enhance-right-bottom-line'
-                      "
-                    ></div>
-                    <div
-                      style="
-                        position: absolute;
-                        width: 2px;
-                        height: 10px;
-                        border-right: 1px dotted grey;
-                        left: 236px;
-                        top: 92px;
-                      "
-                      :class="
-                        form.monitorVideoSource.includes('CLIENT_CAMERA') &&
-                        'enhance-right-line'
-                      "
-                    ></div>
-                    <div class="d-flex">
-                      <div
-                        class="d-flex flex-column justify-content-center align-items-center"
-                        style="width: 52px !important;"
-                      >
-                        <div :class="monitorImgSrc('MOBILE_SECOND')" />
-                        <span style="font-size: 10px;">手机辅机位</span>
-                      </div>
-                      <div
-                        class="d-flex flex-column justify-content-center align-items-center"
-                        style="width: 350px !important;"
-                      >
-                        <div :class="monitorImgSrc('CLIENT_CAMERA')" />
-                        <span style="font-size: 10px;">电脑摄像头主机位</span>
-                      </div>
-                      <div style="width: 52px !important;"></div>
-                    </div>
-                    <div
-                      class="d-flex"
-                      style="margin-bottom: -60px; margin-top: -30px;"
-                    >
-                      <div style="width: 52px !important;"></div>
-                      <div
-                        class="d-flex flex-column justify-content-center align-items-center"
-                        style="width: 350px !important;"
-                      >
-                        <div :class="monitorImgSrc('CLIENT_SCREEN')" />
-                        <span style="font-size: 10px;">电脑开启录屏</span>
-                      </div>
-                      <div style="width: 52px !important;"></div>
-                    </div>
-                    <div class="d-flex">
-                      <div style="width: 52px !important;"></div>
-                      <div style="width: 350px !important;"></div>
-                      <div
-                        class="d-flex flex-column justify-content-center align-items-center"
-                        style="width: 52px !important;"
-                      >
-                        <div :class="monitorImgSrc('MOBILE_FIRST')" />
-                        <span style="font-size: 10px;">手机主机位</span>
-                      </div>
-                    </div>
-                  </div>
-                </div>
-              </el-checkbox-group>
-            </el-form-item>
-          </el-row>
+          <div
+            class="monitor-config"
+            v-if="form.monitorProxy && enablePrevilleges"
+          >
+            <div class="monitor-config-options">
+              <h3 class="monitor-title">监考直播方案配置:</h3>
+              <el-form-item label="电脑摄像头主机位">
+                <el-radio-group
+                  v-model="monitorVideoSource.CLIENT_CAMERA"
+                  @change="monitorClientCameraChange"
+                >
+                  <el-radio
+                    v-for="(val, key) in monitorTypes"
+                    :key="key"
+                    :label="key"
+                    >{{ val }}
+                  </el-radio>
+                </el-radio-group>
+              </el-form-item>
+              <el-form-item label="电脑操作录屏">
+                <el-radio-group
+                  v-model="monitorVideoSource.CLIENT_SCREEN"
+                  :disabled="monitorVideoSource.CLIENT_CAMERA === '0'"
+                  :title="
+                    monitorVideoSource.CLIENT_CAMERA === '0' &&
+                    '原因:先选择电脑摄像头主机位'
+                  "
+                  @change="monitorClientScreenChange"
+                >
+                  <el-radio
+                    v-for="(val, key) in monitorTypes"
+                    :key="key"
+                    :label="key"
+                    >{{ val }}
+                  </el-radio>
+                </el-radio-group>
+              </el-form-item>
+              <el-form-item label="手机监考主机位">
+                <el-radio-group
+                  v-model="monitorVideoSource.MOBILE_FIRST"
+                  @change="monitorMobileFirstChange"
+                >
+                  <el-radio
+                    v-for="(val, key) in monitorTypes"
+                    :key="key"
+                    :label="key"
+                    >{{ val }}
+                  </el-radio>
+                </el-radio-group>
+              </el-form-item>
+              <el-form-item label="手机监考辅机位">
+                <el-radio-group
+                  v-model="monitorVideoSource.MOBILE_SECOND"
+                  :disabled="monitorVideoSource.MOBILE_FIRST === '0'"
+                  :title="
+                    monitorVideoSource.MOBILE_FIRST === '0' &&
+                    '原因:先选择手机监考主机位'
+                  "
+                  @change="monitorVideoSourceChange"
+                >
+                  <el-radio
+                    v-for="(val, key) in monitorTypes"
+                    :key="key"
+                    :label="key"
+                    >{{ val }}
+                  </el-radio>
+                </el-radio-group>
+              </el-form-item>
+              <p class="tips-info tips-error">
+                *主机位设备负责收音及播放监考提示
+              </p>
+            </div>
+            <div class="monitor-config-view">
+              <div class="monitor-item monitor-client-camera">
+                <div
+                  :class="['monitor-item-img', monitorImgSrc('CLIENT_CAMERA')]"
+                />
+                <p class="monitor-item-desc">电脑摄像头主机位</p>
+              </div>
+              <div class="monitor-item monitor-client-screen">
+                <div
+                  :class="['monitor-item-img', monitorImgSrc('CLIENT_SCREEN')]"
+                />
+                <p class="monitor-item-desc">电脑开启录屏</p>
+              </div>
+              <div class="monitor-item monitor-mobile-first">
+                <div
+                  :class="['monitor-item-img', monitorImgSrc('MOBILE_FIRST')]"
+                />
+                <p class="monitor-item-desc">手机监考主机位</p>
+              </div>
+              <div class="monitor-item monitor-mobile-second">
+                <div
+                  :class="['monitor-item-img', monitorImgSrc('MOBILE_SECOND')]"
+                />
+                <p class="monitor-item-desc">手机监考辅机位</p>
+              </div>
+              <div
+                v-if="monitorVideoSource.MOBILE_SECOND !== '0'"
+                class="monitor-line-mobile-second"
+              >
+                <span></span>
+                <span></span>
+              </div>
+              <div
+                v-if="monitorVideoSource.MOBILE_FIRST !== '0'"
+                class="monitor-line-mobile-first"
+              ></div>
+              <div
+                v-if="monitorVideoSource.CLIENT_CAMERA !== '0'"
+                class="monitor-line-client-camera"
+              ></div>
+            </div>
+          </div>
         </el-form>
       </el-tab-pane>
 
@@ -678,41 +645,13 @@ export default {
       handler(v) {
         if (!v) {
           this.form.monitorVideoSource = [];
-          this.form.monitorRecord = "OFF";
+          this.form.monitorRecord = [];
         }
         if (this.form.monitorVideoSource === null) {
           this.form.monitorVideoSource = [];
         }
       },
     },
-    "form.monitorVideoSource": {
-      immediate: true,
-      handler(v, ov) {
-        if (!v) {
-          this.form.monitorVideoSource = [];
-        }
-        if (
-          // 没动静,不修改,避免死循环
-          (v || []).includes("MOBILE_FIRST") !==
-            (ov || []).includes("MOBILE_FIRST") &&
-          !this.form.monitorVideoSource.includes("MOBILE_FIRST")
-        ) {
-          this.form.monitorVideoSource = this.form.monitorVideoSource.filter(
-            (v) => v !== "MOBILE_SECOND"
-          );
-        }
-        if (
-          // 没动静,不修改,避免死循环
-          (v || []).includes("CLIENT_CAMERA") !==
-            (ov || []).includes("CLIENT_CAMERA") &&
-          !this.form.monitorVideoSource.includes("CLIENT_CAMERA")
-        ) {
-          this.form.monitorVideoSource = this.form.monitorVideoSource.filter(
-            (v) => v !== "CLIENT_SCREEN"
-          );
-        }
-      },
-    },
     "form.cameraPhotoUpload": {
       handler(v) {
         if (v) {
@@ -742,9 +681,12 @@ export default {
         this.form.inProcessLivenessFixedRange = [0, 0];
       }
       this.form.startEndTimeProxy = [this.form.startTime, this.form.endTime];
+      this.form.monitorRecord = this.form.monitorRecord || [];
+      this.form.monitorVideoSource = this.form.monitorVideoSource || [];
       this.form.monitorProxy = !!this.form.monitorVideoSource;
       this.form.preNoticeClone = this.form.preNotice;
       this.form.postNoticeClone = this.form.postNotice;
+      this.parseMonitorVideoSource();
     }
 
     // sleep
@@ -794,7 +736,7 @@ export default {
         inProcessLivenessFixedRange: [0, 0],
         inProcessLivenessJudgePolicy: "ALL",
         monitorProxy: false,
-        monitorRecord: "OFF",
+        monitorRecord: [],
         monitorVideoSource: [],
         ipAllow: "",
       },
@@ -878,7 +820,7 @@ export default {
         breakExpireSeconds: {
           validator: (rule, value) => {
             return new Promise((resolve, reject) => {
-              if (this.form.monitorRecord === "MIX" && value > 20 * 60) {
+              if (this.form.monitorRecord.length && value > 20 * 60) {
                 reject("视频回放开启后,断点失效时间必须小于20分钟");
               } else {
                 resolve();
@@ -922,13 +864,30 @@ export default {
           },
         },
       },
+      sources: [
+        "MOBILE_FIRST",
+        "MOBILE_SECOND",
+        "CLIENT_CAMERA",
+        "CLIENT_SCREEN",
+      ],
+      monitorTypes: {
+        0: "关闭",
+        1: "直播",
+        2: "直播+回放",
+      },
+      monitorVideoSource: {
+        CLIENT_CAMERA: "0",
+        CLIENT_SCREEN: "0",
+        MOBILE_FIRST: "0",
+        MOBILE_SECOND: "0",
+      },
       orgSetting: null,
       loading: false,
     };
   },
   methods: {
     monitorImgSrc(monitorSource) {
-      const selected = this.form.monitorVideoSource.includes(monitorSource);
+      const selected = this.monitorVideoSource[monitorSource] !== "0";
       const selectedStr = selected ? "-selected" : "";
       if (monitorSource === "MOBILE_FIRST") {
         return `mobile-first-img${selectedStr}`;
@@ -945,6 +904,54 @@ export default {
 
       return "";
     },
+    monitorClientCameraChange(val) {
+      if (val === "0") {
+        this.monitorVideoSource.CLIENT_SCREEN = "0";
+      }
+      if (val === "2" && this.monitorVideoSource.CLIENT_SCREEN === "2") {
+        this.monitorVideoSource.CLIENT_SCREEN = "1";
+      }
+
+      this.monitorVideoSourceChange();
+    },
+    monitorClientScreenChange(val) {
+      if (val === "2" && this.monitorVideoSource.CLIENT_CAMERA === "2") {
+        this.monitorVideoSource.CLIENT_CAMERA = "1";
+      }
+
+      this.monitorVideoSourceChange();
+    },
+    monitorMobileFirstChange(val) {
+      if (val === "0") this.monitorVideoSource.MOBILE_SECOND = "0";
+      this.monitorVideoSourceChange();
+    },
+    monitorVideoSourceChange() {
+      let monitorVideoSource = [],
+        monitorRecord = [];
+      this.sources.forEach((source) => {
+        if (this.monitorVideoSource[source] === "0") return;
+        if (this.monitorVideoSource[source] === "2") {
+          monitorRecord.push(source);
+        }
+        monitorVideoSource.push(source);
+      });
+
+      this.form.monitorVideoSource = monitorVideoSource;
+      this.form.monitorRecord = monitorRecord;
+    },
+    parseMonitorVideoSource() {
+      this.sources.forEach((source) => {
+        const hasRecord = this.form.monitorRecord.includes(source);
+        const hasSource = this.form.monitorVideoSource.includes(source);
+        if (hasRecord) {
+          this.monitorVideoSource[source] = "2";
+        } else if (hasSource) {
+          this.monitorVideoSource[source] = "1";
+        } else {
+          this.monitorVideoSource[source] = "0";
+        }
+      });
+    },
     async save() {
       try {
         await this.$refs.form1.validate();
@@ -1018,6 +1025,79 @@ export default {
   }
 }
 
+.monitor-config {
+  &-options {
+    display: inline-block;
+    vertical-align: top;
+    width: 470px;
+
+    .el-form-item {
+      margin-bottom: 10px;
+    }
+  }
+
+  &-view {
+    position: relative;
+    display: inline-block;
+    vertical-align: top;
+    width: 498px;
+    height: 257px;
+    background: #f0f4f9;
+    border-radius: 6px;
+    border: 1px solid #e8edf3;
+  }
+}
+.monitor-title {
+  height: 20px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #202b4b;
+  line-height: 20px;
+  margin-bottom: 20px;
+  padding-left: 20px;
+
+  &::before {
+    content: "";
+    display: inline-block;
+    vertical-align: middle;
+    width: 4px;
+    height: 10px;
+    background: #1886fe;
+    border-radius: 2px;
+    margin-right: 10px;
+  }
+}
+
+.monitor-item {
+  position: absolute;
+  text-align: center;
+
+  &-img {
+    margin: 0 auto;
+  }
+  &-desc {
+    font-size: 12px;
+    line-height: 1;
+    margin: 5px 0 0;
+  }
+}
+.monitor-client-camera {
+  left: 171px;
+  top: 21px;
+}
+.monitor-client-screen {
+  left: 124px;
+  bottom: 20px;
+}
+.monitor-mobile-first {
+  left: 20px;
+  top: 20px;
+}
+.monitor-mobile-second {
+  bottom: 20px;
+  right: 20px;
+}
+
 .mobile-first-img {
   background-image: url(./imgs/手机监考主机位.png);
   background-repeat: no-repeat;
@@ -1065,18 +1145,87 @@ export default {
     background-image: url(./imgs/电脑操作录屏-选中.png);
   }
 }
+.monitor-line-point {
+  content: "";
+  display: block;
+  position: absolute;
+  width: 5px;
+  height: 5px;
+  border: 1px solid #c1cbdb;
+  border-radius: 50%;
+  background: #f0f4f9;
+}
+.monitor-line-client-camera {
+  position: absolute;
+  width: 0;
+  height: 23px;
+  border-left: 1px solid #c1cbdb;
+  top: 89px;
+  left: 219px;
 
-.enhance-left-bottom-line {
-  border-left-style: solid !important;
-  border-bottom-style: solid !important;
+  &::before {
+    @extend .monitor-line-point;
+    left: -3px;
+    top: 0;
+  }
+  &::after {
+    @extend .monitor-line-point;
+    left: -3px;
+    bottom: 0;
+  }
 }
+.monitor-line-mobile-first {
+  position: absolute;
+  width: 56px;
+  height: 28px;
+  bottom: 96px;
+  left: 63px;
+  border-left: 1px solid #c1cbdb;
+  border-bottom: 1px solid #c1cbdb;
 
-.enhance-right-bottom-line {
-  border-right-style: solid !important;
-  border-top-style: solid !important;
+  &::before {
+    @extend .monitor-line-point;
+    left: -3px;
+    top: 0;
+  }
+  &::after {
+    @extend .monitor-line-point;
+    right: 0;
+    bottom: -3px;
+  }
 }
+.monitor-line-mobile-second {
+  span:nth-of-type(1) {
+    display: block;
+    position: absolute;
+    width: 35px;
+    height: 63px;
+    bottom: 92px;
+    right: 139px;
+    border-right: 1px solid #c1cbdb;
+    border-bottom: 1px solid #c1cbdb;
 
-.enhance-right-line {
-  border-right-style: solid !important;
+    &::before {
+      @extend .monitor-line-point;
+      left: 0;
+      bottom: -3px;
+    }
+  }
+  span:nth-of-type(2) {
+    display: block;
+    position: absolute;
+    width: 78px;
+    height: 23px;
+    top: 100px;
+    right: 61px;
+    border-right: 1px solid #c1cbdb;
+    border-top: 1px solid #c1cbdb;
+
+    &::after {
+      @extend .monitor-line-point;
+      right: -3px;
+      bottom: 0;
+    }
+  }
 }
 </style>

二進制
src/features/examwork/ExamManagement/imgs/手机监考主机位-选中.png


二進制
src/features/examwork/ExamManagement/imgs/手机监考主机位.png


二進制
src/features/examwork/ExamManagement/imgs/手机监考辅机位-选中.png


二進制
src/features/examwork/ExamManagement/imgs/手机监考辅机位.png


+ 74 - 0
src/features/examwork/StudentManagement/HlsMedia.vue

@@ -0,0 +1,74 @@
+<template>
+  <div class="hls-media">
+    <video ref="VideoMedia" muted></video>
+  </div>
+</template>
+
+<script>
+import Hls from "hls.js";
+
+export default {
+  name: "hls-media",
+  props: {
+    url: {
+      type: String,
+    },
+  },
+  data() {
+    return {
+      player: null,
+      hls: null,
+    };
+  },
+  mounted() {
+    this.initVideo();
+  },
+  methods: {
+    initVideo() {
+      if (!this.url) return;
+      if (!Hls.isSupported()) return;
+
+      this.hls = new Hls(this.$refs.VideoMedia);
+      this.hls.loadSource(this.url);
+      this.hls.attachMedia(this.$el);
+      this.player = this.$refs.VideoMedia;
+      this.player.play();
+
+      this.hls.on(Hls.Events.ERROR, (event, data) => {
+        if (data.fatal) {
+          switch (data.type) {
+            case Hls.ErrorTypes.NETWORK_ERROR:
+              console.log("fatal network error encountered, try to recover");
+              this.hls.startLoad();
+              break;
+            case Hls.ErrorTypes.MEDIA_ERROR:
+              console.log("fatal media error encountered, try to recover");
+              this.hls.recoverMediaError();
+              break;
+            default:
+              this.hls.destroy();
+              break;
+          }
+        }
+      });
+    },
+    playPlayer() {
+      this.player.play();
+    },
+    pausePlayer() {
+      this.player.pause();
+    },
+    destroyPlayer() {
+      if (!this.player) return;
+      this.player.pause();
+      this.player.unload();
+      this.player = null;
+      this.hls.destroy();
+      this.hls = null;
+    },
+  },
+  beforeDestroy() {
+    this.destroyPlayer();
+  },
+};
+</script>

+ 71 - 55
src/features/examwork/StudentManagement/StudentManagementDialog.vue

@@ -1,61 +1,73 @@
 <template>
-  <el-dialog
-    ref="dialog"
-    title="考试记录"
-    width="600px"
-    :visible.sync="visible"
-    @close="closeDialog"
-  >
-    <el-form
-      :model="form"
-      ref="form"
-      :rules="rules"
-      label-position="right"
-      label-width="120px"
-      inline
+  <div>
+    <el-dialog
+      ref="dialog"
+      title="考试记录"
+      width="600px"
+      :visible.sync="visible"
+      @close="closeDialog"
     >
-      <el-form-item label="批次名称" prop="examId">
-        <ExamSelect v-model="form.examId" />
-      </el-form-item>
-      <el-button type="primary" @click="handleCurrentChange(0)">查询</el-button>
-    </el-form>
+      <el-form
+        :model="form"
+        ref="form"
+        :rules="rules"
+        label-position="right"
+        label-width="120px"
+        inline
+      >
+        <el-form-item label="批次名称" prop="examId">
+          <ExamSelect v-model="form.examId" />
+        </el-form-item>
+        <el-button type="primary" @click="handleCurrentChange(0)"
+          >查询</el-button
+        >
+      </el-form>
 
-    <el-table :data="tableData" stripe style="width: 100%;">
-      <el-table-column width="100" label="姓名">
-        <span slot-scope="scope">{{ scope.row.name }}</span>
-      </el-table-column>
-      <el-table-column width="180" label="证件号">
-        <span slot-scope="scope">{{ scope.row.identity }}</span>
-      </el-table-column>
-      <el-table-column label="批次名称">
-        <span slot-scope="scope">{{ scope.row.examName }}</span>
-      </el-table-column>
-      <el-table-column width="100" label="课程">
-        <span slot-scope="scope">{{ scope.row.courseName }}</span>
-      </el-table-column>
-      <el-table-column width="100" label="监考回放">
-        <span slot-scope="scope">{{
-          (scope.row.monitorRecord || []).length > 1 ? "查看" : "无"
-        }}</span>
-      </el-table-column>
-      <el-table-column width="100" label="状态">
-        <span slot-scope="scope">{{ scope.row.status }}</span>
-      </el-table-column>
-    </el-table>
-    <div class="page float-right">
-      <el-pagination
-        background
-        @current-change="handleCurrentChange"
-        :current-page="currentPage"
-        :page-size="pageSize"
-        :page-sizes="[10, 20, 50, 100, 200, 300]"
-        @size-change="handleSizeChange"
-        layout="total, sizes, prev, pager, next, jumper"
-        :total="total"
-      />
-    </div>
-    <div class="my-2"></div>
-  </el-dialog>
+      <el-table :data="tableData" stripe style="width: 100%;">
+        <el-table-column width="100" label="姓名">
+          <span slot-scope="scope">{{ scope.row.name }}</span>
+        </el-table-column>
+        <el-table-column width="180" label="证件号">
+          <span slot-scope="scope">{{ scope.row.identity }}</span>
+        </el-table-column>
+        <el-table-column label="批次名称">
+          <span slot-scope="scope">{{ scope.row.examName }}</span>
+        </el-table-column>
+        <el-table-column width="100" label="课程">
+          <span slot-scope="scope">{{ scope.row.courseName }}</span>
+        </el-table-column>
+        <el-table-column width="100" label="状态">
+          <span slot-scope="scope">{{ scope.row.status }}</span>
+        </el-table-column>
+        <el-table-column width="100" label="操作">
+          <template slot-scope="scope">
+            <el-button
+              v-if="scope.row.monitorRecord && scope.row.monitorRecord.length"
+              size="mini"
+              type="primary"
+              plain
+              @click="openMonitorRecord(scope.row)"
+            >
+              监考回放
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div class="page float-right">
+        <el-pagination
+          background
+          @current-change="handleCurrentChange"
+          :current-page="currentPage"
+          :page-size="pageSize"
+          :page-sizes="[10, 20, 50, 100, 200, 300]"
+          @size-change="handleSizeChange"
+          layout="total, sizes, prev, pager, next, jumper"
+          :total="total"
+        />
+      </div>
+      <div class="my-2"></div>
+    </el-dialog>
+  </div>
 </template>
 
 <script>
@@ -123,6 +135,10 @@ export default {
         this.handleCurrentChange(this.currentPage - 1);
       }
     },
+    // monitor record
+    openMonitorRecord(row) {
+      console.log(row);
+    },
   },
 };
 </script>

+ 266 - 0
src/features/examwork/StudentManagement/StudentMonitorRecord.vue

@@ -0,0 +1,266 @@
+<template>
+  <div class="student-monitor-record">
+    <div class="record-header">
+      <div class="header-info">
+        <span class="header-info-name">
+          <i class="icon icon-user-act"></i>
+          {{ info.examStudentName }}
+        </span>
+        <span class="header-info-item"> 证件号:{{ info.identity }} </span>
+        <span class="header-info-item"> 课程:{{ info.courseName }} </span>
+        <span class="header-info-item">
+          批次名称:{{ info.examBatchName }}
+        </span>
+        <span class="header-info-item"> 考试时间:{{ info.examTime }} </span>
+      </div>
+      <div>
+        <el-button
+          v-for="source in videoSources"
+          :key="source.monitorKey"
+          :type="
+            curSource.monitorKey === source.monitorKey ? 'primary' : 'info'
+          "
+          @click="switchVideo(source)"
+          >{{ videoSourceInfo[source.monitorKey] }}</el-button
+        >
+      </div>
+    </div>
+    <div class="record-body">
+      <div class="record-video">
+        <video
+          :src="curSourceUrl"
+          controls
+          controlslist="nodownload"
+          disablePictureInPicture
+          @error="videoError"
+          @loadeddata="videoLoad($event)"
+        ></video>
+      </div>
+    </div>
+    <div class="record-footer">
+      <i class="icon icon-error"></i>
+      <span
+        >提示:无法正常播放,请<i
+          class="color-primary record-qr"
+          @click="modalIsShow = true"
+          >扫描二维码</i
+        >。</span
+      >
+    </div>
+    <!-- qr-code -->
+    <el-dialog
+      custom-class="record-code-dialog"
+      :visible.sync="modalIsShow"
+      width="280px"
+      :close-on-click-modal="false"
+      :close-on-press-escape="false"
+      append-to-body
+      destroy-on-close
+    >
+      <div class="record-close" @click="modalIsShow = false">
+        <i class="el-icon-close"></i>
+      </div>
+      <div class="record-qrcode">
+        <vue-qrcode
+          :value="curSourceUrl"
+          :options="{ width: 200, margin: 0 }"
+          tag="img"
+        ></vue-qrcode>
+        <p class="record-tips">请扫描二维码</p>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import VueQrcode from "@chenfengyuan/vue-qrcode";
+
+export default {
+  name: "StudentMonitorRecord",
+  components: { VueQrcode },
+  data() {
+    return {
+      info: {
+        examStudentName: "夏燕",
+      },
+      videoSources: [
+        {
+          monitorKey: "MOBILE_FIRST",
+          monitorKeyName: "",
+          urls: [],
+        },
+        {
+          monitorKey: "MOBILE_SECOND",
+          monitorKeyName: "",
+          urls: [],
+        },
+        {
+          monitorKey: "CLIENT_CAMERA",
+          monitorKeyName: "",
+          urls: [],
+        },
+        {
+          monitorKey: "CLIENT_SCREEN",
+          monitorKeyName: "",
+          urls: [],
+        },
+      ],
+      videoSourceInfo: {
+        CLIENT_CAMERA: "电脑摄像头",
+        CLIENT_SCREEN: "电脑录屏",
+        MOBILE_FIRST: "手机主机位",
+        MOBILE_SECOND: "手机辅机位",
+      },
+      curSource: {},
+      curSourceUrl: "http://localhost:8176/files/big_buck_bunny.mp4",
+      modalIsShow: false,
+    };
+  },
+  methods: {
+    switchVideo(source) {
+      this.curSource = source;
+    },
+    videoError() {
+      this.$message.error("视频解析错误,您可以尝试扫描二维码,通过手机观看。");
+    },
+    videoLoad(event) {
+      console.log(event);
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.student-monitor-record {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  z-index: auto;
+  top: 0;
+  left: 0;
+  background-color: #f0f4f9;
+  color: #626a82;
+  font-weight: bold;
+}
+.record-header {
+  position: absolute;
+  height: 56px;
+  width: 100%;
+  left: 0;
+  top: 0;
+  background-color: #fff;
+  z-index: 9;
+  padding: 12px 20px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+
+  .header-info-name {
+    font-size: 18px;
+    vertical-align: middle;
+  }
+
+  .icon-user-act {
+    margin-top: -3px;
+    margin-right: 8px;
+  }
+
+  .header-info-item {
+    padding: 0 12px;
+    vertical-align: middle;
+
+    &:not(:last-child) {
+      border-right: 1px solid #abb8c9;
+    }
+  }
+
+  .el-button--info {
+    border-color: #f0f4f9;
+    background: #f0f4f9;
+    border-radius: 6px;
+    color: #273a62;
+
+    &:hover {
+      border-color: #1886fe;
+      background: #1886fe;
+      color: #fff;
+    }
+  }
+}
+.record-body {
+  padding: 76px 20px 70px;
+  height: 100%;
+}
+.record-video {
+  background: #000000;
+  border-radius: 6px;
+  height: 100%;
+  overflow: hidden;
+
+  > video {
+    display: block;
+    width: 100%;
+    height: 100%;
+  }
+}
+.record-footer {
+  position: absolute;
+  width: 100%;
+  left: 0;
+  bottom: 34px;
+  text-align: center;
+  .icon {
+    margin-right: 10px;
+    margin-top: -1px;
+  }
+
+  .record-qr {
+    color: #1886fe;
+    text-decoration: underline;
+    font-style: normal;
+    cursor: pointer;
+    &:hover {
+      color: mix(#333, #1886fe, 20%);
+    }
+  }
+}
+</style>
+<style lang="scss">
+.record-code-dialog {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  margin: 0 !important;
+
+  .el-dialog__header,
+  .el-dialog__footer {
+    display: none;
+  }
+  .el-dialog__body {
+    position: relative;
+    padding: 40px 40px 30px 40px;
+    text-align: center;
+    font-size: 12px;
+  }
+  .record-close {
+    position: absolute;
+    height: 20px;
+    width: 20px;
+    font-size: 16px;
+    line-height: 20px;
+    top: 8px;
+    right: 8px;
+    z-index: 9;
+    color: #959fb1;
+    cursor: pointer;
+
+    &:hover {
+      color: #fe5863;
+    }
+  }
+  .record-tips {
+    margin: 20px 0 0;
+  }
+}
+</style>

+ 13 - 1
src/features/invigilation/RealtimeMonitoring/VideoCommunication.vue

@@ -107,7 +107,11 @@
 </template>
 
 <script>
-import { createClient, createStream } from "@/plugins/trtc";
+import {
+  checkSystemRequirements,
+  createClient,
+  createStream,
+} from "@/plugins/trtc";
 import {
   communicationList,
   communicationCalling,
@@ -203,6 +207,7 @@ export default {
         sdkAppId: this.userMonitor.appId,
         userId: this.userMonitor.monitorUserId,
         userSig: this.userMonitor.monitorUserSig,
+        useStringRoomId: true,
       });
     },
     async getLocalMedia(isVideo) {
@@ -230,6 +235,13 @@ export default {
       return initLocalStreamResult && localStream;
     },
     async answer(student, isVideo) {
+      const result = await checkSystemRequirements().catch(() => {
+        this.$message.error(
+          `您的浏览器不支持当前音视频通讯版本。建议使用最新版的chrome浏览器!`
+        );
+      });
+      if (!result) return;
+
       if (this.holding) return;
       this.holding = true;
 

+ 13 - 1
src/features/invigilation/RealtimeMonitoring/WarningDetail.vue

@@ -295,7 +295,11 @@
 </template>
 
 <script>
-import { createClient, createStream } from "@/plugins/trtc";
+import {
+  checkSystemRequirements,
+  createClient,
+  createStream,
+} from "@/plugins/trtc";
 import {
   invigilateDetail,
   invigilateFinish,
@@ -631,6 +635,7 @@ export default {
         sdkAppId: this.userMonitor.appId,
         userId: this.userMonitor.monitorUserId,
         userSig: this.userMonitor.monitorUserSig,
+        useStringRoomId: true,
       });
     },
     async getLocalMedia(isVideo) {
@@ -658,6 +663,13 @@ export default {
       return initLocalStreamResult && localStream;
     },
     async answer(isVideo) {
+      const result = await checkSystemRequirements().catch(() => {
+        this.$message.error(
+          `您的浏览器不支持当前音视频通讯版本。建议使用最新版的chrome浏览器!`
+        );
+      });
+      if (!result) return;
+
       // 客户端两路视频公用一个userId:
       // main:有音频,有视频
       // auxiliary:无音频,有视频

+ 7 - 0
src/plugins/trtc.js

@@ -2,6 +2,13 @@ import TRTC from "trtc-js-sdk";
 // 输出INFO以上日志等级
 TRTC.Logger.setLogLevel(TRTC.Logger.LogLevel.WARN);
 
+export const checkSystemRequirements = async () => {
+  const checkResult = await TRTC.checkSystemRequirements();
+  console.log(checkResult);
+  if (!checkResult.result) return Promise.reject(checkResult.detail);
+  return Promise.resolve(true);
+};
+
 export const createClient = (options) => {
   return TRTC.createClient(options);
 };

+ 8 - 0
src/router/index.js

@@ -178,6 +178,14 @@ const routes = [
       },
     ],
   },
+  {
+    path: "/exam/student-monitor-record/:recordId",
+    name: "StudentMonitorRecord",
+    component: () =>
+      import(
+        /* webpackChunkName: "record" */ "../features/examwork/StudentManagement/StudentMonitorRecord.vue"
+      ),
+  },
   {
     path: "/invigilation",
     name: "Invigilation",

+ 3 - 0
src/styles/icons.scss

@@ -68,6 +68,9 @@
   &-logout {
     background-image: url(../assets/icon-logout.png);
   }
+  &-error {
+    background-image: url(../assets/icon-error.png);
+  }
 
   &-reexam {
     background-image: url(../assets/icon-reexam.png);

+ 54 - 5
yarn.lock

@@ -1308,6 +1308,13 @@
     lodash "^4.17.13"
     to-fast-properties "^2.0.0"
 
+"@chenfengyuan/vue-qrcode@1.0.2":
+  version "1.0.2"
+  resolved "https://registry.npmmirror.com/@chenfengyuan/vue-qrcode/-/vue-qrcode-1.0.2.tgz#37d71902e166e1ae58176bd6cb9c40905c1b0949"
+  integrity sha512-hwy1d4YMJAyEh+V7dLPG8eAKACRvugzSB4ylwb6QNqo84KHTF50/5EJcBYdUhTRPfAqrxG0i6jDAXONWOGyQbQ==
+  dependencies:
+    qrcode "^1.4.4"
+
 "@cnakazawa/watch@^1.0.3":
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.3.tgz#099139eaec7ebf07a27c1786a3ff64f39464d2ef"
@@ -4533,6 +4540,11 @@ diffie-hellman@^5.0.0:
     miller-rabin "^4.0.0"
     randombytes "^2.0.0"
 
+dijkstrajs@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257"
+  integrity sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg==
+
 dir-glob@^2.0.0, dir-glob@^2.2.2:
   version "2.2.2"
   resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.2.2.tgz#fa09f0694153c8918b18ba0deafae94769fc50c4"
@@ -4754,6 +4766,11 @@ emojis-list@^3.0.0:
   resolved "https://registry.npm.taobao.org/emojis-list/download/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
   integrity sha1-VXBmIEatKeLpFucariYKvf9Pang=
 
+encode-utf8@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.npmmirror.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda"
+  integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==
+
 encodeurl@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
@@ -8872,6 +8889,11 @@ pn@^1.1.0:
   resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
   integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==
 
+pngjs@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
+  integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
+
 pnp-webpack-plugin@^1.6.4:
   version "1.6.4"
   resolved "https://registry.npm.taobao.org/pnp-webpack-plugin/download/pnp-webpack-plugin-1.6.4.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpnp-webpack-plugin%2Fdownload%2Fpnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149"
@@ -9443,6 +9465,16 @@ q@^1.1.2:
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
   integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
 
+qrcode@^1.4.4:
+  version "1.5.0"
+  resolved "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.0.tgz#95abb8a91fdafd86f8190f2836abbfc500c72d1b"
+  integrity sha512-9MgRpgVc+/+47dFvQeD6U2s0Z92EsKzcHogtum4QB+UNd025WOJSHvn/hjk9xmzj7Stj95CyUAs31mrjxliEsQ==
+  dependencies:
+    dijkstrajs "^1.0.1"
+    encode-utf8 "^1.0.3"
+    pngjs "^5.0.0"
+    yargs "^15.3.1"
+
 qs@6.7.0:
   version "6.7.0"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
@@ -11040,10 +11072,10 @@ trim-right@^1.0.1:
   resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
   integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=
 
-trtc-js-sdk@^4.6.1:
-  version "4.6.1"
-  resolved "https://registry.npm.taobao.org/trtc-js-sdk/download/trtc-js-sdk-4.6.1.tgz?cache=0&sync_timestamp=1597029484476&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ftrtc-js-sdk%2Fdownload%2Ftrtc-js-sdk-4.6.1.tgz#7792522eacdfebbcb169f2943ccf877f6bdb28bb"
-  integrity sha1-d5JSLqzf67yxafKUPM+Hf2vbKLs=
+trtc-js-sdk@^4.12.1:
+  version "4.12.1"
+  resolved "https://registry.npmmirror.com/trtc-js-sdk/-/trtc-js-sdk-4.12.1.tgz#8be2ab460d830d14e4196c63509b1c86d4ea29ce"
+  integrity sha512-eXYH6uuZQNPn6UmKod0EquwQtoPPVdsb36ruOXMknn6KsnHWzsh8SSbAp1UH9nBdPJQI41lM64XeLjuAQADOpw==
 
 tryer@^1.0.0:
   version "1.0.1"
@@ -12054,7 +12086,7 @@ yargs-parser@^13.1.2:
     camelcase "^5.0.0"
     decamelize "^1.2.0"
 
-yargs-parser@^18.1.1:
+yargs-parser@^18.1.1, yargs-parser@^18.1.2:
   version "18.1.3"
   resolved "https://registry.npm.taobao.org/yargs-parser/download/yargs-parser-18.1.3.tgz?cache=0&sync_timestamp=1587068056050&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fyargs-parser%2Fdownload%2Fyargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
   integrity sha1-vmjEl1xrKr9GkjawyHA2L6sJp7A=
@@ -12111,6 +12143,23 @@ yargs@^15.0.0:
     y18n "^4.0.0"
     yargs-parser "^18.1.1"
 
+yargs@^15.3.1:
+  version "15.4.1"
+  resolved "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
+  integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
+  dependencies:
+    cliui "^6.0.0"
+    decamelize "^1.2.0"
+    find-up "^4.1.0"
+    get-caller-file "^2.0.1"
+    require-directory "^2.1.1"
+    require-main-filename "^2.0.0"
+    set-blocking "^2.0.0"
+    string-width "^4.2.0"
+    which-module "^2.0.0"
+    y18n "^4.0.0"
+    yargs-parser "^18.1.2"
+
 yorkie@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/yorkie/-/yorkie-2.0.0.tgz#92411912d435214e12c51c2ae1093e54b6bb83d9"