Pārlūkot izejas kodu

feat: 监考大屏

chenhao 2 gadi atpakaļ
vecāks
revīzija
a30e9970af
34 mainītis faili ar 1824 papildinājumiem un 113 dzēšanām
  1. 2 2
      .env.development
  2. BIN
      src/assets/icon-back.png
  3. BIN
      src/assets/icon-call-full.png
  4. BIN
      src/assets/icon-full.png
  5. BIN
      src/assets/icon-send-text-light.png
  6. BIN
      src/assets/icon-send-text.png
  7. BIN
      src/assets/icon-send-video-light.png
  8. BIN
      src/assets/icon-send-video.png
  9. BIN
      src/assets/icon-sound-light.png
  10. BIN
      src/assets/icon-sound.png
  11. BIN
      src/assets/icon-student-rate.png
  12. BIN
      src/assets/icon-talk-light.png
  13. BIN
      src/assets/icon-talk.png
  14. BIN
      src/assets/icon-tip.png
  15. BIN
      src/assets/icon-voice-light.png
  16. BIN
      src/assets/icon-voice.png
  17. BIN
      src/assets/icon-waring.png
  18. BIN
      src/assets/icon-warn.png
  19. 47 1
      src/features/invigilation/RealtimeMonitoring/RealtimeMonitoring.vue
  20. 459 0
      src/features/invigilation/RealtimeMonitoring/RealtimeMonitoringFull.vue
  21. 2 1
      src/features/invigilation/RealtimeMonitoring/VideoCommunication.vue
  22. 87 0
      src/features/invigilation/RealtimeMonitoring/components/EarlyWarningList.vue
  23. 260 0
      src/features/invigilation/RealtimeMonitoring/components/InvigilationStudentFull.vue
  24. 287 0
      src/features/invigilation/RealtimeMonitoring/components/InvigilationStudentTalk.vue
  25. 183 0
      src/features/invigilation/RealtimeMonitoring/components/RealtimeMonitoringFullHeader.vue
  26. 132 0
      src/features/invigilation/RealtimeMonitoring/components/RequestCallList.vue
  27. 101 0
      src/features/invigilation/RealtimeMonitoring/components/SummaryLineFull.vue
  28. 16 0
      src/features/invigilation/RealtimeMonitoring/mixins/IntervalMixin.js
  29. 51 0
      src/features/invigilation/RealtimeMonitoring/mixins/RealtimeMonitoringMixin.js
  30. 13 1
      src/features/invigilation/common/FlvMedia.vue
  31. 2 107
      src/features/invigilation/common/SummaryLine.vue
  32. 110 0
      src/features/invigilation/common/mixins/summaryLineMixin.js
  33. 8 0
      src/router/invigilation.js
  34. 64 1
      src/styles/icons.scss

+ 2 - 2
.env.development

@@ -1,5 +1,5 @@
 VUE_APP_SELF_DEFINE_DOMAIN=true
 VUE_APP_ENABLE_VUE_RENDER_LOGS=false
 VUE_APP_ROUTER_PATH=/admin/
-VUE_APP_MAIN_PROXY=https://admin.online-exam-test.cn
-VUE_APP_MAIN_PROXY_FILE=https://admin.online-exam-test.cn
+VUE_APP_MAIN_PROXY=http://192.168.10.86:6001
+VUE_APP_MAIN_PROXY_FILE=https://m01.online-exam-test.cn

BIN
src/assets/icon-back.png


BIN
src/assets/icon-call-full.png


BIN
src/assets/icon-full.png


BIN
src/assets/icon-send-text-light.png


BIN
src/assets/icon-send-text.png


BIN
src/assets/icon-send-video-light.png


BIN
src/assets/icon-send-video.png


BIN
src/assets/icon-sound-light.png


BIN
src/assets/icon-sound.png


BIN
src/assets/icon-student-rate.png


BIN
src/assets/icon-talk-light.png


BIN
src/assets/icon-talk.png


BIN
src/assets/icon-tip.png


BIN
src/assets/icon-voice-light.png


BIN
src/assets/icon-voice.png


BIN
src/assets/icon-waring.png


BIN
src/assets/icon-warn.png


+ 47 - 1
src/features/invigilation/RealtimeMonitoring/RealtimeMonitoring.vue

@@ -11,6 +11,9 @@
             { 'realtime-switch-warning': hasNewWarning },
           ]"
         >
+          <el-button class="toggle-full-button" @click="toFullScreen">
+            全屏监控
+          </el-button>
           <div
             :class="[
               'realtime-switch-item',
@@ -418,6 +421,19 @@ export default {
   },
   computed: {
     ...mapState("invigilation", ["liveDomains"]),
+    isFullScreen() {
+      return this.$store.state.isFullScreen;
+    },
+  },
+  watch: {
+    isFullScreen: {
+      immediate: true,
+      handler(val, oldVal) {
+        if (val !== oldVal && val) {
+          this.$router.replace({ name: "RealtimeMonitoringFull" });
+        }
+      },
+    },
   },
   methods: {
     ...mapActions("invigilation", ["updateDetailIds"]),
@@ -566,7 +582,7 @@ export default {
       const res = await monitorCallCount({
         examId: this.filter.examId,
         roomCode: this.filter.roomCode,
-        callStatus: "START",
+        callStatus: "START,CANCEL",
       });
       this.communicationCount = res.data.data.count || 0;
     },
@@ -661,6 +677,32 @@ export default {
         refInst.mutedPlayer(true);
       });
     },
+    async toFullScreen() {
+      const fullscreenEnabled =
+        document.fullscreenEnabled ||
+        document.mozFullScreenEnabled ||
+        document.webkitFullscreenEnabled ||
+        document.msFullscreenEnabled;
+      if (!fullscreenEnabled) {
+        this.$message.error("当前浏览器不支持全屏!");
+        return;
+      }
+      const de = document.documentElement;
+      const requestFullscreen =
+        de.requestFullscreen ||
+        de.mozRequestFullScreen ||
+        de.webkitRequestFullscreen;
+      const exitFullscreen =
+        document.exitFullscreen ||
+        document.mozCancelFullScreen ||
+        document.webkitCancelFullScreen;
+
+      if (this.isFullscreen) {
+        await exitFullscreen.call(document).catch(() => {});
+      } else {
+        await requestFullscreen.call(de).catch(() => {});
+      }
+    },
   },
   beforeDestroy() {
     this.loopRunning = false;
@@ -696,6 +738,10 @@ export default {
 }
 .realtime-switch {
   font-size: 0;
+  .toggle-full-button {
+    margin-right: 20px;
+    height: 28px;
+  }
   &-warning {
     .realtime-switch-item {
       &::before {

+ 459 - 0
src/features/invigilation/RealtimeMonitoring/RealtimeMonitoringFull.vue

@@ -0,0 +1,459 @@
+<template>
+  <div class="realtime-monitoring-full">
+    <div :style="viewStyle" class="monitor-view">
+      <RealtimeMonitoringFullHeader
+        class="monitor-view-header"
+        @back="onBack"
+        @filterChange="onFilterChange"
+      />
+      <div class="monitor-view-content">
+        <div class="monitor-view-content-left">
+          <EarlyWarningList @warnClick="toDetail" :list="warningList" />
+          <RequestCallList @backCall="onBackCall" :list="communicationList" />
+        </div>
+        <div class="monitor-view-content-right">
+          <div class="card-box monitor-view-content-right-header">
+            <SummaryLineFull data-type="trouble" :examId="filter.examId" />
+            <el-button
+              class="finish-invigilation"
+              type="danger"
+              icon="icon icon-over"
+              :disabled="!filter.examId"
+              @click="finishInvigilationExam"
+              >结束监考</el-button
+            >
+          </div>
+          <div class="monitor-view-content-video-table">
+            <div
+              class="invigilation-student-item"
+              v-for="item in dataList"
+              :key="item.examRecordId"
+            >
+              <invigilation-student-full
+                ref="InvigilationStudent"
+                :data="item"
+                :talking="talking"
+                :warning="isWarning(item)"
+                :beCalled="isBeCalled(item)"
+                @operation="onOperation"
+                @muted-change="videoAllMuted"
+                @click.native="toDetail(item)"
+              ></invigilation-student-full>
+            </div>
+          </div>
+          <div class="monitor-view-content-video-pagination">
+            <el-pagination
+              background
+              layout="prev, pager, next"
+              :current-page="filter.pageNumber"
+              :total="total"
+              hide-on-single-page
+              :page-size="filter.pageSize"
+              @current-change="(pageNumber) => onFilterChange({ pageNumber })"
+            >
+              <template #prev>
+                <span>1</span>
+              </template>
+            </el-pagination>
+          </div>
+        </div>
+      </div>
+    </div>
+    <!-- warning-text-message-dialog -->
+    <warning-text-message-dialog
+      :record-id="operationStudent['message'].examRecordId"
+      ref="WarningTextMessageDialog"
+    ></warning-text-message-dialog>
+    <!-- audio-record-dialog -->
+    <audio-record-dialog
+      :record-id="operationStudent['voice'].examRecordId"
+      ref="AudioRecordDialog"
+    ></audio-record-dialog>
+    <InvigilationStudentTalk
+      ref="talkDialog"
+      :record-id="operationStudent['talk'].examRecordId"
+      @talking="onTalking"
+      @muted-change="videoAllMuted"
+    />
+  </div>
+</template>
+
+<script>
+import {
+  invigilateVideoList,
+  invigilateExamFinish,
+  invigilationWarningMessage,
+  communicationList,
+} from "@/api/invigilation";
+
+import { mapState, mapMutations, mapActions } from "vuex";
+import RealtimeMonitoringFullHeader from "./components/RealtimeMonitoringFullHeader";
+import EarlyWarningList from "./components/EarlyWarningList";
+import RequestCallList from "./components/RequestCallList";
+import SummaryLineFull from "./components/SummaryLineFull";
+import InvigilationStudentFull from "./components/InvigilationStudentFull";
+import InvigilationStudentTalk from "./components/InvigilationStudentTalk";
+import WarningTextMessageDialog from "./WarningTextMessageDialog";
+import AudioRecordDialog from "./audioRecord/AudioRecordDialog";
+import { debounce } from "lodash-es";
+import IntervalMixin from "./mixins/IntervalMixin";
+const DESIGN_WIDTH = 1920;
+const DESIGN_HEIGHT = 1080;
+
+export default {
+  name: "RealtimeMonitoringFull",
+  mixins: [IntervalMixin],
+  components: {
+    RealtimeMonitoringFullHeader,
+    EarlyWarningList,
+    RequestCallList,
+    SummaryLineFull,
+    InvigilationStudentFull,
+    WarningTextMessageDialog,
+    AudioRecordDialog,
+    InvigilationStudentTalk,
+  },
+
+  data() {
+    return {
+      filter: {
+        examId: "",
+        roomCode: "",
+        monitorVideoSource: "",
+        pageSize: 24,
+        pageNumber: 1,
+      },
+      /** 通话待办 */
+      communication: 0,
+      /** 当前考试批次 */
+      curExamBatch: {},
+      /** 监考视频列表 */
+      dataList: [],
+      /** 监考视频总数 */
+      total: 0,
+      /** 当前操作的学生 */
+      operationStudent: {
+        message: {},
+        voice: {},
+        talk: {},
+      },
+      /** 通话中 */
+      talking: {
+        examRecordId: "",
+        isVideo: false,
+      },
+      /** 预警列表 */
+      warningList: [],
+      /** 通话列表 */
+      communicationList: [],
+      /** 屏幕适配缩放 */
+      viewStyle: {
+        "transform-origin": "center top",
+        transform: "scale(1)",
+      },
+    };
+  },
+  mounted() {
+    this.observerResize();
+  },
+  computed: {
+    ...mapState("invigilation", ["liveDomains"]),
+    isFullScreen() {
+      return this.$store.state.isFullScreen;
+    },
+  },
+  watch: {
+    isFullScreen: {
+      immediate: true,
+      handler(val, oldVal) {
+        if (val !== oldVal && !val) {
+          this.$router.replace({ name: "RealtimeMonitoring" });
+        }
+      },
+    },
+    filter: {
+      handler() {
+        this.refreshList();
+      },
+      deep: true,
+    },
+  },
+  methods: {
+    ...mapActions("invigilation", ["updateDetailIds"]),
+    ...mapMutations("invigilation", ["setDetailIds"]),
+    /** esc 返回 */
+    onBack() {
+      document.exitFullscreen?.();
+    },
+
+    /** filter change */
+    onFilterChange(filter) {
+      this.filter = Object.assign(this.filter, filter);
+      this.getList();
+    },
+
+    /** 查询视频列表 */
+    async getList() {
+      const res = await invigilateVideoList(this.filter);
+      const domainLen = this.liveDomains.length;
+      this.dataList = res.data.data.records.map((item, index) => {
+        const domain = domainLen ? this.liveDomains[index % domainLen] : "";
+        item.label = `${item.identity} ${item.courseName}(${item.courseCode}) ${item.name}`;
+        item.liveUrl = item.monitorLiveUrl
+          ? `${domain}/live/${item.monitorLiveUrl.toLowerCase()}.flv`
+          : "";
+        item.progress = item.progress ? Math.round(item.progress * 100) : 0;
+        return item;
+      });
+      this.total = res.data.data.total;
+      if (this.total > this.filter.pageSize) {
+        this.updateDetailIds({
+          filterData: this.filter,
+          fetchFunc: invigilateVideoList,
+        });
+      } else {
+        const ids = this.dataList.map((item) => item.examRecordId);
+        this.setDetailIds([...new Set(ids)]);
+      }
+    },
+
+    /** 结束监考 */
+    async finishInvigilationExam() {
+      const result = await this.$confirm(
+        "确定要结束监考吗?",
+        "结束监考确认提醒",
+        {
+          confirmButtonText: "确定",
+          cancelButtonText: "取消",
+          iconClass: "el-icon-warning",
+          customClass: "el-message-box__error",
+        }
+      ).catch(() => {});
+
+      if (!result) return;
+
+      await invigilateExamFinish(this.filter.examId);
+      this.onBack();
+    },
+
+    onOperation({ type, data }) {
+      if (type === "video") {
+        this.operationStudent["talk"] = data;
+      } else {
+        this.operationStudent[type] = data;
+      }
+      const operationMap = {
+        message: this.toSendTextMsg,
+        voice: this.toSendAudioMsg,
+        talk: () => this.toTalk(),
+        video: () => this.toTalk(true),
+      };
+      this.$nextTick(() => {
+        operationMap[type]();
+      });
+    },
+
+    /** 全部静音 */
+    videoAllMuted() {
+      this.$refs.InvigilationStudent.forEach((refInst) => {
+        refInst.mutedPlayer(true);
+      });
+    },
+
+    /** 跳转详情 */
+    toDetail(item) {
+      this.$router
+        .push({
+          name: "WarningDetail",
+          params: { examRecordId: item.examRecordId },
+        })
+        .then(() => {
+          document.exitFullscreen?.();
+        });
+    },
+
+    /** 发送文字消息 */
+    toSendTextMsg() {
+      this.$refs.WarningTextMessageDialog.open();
+    },
+
+    /** 发送语音消息 */
+    toSendAudioMsg() {
+      this.$refs.AudioRecordDialog.open();
+    },
+
+    /** 发起视频/语音通话 */
+    toTalk(isVideo = false) {
+      this.$refs.talkDialog.open(isVideo);
+    },
+
+    /** 回拨 */
+    onBackCall(data) {
+      this.onOperation({ type: "talk", data });
+    },
+
+    /** 通话中事件... */
+    onTalking({ examRecordId, talking }) {
+      this.talking.examRecordId = talking ? examRecordId : "";
+    },
+
+    /** 是否预警状态 */
+    isWarning(item) {
+      return !!this.warningList.find(
+        (warn) => warn.examRecordId === item.examRecordId
+      );
+    },
+    /** 是否申请通话中 */
+    isBeCalled(item) {
+      return !!this.communicationList.find(
+        (talk) =>
+          talk.callStatus !== "CANCEL" &&
+          talk.examRecordId === item.examRecordId
+      );
+    },
+    /** 获取通话代办列表 */
+    async getCommunicationList() {
+      if (!this.filter.examId) return;
+      const res = await communicationList({
+        examId: this.filter.examId,
+        roomCode: this.filter.roomCode,
+        callStatus: "START,CANCEL",
+        pageNumber: 1,
+        pageSize: 100,
+      });
+      this.communicationList = res.data.data.records;
+    },
+    /** 获取预警列表 */
+    async fetchWarningNotice() {
+      if (!this.filter.examId) return;
+      const res = await invigilationWarningMessage(this.filter.examId);
+      this.warningList = res.data.data;
+    },
+    /** 定时刷新预警/通话待办 */
+    refreshList() {
+      if (this.stopInterval) {
+        this.stopInterval();
+      }
+      Promise.all([this.getCommunicationList(), this.fetchWarningNotice()]);
+      this.stopInterval = this.interval(() => {
+        Promise.all([this.getCommunicationList(), this.fetchWarningNotice()]);
+      }, 10 * 1000);
+    },
+
+    observerResize() {
+      const resizeHandle = debounce(() => {
+        const scale = this.getScale();
+        this.viewStyle.transform = `scale(${scale})`;
+      }, 60);
+      resizeHandle();
+      window.addEventListener("resize", resizeHandle);
+      this.$once("hook:beforeDestroy", () => {
+        window.removeEventListener("resize", resizeHandle);
+      });
+    },
+    getScale() {
+      const wScale = window.innerWidth / DESIGN_WIDTH;
+      const hScale = window.innerHeight / DESIGN_HEIGHT;
+      return wScale < hScale ? wScale : hScale;
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.realtime-monitoring-full {
+  position: fixed;
+  left: 0;
+  top: 0;
+  width: 100vw;
+  height: 100vh;
+  background-color: #1a1d1f;
+  z-index: 2;
+  .card-box {
+    width: 280px;
+    background: #2e3238;
+    border-radius: 10px;
+    & ~ .card-box {
+      margin-top: 10px;
+    }
+  }
+  .ellipsis {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+  .monitor-view {
+    .monitor-view-header {
+      height: 64px;
+      background: #2e3238;
+    }
+    .monitor-view-content {
+      padding: 20px;
+      display: flex;
+      .monitor-view-content-left {
+        width: 280px;
+        margin-right: 20px;
+      }
+      .monitor-view-content-right {
+        flex: 1;
+        .monitor-view-content-right-header {
+          width: 100%;
+          padding: 10px;
+          display: flex;
+          align-items: center;
+          justify-content: space-between;
+          .finish-invigilation {
+            width: 136px;
+            height: 48px;
+          }
+        }
+        .monitor-view-content-video-table {
+          display: flex;
+          flex-wrap: wrap;
+          margin-top: 20px;
+          .invigilation-student-item {
+            margin-right: 10px;
+            &:nth-child(6n) {
+              margin-right: 0;
+            }
+            &:nth-child(n + 7) {
+              margin-top: 10px;
+            }
+          }
+        }
+        .monitor-view-content-video-pagination {
+          margin-top: 15px;
+          ::v-deep {
+            .el-pagination {
+              text-align: right;
+              .btn-prev,
+              .btn-next,
+              .number,
+              .more {
+                width: 32px;
+                height: 32px;
+                border: none;
+                background-color: #2e3238;
+                border-radius: 10px;
+              }
+              .btn-prev,
+              .btn-next {
+                &[disabled="disabled"] {
+                  background-color: #6a6e74;
+                }
+                &:not([disabled="disabled"]):hover {
+                  background-color: #1886fe;
+                  color: #fff;
+                }
+              }
+              .number.active {
+                background-color: #1886fe;
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+</style>

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

@@ -9,6 +9,7 @@
           @change="getCommunicationList"
         >
           <el-radio-button label="START">待处理</el-radio-button>
+          <el-radio-button label="CANCEL">未接通话</el-radio-button>
           <el-radio-button label="STOP">已处理</el-radio-button>
         </el-radio-group>
         <el-button
@@ -34,7 +35,7 @@
               </div>
             </div>
             <h4 class="student-name">{{ item.examStudentName }}</h4>
-            <div v-if="callStatus === 'START'">
+            <div v-if="callStatus === 'START' || callStatus === 'CANCEL'">
               <el-button round type="success" @click="answer(item, 0)"
                 >语音通话</el-button
               >

+ 87 - 0
src/features/invigilation/RealtimeMonitoring/components/EarlyWarningList.vue

@@ -0,0 +1,87 @@
+<template>
+  <div class="card-box card-box-list">
+    <div class="card-box-title">预警列表</div>
+    <ul class="card-box-list-content">
+      <li
+        class="card-box card-box-list-item"
+        v-for="item in list"
+        :key="item.warningId"
+        @click="$emit('warnClick', item)"
+      >
+        <span class="icon icon-warn"></span>
+        <span class="ellipsis card-box-list-item-user">
+          {{ item.name }}
+        </span>
+        <span class="ellipsis early-warning-reason">{{ item.info }}</span>
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script>
+import IntervalMixin from "../mixins/IntervalMixin";
+export default {
+  name: "EarlyWarningList",
+  mixins: [IntervalMixin],
+  props: {
+    list: {
+      type: Array,
+      default: () => [],
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.ellipsis {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+.card-box-list {
+  padding: 0 10px 10px;
+  list-style: none;
+  .card-box-title {
+    font-size: 14px;
+    font-weight: bold;
+    color: #ffffff;
+    line-height: 1;
+    padding: 20px 0;
+  }
+  .card-box-list-content {
+    margin: 0;
+    padding: 0;
+    padding-right: 10px;
+    height: 500px; // 7
+    overflow-y: auto;
+    .card-box-list-item {
+      width: unset;
+      background-color: #3f444d;
+      padding: 10px;
+      line-height: 1;
+      display: flex;
+      align-items: center;
+      color: #fff;
+      font-size: 12px;
+      &:not(:last-child) {
+        margin-bottom: 6px;
+      }
+      .icon {
+        width: 24px;
+        height: 24px;
+      }
+      .card-box-list-item-user {
+        font-weight: bold;
+        margin: 0 10px;
+        width: 48px;
+      }
+      .early-warning-reason {
+        color: #a1a8b3;
+        margin-left: auto;
+        flex: 1;
+        text-align: right;
+      }
+    }
+  }
+}
+</style>

+ 260 - 0
src/features/invigilation/RealtimeMonitoring/components/InvigilationStudentFull.vue

@@ -0,0 +1,260 @@
+<template>
+  <div class="invigilation-student-full">
+    <div
+      class="student-video"
+      :class="{ warning, 'be-call': beCalled }"
+      title="点击放大播放"
+    >
+      <flv-media
+        ref="ThirdViewVideo"
+        hide-audio-icon
+        :live-url="data.liveUrl"
+        :max-retry-count="maxRetryCount"
+        @media-muted-change="mediaMutedChange"
+        @muted-change="mutedChange"
+      ></flv-media>
+      <span class="icon icon-waring warn-icon"></span>
+      <span class="icon icon-full max-icon" @click.stop="toLargeView"></span>
+      <div class="student-info">
+        <span class="student-info-name">{{ data.name }}</span>
+        <span class="student-info-identity">{{ data.identity }}</span>
+        <div class="student-info-progress">
+          <span class="icon icon-student-rate"></span>
+          <span>{{ data.progress || 0 }}%</span>
+        </div>
+      </div>
+    </div>
+    <div class="operation-group" v-if="beCalled" @click.stop>
+      <span>考生申请通话…</span>
+      <div class="other-operation">
+        <span
+          class="icon icon-talk"
+          :disabled="disabled || talkingType === 'video'"
+          @click.stop="onOperation('talk')"
+        ></span>
+      </div>
+    </div>
+    <div class="operation-group" v-else @click.stop>
+      <span
+        class="icon icon-sound sound-operation"
+        :class="{ 'icon-sound-active': !muted }"
+        @click.stop="onOperation('sound')"
+      ></span>
+      <div class="other-operation">
+        <span
+          class="icon icon-send-text"
+          @click.stop="onOperation('message')"
+        ></span>
+        <span class="icon icon-voice" @click.stop="onOperation('voice')"></span>
+        <span
+          class="icon icon-talk"
+          :class="{ 'icon-talk-active': talkingType === 'talk' }"
+          :disabled="disabled || talkingType === 'video'"
+          @click.stop="onOperation('talk')"
+        ></span>
+        <span
+          class="icon icon-send-video"
+          :class="{ 'icon-send-video-active': talkingType === 'video' }"
+          :disabled="disabled || talkingType === 'talk'"
+          @click.stop="onOperation('video')"
+        ></span>
+      </div>
+    </div>
+    <!-- InvigilationStudentMediaDialog -->
+    <InvigilationStudentMediaDialog
+      ref="InvigilationStudentMediaDialog"
+      :data="data"
+      destroy-on-close
+      @close="dialogClose"
+    />
+  </div>
+</template>
+
+<script>
+import FlvMedia from "../../common/FlvMedia";
+import InvigilationStudentMediaDialog from "../../common/InvigilationStudentMediaDialog";
+
+export default {
+  name: "invigilation-student",
+  components: { FlvMedia, InvigilationStudentMediaDialog },
+  props: {
+    data: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+    talking: {
+      type: Object,
+      default: () => ({ examRecordId: "", isVideo: false }),
+    },
+    warning: {
+      type: Boolean,
+      default: false,
+    },
+    beCalled: {
+      type: Boolean,
+      default: false,
+    },
+    maxRetryCount: {
+      type: Number,
+      default: 3,
+    },
+  },
+  data() {
+    return {
+      muted: true,
+    };
+  },
+  computed: {
+    disabled() {
+      return (
+        !!this.talking?.examRecordId &&
+        this.talking.examRecordId !== this.data.examRecordId
+      );
+    },
+    talkingType() {
+      return (
+        !!this.talking?.examRecordId &&
+        this.talking.examRecordId === this.data.examRecordId &&
+        (this.talking.isVideo ? "video" : "talk")
+      );
+    },
+  },
+  methods: {
+    onOperation(type) {
+      if (type === "sound") {
+        return this.$refs.ThirdViewVideo?.videoMuted();
+      }
+      if (["talk", "video"].includes(type) && this.disabled) {
+        return;
+      }
+      this.$emit("operation", { type, data: this.data });
+    },
+    toDetail() {
+      this.$router.push({
+        name: "WarningDetail",
+        params: { examRecordId: this.data.examRecordId },
+      });
+    },
+    toLargeView() {
+      this.$emit("muted-change");
+      this.$refs.ThirdViewVideo.destroyPlayer();
+      this.$refs.InvigilationStudentMediaDialog.open();
+    },
+    mediaMutedChange(muted) {
+      this.muted = muted;
+    },
+    mutedChange() {
+      this.$emit("muted-change");
+    },
+    mutedPlayer(muted) {
+      this.$refs.ThirdViewVideo.mutedPlayer(muted);
+    },
+    dialogClose() {
+      this.$refs.ThirdViewVideo.reloadVideo();
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.invigilation-student-full {
+  background: #2e3238;
+  border-radius: 10px;
+  overflow: hidden;
+  width: 255px;
+  .student-video {
+    position: relative;
+    z-index: 0;
+    height: 164px;
+    border-radius: 10px;
+    overflow: hidden;
+    border: 2px solid transparent;
+
+    &.warning {
+      border-color: #f65863;
+      .warn-icon {
+        display: block;
+      }
+    }
+    &.be-call {
+      border-color: #1a89fe;
+    }
+    .warn-icon {
+      position: absolute;
+      width: 14px;
+      height: 12px;
+      top: 10px;
+      left: 10px;
+      z-index: 2001;
+      display: none;
+    }
+    .max-icon {
+      width: 12px;
+      height: 12px;
+      position: absolute;
+      right: 10px;
+      top: 10px;
+      z-index: 2001;
+      cursor: pointer;
+    }
+    .student-info {
+      position: absolute;
+      left: 0;
+      bottom: 0;
+      width: 100%;
+      height: 32px;
+      background: rgba(46, 50, 56, 0.5);
+      display: flex;
+      align-items: center;
+      font-size: 12px;
+      color: rgba(255, 255, 255, 0.6);
+      z-index: 2001;
+      padding: 0 16px;
+      .student-info-name {
+        font-weight: bold;
+        color: #ffffff;
+      }
+      .student-info-identity {
+        margin: 0 8px;
+        flex: 1;
+        overflow: auto;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .student-info-progress {
+        display: flex;
+        align-items: center;
+        margin-left: auto;
+        .icon {
+          width: 14px;
+          height: 14px;
+          margin-right: 4px;
+        }
+      }
+    }
+  }
+  .operation-group {
+    display: flex;
+    align-items: center;
+    height: 40px;
+    padding: 0 8px 0 16px;
+    color: #fff;
+    .icon {
+      width: 16px;
+      height: 16px;
+      margin-right: 8px;
+      cursor: pointer;
+      &[disabled="disabled"] {
+        cursor: not-allowed;
+      }
+    }
+    .other-operation {
+      margin-left: auto;
+      display: flex;
+      align-items: center;
+    }
+  }
+}
+</style>

+ 287 - 0
src/features/invigilation/RealtimeMonitoring/components/InvigilationStudentTalk.vue

@@ -0,0 +1,287 @@
+<template>
+  <!-- 通话弹出层 -->
+  <div
+    v-if="dialogVisible"
+    class="communication-dialog"
+    v-move-ele.prevent.stop
+  >
+    <div class="communication-box" v-show="!isWaiting">
+      <div class="communication-host" id="communication-host"></div>
+      <div class="communication-guest" id="communication-guest"></div>
+      <div class="communication-action" @mousedown.stop>
+        <el-button round type="danger" @click.stop="hangup">结束通话</el-button>
+      </div>
+      <div class="communication-info">
+        <span>持续时长:<second-timer ref="SecondTimer"></second-timer></span>
+      </div>
+    </div>
+    <div class="communication-wait" v-show="isWaiting">
+      <p class="communication-wait-tips">等待接听…</p>
+      <div class="communication-wait-avatar">
+        <img
+          :src="detailInfo.basePhotoPath"
+          :alt="detailInfo.examStudentName"
+        />
+      </div>
+      <p class="communication-wait-username">
+        {{ detailInfo.examStudentName }}
+      </p>
+      <div class="communication-wait-action" @mousedown.stop>
+        <el-button round type="danger" @click="hangup">取消通话</el-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { invigilateDetail, getUserMonitorKey } from "@/api/invigilation";
+import {
+  checkSystemRequirements,
+  createClient,
+  createStream,
+} from "@/plugins/trtc";
+import MoveEle from "@/plugins/move-ele";
+import SecondTimer from "../../common/SecondTimer";
+
+const domEmpty = (dom) => {
+  dom.childNodes.forEach((childNode) => {
+    dom.removeChild(childNode);
+  });
+};
+
+export default {
+  name: "InvigilationStudentTalk",
+  components: {
+    SecondTimer,
+  },
+  directives: { MoveEle },
+  props: {
+    recordId: {
+      type: String,
+    },
+  },
+  data() {
+    return {
+      detailInfo: {},
+      dialogVisible: false,
+      isWaiting: false,
+      isHandUp: false,
+      subscribeSetTs: [],
+      userMonitor: {},
+      client: null,
+      localStream: null,
+    };
+  },
+  watch: {
+    dialogVisible(v) {
+      v && this.initData();
+      this.$emit("talking", { examRecordId: this.recordId, talking: v });
+    },
+  },
+  methods: {
+    async initData() {
+      await this.getInvigilateDetail().catch(() => {});
+      this.holding = false;
+    },
+    async getInvigilateDetail() {
+      const res = await invigilateDetail(this.recordId);
+      this.detailInfo = res.data.data;
+    },
+    async initClient(recordId) {
+      const res = await getUserMonitorKey(recordId);
+      this.userMonitor = res.data.data;
+      this.client = createClient({
+        mode: "live",
+        sdkAppId: this.userMonitor.appId * 1,
+        userId: this.userMonitor.monitorUserId,
+        userSig: this.userMonitor.monitorUserSig,
+        useStringRoomId: true,
+      });
+    },
+    async answer(isVideo) {
+      const result = await checkSystemRequirements().catch(() => {
+        this.$message.error(
+          `您的浏览器不支持当前音视频通讯版本。建议使用最新版的chrome浏览器!`
+        );
+      });
+      if (!result) return;
+
+      // 客户端两路视频公用一个userId:
+      // main:有音频,有视频
+      // auxiliary:无音频,有视频
+      // 手机端userId各不同
+      if (this.holding) return;
+      this.holding = true;
+      this.$emit("muted-change");
+
+      await this.initClient(this.recordId).catch(() => {});
+      if (!this.client) {
+        this.holding = false;
+        return;
+      }
+      this.localStream = await this.getLocalMedia(isVideo);
+      if (!this.localStream) {
+        this.holding = false;
+        return;
+      }
+
+      this.dialogVisible = true;
+      this.holding = false;
+      // 添加远程用户视频发布监听
+      this.client.on("stream-added", (event) => {
+        console.log(event);
+        console.log(event.stream.getUserId(), this.userMonitor.sourceUserId);
+        const remoteStream = event.stream;
+        if (remoteStream.getUserId() !== this.userMonitor.sourceUserId) return;
+        if (remoteStream.getType() !== "main") return;
+        console.log(`有效视频${remoteStream.getUserId()},准备订阅`);
+
+        this.subscribeSetTs.push(
+          setTimeout(() => {
+            this.client
+              .subscribe(remoteStream, { audio: true, video: true })
+              .catch((error) => {
+                console.log(`${remoteStream.getUserId()}视频订阅失败!`, error);
+                this.notifyError("学生视频获取失败!");
+              });
+          }, 5000)
+        );
+      });
+      this.client.on("stream-subscribed", (event) => {
+        const remoteStream = event.stream;
+        console.log(event);
+        console.log(`${remoteStream.getUserId()}视频已订阅!`);
+        this.isWaiting = false;
+        this.$nextTick(() => {
+          if (!this.$refs.SecondTimer.recoding) this.$refs.SecondTimer.start();
+          domEmpty(document.getElementById("communication-host"));
+          remoteStream.play("communication-host", { objectFit: "contain" });
+        });
+      });
+      this.client.on("stream-removed", (event) => {
+        const remoteStream = event.stream;
+        if (
+          remoteStream.getUserId() !== this.userMonitor.sourceUserId ||
+          remoteStream.getType() !== "main" ||
+          this.isHandup
+        )
+          return;
+
+        console.log(event);
+        console.log(`${remoteStream.getUserId()}已退出房间!`);
+        this.notifyError("对方已挂断!");
+        this.hangup();
+      });
+
+      // 加入房间
+      let roomJoinResult = true;
+      await this.client
+        .join({
+          roomId: this.userMonitor.monitorKey,
+          role: "audience",
+        })
+        .catch((error) => {
+          roomJoinResult = false;
+          console.log("加入房间失败!", error);
+          this.notifyError("发起通信失败!");
+        });
+      if (!roomJoinResult) return;
+      console.log("加入房间成功!");
+
+      // 切换角色,连麦互动
+      let switchResult = true;
+      await this.client.switchRole("anchor").catch((error) => {
+        console.log("切换角色失败!", error);
+        this.notifyError("角色错误!");
+        switchResult = false;
+      });
+      if (!switchResult) return;
+
+      // 发布本地视频
+      let publishStreamResult = true;
+      this.client.publish(this.localStream).catch((error) => {
+        console.log("发布本地视频失败!", error);
+        this.notifyError("本地音视频推送失败!");
+        publishStreamResult = false;
+      });
+      if (!publishStreamResult) return;
+      console.log("发布本地音视频成功!");
+
+      // 播放本地视频
+      this.localStream.play("communication-guest", { muted: true });
+      this.isHandUp = false;
+    },
+    async getLocalMedia(isVideo) {
+      const localStream = createStream({
+        userId: this.userMonitor.monitorUserId,
+        audio: true,
+        video: !!isVideo,
+      });
+      const errorTips = {
+        NotFoundError: "找不到硬件设备,请确保硬件设备正常。",
+        NotAllowedError: "不授权摄像头/麦克风访问无法进行音视频通话。",
+        NotReadableError:
+          "暂时无法访问摄像头/麦克风,请确保当前没有其他应用请求访问摄像头/麦克风,并重试。",
+        OverConstrainedError: "设备异常",
+        AbortError: "设备异常",
+      };
+
+      let initLocalStreamResult = true;
+      await localStream.initialize().catch((error) => {
+        console.log(errorTips[error.name]);
+        this.notifyError(errorTips[error.name] || "未知错误");
+        initLocalStreamResult = false;
+        localStream.close();
+      });
+      return initLocalStreamResult && localStream;
+    },
+    async hangup() {
+      if (this.isHandUp) return;
+      this.isHandUp = true;
+      this.clearSubscribeSetTs();
+      this.$refs.SecondTimer.end();
+
+      // 取消发布本地视频
+      await this.client.unpublish(this.localStream).catch((error) => {
+        console.log("取消发布本地视频失败!", error);
+      });
+
+      this.localStream.close();
+      this.localStream = null;
+
+      // 离开房间
+      let result = true;
+      await this.client.leave().catch((error) => {
+        console.log("离开房间失败!", error);
+        this.notifyError("操作异常,请重新尝试!");
+        result = false;
+      });
+      if (!result) return;
+
+      this.client.off("*");
+      this.client = null;
+      this.userMonitor = {};
+
+      this.dialogVisible = false;
+      this.isWaiting = true;
+    },
+    notifyError(content) {
+      this.$notify({
+        type: "error",
+        message: content,
+      });
+    },
+    clearSubscribeSetTs() {
+      if (!this.subscribeSetTs.length) return;
+      this.subscribeSetTs.forEach((sett) => {
+        clearTimeout(sett);
+      });
+      this.subscribeSetTs = [];
+    },
+    open(isVideo = false) {
+      this.dialogVisible = true;
+      this.answer(isVideo);
+    },
+  },
+};
+</script>

+ 183 - 0
src/features/invigilation/RealtimeMonitoring/components/RealtimeMonitoringFullHeader.vue

@@ -0,0 +1,183 @@
+<template>
+  <div class="full-view-header">
+    <div class="esc-back" @click="$emit('back')">
+      <span class="icon icon-back"></span>
+      <span class="esc">ESC返回</span>
+    </div>
+    <div class="filter-form">
+      <el-form inline>
+        <el-form-item class="exam-select">
+          <div @click="$refs.ExamBatchDialog.open()">
+            <el-input
+              v-model="curExamBatch.label"
+              placeholder="请选择批次"
+              suffix-icon="el-icon-arrow-down"
+              readonly
+            ></el-input>
+          </div>
+        </el-form-item>
+        <el-form-item class="exam-room-select">
+          <el-select v-model="filter.roomCode" placeholder="考场">
+            <el-option
+              v-for="item in examRooms"
+              :key="item.roomCode"
+              :label="item.roomName"
+              :value="item.roomCode"
+            >
+              <span>{{ item.roomName }}</span>
+            </el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item v-if="viewingAngles.length" class="media-source-select">
+          <el-select v-model="filter.monitorVideoSource" placeholder="视频源">
+            <el-option
+              v-for="item in viewingAngles"
+              :key="item.code"
+              :label="item.name"
+              :value="item.code"
+            >
+              <span>{{ item.name }}</span>
+            </el-option>
+          </el-select>
+        </el-form-item>
+      </el-form>
+    </div>
+    <div class="current-date-time">
+      <TextClock :showIcon="false" :showApm="false" />
+    </div>
+    <!-- 考试批次选择 -->
+    <exam-batch-dialog
+      @confirm="examChange"
+      ref="ExamBatchDialog"
+    ></exam-batch-dialog>
+  </div>
+</template>
+
+<script>
+import { examRoomList } from "@/api/invigilation";
+import { VIDEO_SOURCE_TYPE } from "@/constant/constants";
+import ExamBatchDialog from "../ExamBatchDialog";
+import TextClock from "../../common/TextClock";
+export default {
+  name: "RealtimeMonitoringFullHeader",
+  components: {
+    TextClock,
+    ExamBatchDialog,
+  },
+  data() {
+    return {
+      /** 筛选条件 */
+      filter: {
+        examId: "",
+        roomCode: "",
+        monitorVideoSource: "",
+      },
+      /** 考场列表 */
+      examRooms: [],
+      /** 视频源 */
+      viewingAngles: [],
+      /** 当前考试批次 */
+      curExamBatch: {},
+    };
+  },
+  watch: {
+    filter: {
+      handler() {
+        this.$emit("filterChange", this.filter);
+      },
+      immediate: true,
+      deep: true,
+    },
+    "filter.examId": {
+      handler: "getExamRooms",
+      immediate: true,
+    },
+  },
+  methods: {
+    examChange(examBatch) {
+      if (!examBatch) return;
+      this.curExamBatch = examBatch;
+      if (examBatch.monitorVideoSource) {
+        this.viewingAngles = examBatch.monitorVideoSource
+          .split(",")
+          .map((item) => {
+            return {
+              code: item,
+              name: VIDEO_SOURCE_TYPE[item],
+            };
+          });
+      } else {
+        this.viewingAngles = [];
+      }
+      this.filter.examId = examBatch.id;
+      this.filter.monitorVideoSource = this.viewingAngles?.[0]?.code ?? "";
+    },
+    async getExamRooms() {
+      this.examRooms = [];
+      if (!this.curExamBatch.code) return;
+      const res = await examRoomList(this.curExamBatch.code);
+      this.examRooms = res.data.data ?? [];
+      this.filter.roomCode = this.examRooms[0]?.roomCode;
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.full-view-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 14px 20px;
+  .esc-back {
+    display: flex;
+    align-items: center;
+    cursor: pointer;
+    &:hover {
+      filter: brightness(1.2);
+    }
+    .icon {
+      width: 24px;
+      height: 24px;
+    }
+    .esc {
+      font-size: 14px;
+      color: #fff;
+      font-weight: bold;
+      margin-left: 10px;
+    }
+  }
+  ::v-deep .filter-form {
+    .exam-select {
+      .el-input {
+        min-width: 450px;
+        .el-input__icon {
+          line-height: 40px;
+        }
+      }
+    }
+    .exam-room-select {
+      width: 200px;
+    }
+    .media-source-select {
+      width: 160px;
+    }
+    .el-input {
+      .el-input__icon {
+        line-height: 32px;
+      }
+      .el-input__inner {
+        height: 36px;
+        background: rgba(66, 66, 69, 0);
+        border-radius: 10px;
+        border: 2px solid #3f444d;
+      }
+    }
+  }
+  .current-date-time {
+    font-size: 12px;
+    font-weight: bold;
+    color: #a1a8b3;
+  }
+}
+</style>

+ 132 - 0
src/features/invigilation/RealtimeMonitoring/components/RequestCallList.vue

@@ -0,0 +1,132 @@
+<template>
+  <div class="card-box card-box-list">
+    <div class="card-box-title">通话申请</div>
+    <ul class="card-box-list-content">
+      <li
+        class="card-box card-box-list-item"
+        v-for="(item, i) in viewList"
+        :key="item.id"
+      >
+        <span class="icon icon-call-full"></span>
+        <span class="ellipsis card-box-list-item-user">
+          {{ item.examStudentName }}
+        </span>
+        <span class="call-time">{{ item.durationTime }}</span>
+        <span class="ellipsis early-warning-reason">
+          <span
+            v-if="item.callStatus === 'CANCEL'"
+            class="operation"
+            @click="onCancel(item, i)"
+          >
+            忽略
+          </span>
+          <span class="operation" @click="$emit('backCall', item)">
+            {{ item.callStatus === "CANCEL" ? "回拨" : "接听" }}
+          </span>
+        </span>
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script>
+import { communicationOver } from "@/api/invigilation";
+import IntervalMixin from "../mixins/IntervalMixin";
+export default {
+  name: "RequestCallList",
+  mixin: [IntervalMixin],
+  props: {
+    list: {
+      type: Array,
+      default: () => [],
+    },
+  },
+  data() {
+    return {
+      removed: [],
+    };
+  },
+  computed: {
+    viewList() {
+      return this.list.filter((item) => !this.removed.includes(item.id));
+    },
+  },
+  methods: {
+    async onCancel(item) {
+      this.removed.push(item.id);
+      // await communicationCalling({
+      //   recordId: item.examRecordId,
+      //   source: item.source,
+      // });
+      await communicationOver({
+        recordId: item.examRecordId,
+        source: item.source,
+      });
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.ellipsis {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+.card-box-list {
+  padding: 0 10px 10px;
+  list-style: none;
+  .card-box-title {
+    font-size: 14px;
+    font-weight: bold;
+    color: #ffffff;
+    line-height: 1;
+    padding: 20px 0;
+  }
+  .card-box-list-content {
+    margin: 0;
+    padding: 0;
+    padding-right: 10px;
+    height: 300px; // 6
+    overflow-y: auto;
+    .card-box-list-item {
+      width: unset;
+      background-color: #3f444d;
+      padding: 10px;
+      line-height: 1;
+      display: flex;
+      align-items: center;
+      color: #fff;
+      font-size: 12px;
+      &:not(:last-child) {
+        margin-bottom: 6px;
+      }
+      .icon {
+        width: 24px;
+        height: 24px;
+      }
+      .card-box-list-item-user {
+        font-weight: bold;
+        margin: 0 10px;
+        width: 48px;
+      }
+      .call-time {
+        color: #a1a8b3;
+      }
+      .early-warning-reason {
+        color: #a1a8b3;
+        margin-left: auto;
+        flex: 1;
+        text-align: right;
+        .operation {
+          margin-left: 10px;
+          cursor: pointer;
+          &:hover {
+            filter: brightness(1.2);
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 101 - 0
src/features/invigilation/RealtimeMonitoring/components/SummaryLineFull.vue

@@ -0,0 +1,101 @@
+<template>
+  <div class="summary-line-box">
+    <div
+      class="summary-item"
+      :style="getDotColor(colors[index % colors.length])"
+      v-for="(item, index) in paramList"
+      :key="item.param"
+    >
+      <!-- <el-popover
+        placement="bottom-start"
+        popper-class="warning-popover"
+        width="300"
+        trigger="hover"
+        :content="item.content"
+        v-if="item.desc"
+      >
+        <span class="line-name" slot="reference">{{ item.name }}:</span>
+      </el-popover>
+      <span class="line-name" v-else>{{ item.name }}</span> -->
+      <span class="line-name">{{ item.name }}</span>
+      <span class="line-num">
+        {{ examPropData[item.param] }}{{ item.unit }}
+      </span>
+    </div>
+  </div>
+</template>
+
+<script>
+import summaryLineMixin from "../../common/mixins/summaryLineMixin";
+export default {
+  name: "SummaryLineFull",
+  mixins: [summaryLineMixin],
+  data() {
+    return {
+      colors: [
+        ["#35BBFF", "#1886FE"],
+        ["#98E6FD", "#5FC9FA"],
+        ["#B9A9FF", "#8370FF"],
+        ["#3CEACF", "#1CD1A1"],
+        ["#FF919C", "#FE5863"],
+      ],
+    };
+  },
+  methods: {
+    getDotColor([startColor, endColor]) {
+      return {
+        "--dot-color": `linear-gradient(180deg,${startColor} 0%, ${endColor} 100%)`,
+      };
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.summary-line-box {
+  display: flex;
+  align-items: center;
+  .summary-item {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    height: 48px;
+    padding: 16px 16px 16px 40px;
+    background: #3f444d;
+    border-radius: 10px;
+    font-size: 14px;
+    color: #fff;
+    user-select: none;
+    &:not(:last-child) {
+      margin-right: 10px;
+    }
+    .line-name {
+      font-weight: bold;
+      margin-right: 40px;
+    }
+    .line-num {
+      font-size: 16px;
+    }
+
+    &:before,
+    &:after {
+      content: "";
+      position: absolute;
+      left: 16px;
+      top: 50%;
+      width: 16px;
+      height: 16px;
+      border-radius: 8px;
+      background: var(--dot-color);
+      transform: translate(0, -50%);
+    }
+    &:after {
+      top: calc(50% + 4px);
+      opacity: 0.5;
+      filter: blur(4px);
+      z-index: 1;
+    }
+  }
+}
+</style>

+ 16 - 0
src/features/invigilation/RealtimeMonitoring/mixins/IntervalMixin.js

@@ -0,0 +1,16 @@
+export default {
+  methods: {
+    interval(cb, time) {
+      let timer = setInterval(cb, time);
+      const clearTimer = () => {
+        if (timer) {
+          clearInterval(timer);
+          timer = null;
+        }
+      };
+      this.$once("hook:beforeDestroy", clearTimer);
+      this.$once("hook:deactivated", clearTimer);
+      return clearTimer;
+    },
+  },
+};

+ 51 - 0
src/features/invigilation/RealtimeMonitoring/mixins/RealtimeMonitoringMixin.js

@@ -0,0 +1,51 @@
+import { invigilateExamFinish } from "@/api/invigilation";
+
+export default {
+  data() {
+    return {
+      filter: {
+        examId: "",
+        roomCode: "",
+        paperDownload: null,
+        status: null,
+        monitorStatusSource: null,
+        monitorVideoSource: null,
+        name: null,
+        identity: null,
+        maxWarningCount: undefined,
+        minWarningCount: undefined,
+      },
+    };
+  },
+  methods: {
+    /** 结束监考 */
+    async finishInvigilationExam() {
+      const result = await this.$confirm(
+        "确定要结束监考吗?",
+        "结束监考确认提醒",
+        {
+          confirmButtonText: "确定",
+          cancelButtonText: "取消",
+          iconClass: "el-icon-warning",
+          customClass: "el-message-box__error",
+        }
+      ).catch(() => {});
+
+      if (!result) return;
+
+      await invigilateExamFinish(this.filter.examId);
+      this.$refs.ExamBatchDialog.getExamList();
+      this.$message({
+        type: "success",
+        message: "操作成功!",
+      });
+    },
+    /** 查看详情 */
+    toDetail(row) {
+      this.$router.push({
+        name: "WarningDetail",
+        params: { examRecordId: row.examRecordId },
+      });
+    },
+  },
+};

+ 13 - 1
src/features/invigilation/common/FlvMedia.vue

@@ -18,7 +18,7 @@
       </div>
     </div>
     <div
-      v-if="liveUrl && !result.error && !loading"
+      v-if="liveUrl && !result.error && !loading && !hideAudioIcon"
       class="media-video-muted"
       @click.stop="videoMuted"
     >
@@ -43,6 +43,10 @@ export default {
       type: Number,
       default: 3,
     },
+    hideAudioIcon: {
+      type: Boolean,
+      default: false,
+    },
   },
   data() {
     return {
@@ -56,6 +60,14 @@ export default {
       },
     };
   },
+  watch: {
+    muted: {
+      handler() {
+        this.$emit("media-muted-change", this.muted);
+      },
+      immediate: true,
+    },
+  },
   mounted() {
     this.initVideo();
     this.retryCount++;

+ 2 - 107
src/features/invigilation/common/SummaryLine.vue

@@ -21,114 +21,9 @@
 </template>
 
 <script>
-import { examPropCount } from "@/api/invigilation";
-
-const paramInfo = {
-  all: {
-    name: "全部应考",
-    param: "allCount",
-    desc: "参加考试的全部考生。",
-    icon: "users",
-    unit: "人",
-  },
-  finish: {
-    name: "完成率",
-    param: "completionRate",
-    desc: "“已完成”科次数量占应考科次数量的百分比。",
-    icon: "rate",
-    unit: "%",
-  },
-  prepare: {
-    name: "已待考",
-    param: "prepareCount",
-    desc: "已进入待考界面等待开考的考生。",
-    unit: "人",
-  },
-  exam: {
-    name: "考试中",
-    param: "examCount",
-    desc: "正在答题的考生。",
-    unit: "人",
-  },
-  complete: {
-    name: "已交卷",
-    param: "alreadyComplete",
-    desc: "某科次的某次考试已完成“交卷”。",
-    unit: "人",
-  },
-  trouble: {
-    name: "通讯故障",
-    param: "clientWebsocketStatusCount",
-    desc:
-      "考生端出现断网、断电、软硬件故障等异常导致考生端与监考端无法正常连接的考生。",
-    unit: "人",
-  },
-  unfinish: {
-    name: "未参加考试",
-    param: "notComplete",
-    desc: "某科次完成“交卷”的考试次数为0。",
-    unit: "人",
-  },
-};
-const types = {
-  trouble: ["all", "prepare", "exam", "complete", "unfinish"],
-  complete: ["all", "prepare", "exam", "complete", "unfinish"],
-  progress: ["all", "prepare", "exam", "complete", "unfinish"],
-};
-// const types = {
-//   trouble: ["all", "prepare", "exam", "trouble", "unfinish"],
-//   complete: ["all", "prepare", "exam", "complete", "unfinish"],
-//   progress: ["all", "finish", "prepare", "unfinish"],
-// };
-
+import summaryLineMixin from "./mixins/summaryLineMixin";
 export default {
   name: "summary-line",
-  props: {
-    examId: {
-      type: String,
-    },
-    dataType: {
-      type: String,
-      required: true,
-      validator: (val) => {
-        return ["trouble", "complete", "progress"].includes(val);
-      },
-    },
-  },
-  // mounted() {
-  //   this.initData();
-  // },
-  watch: {
-    examId: {
-      immediate: true,
-      handler() {
-        this.initData();
-      },
-    },
-  },
-  data() {
-    return {
-      paramList: [],
-      examPropData: {},
-    };
-  },
-  methods: {
-    async initData() {
-      if (!this.examId) return;
-      const res = await examPropCount(this.examId).catch(() => {});
-      this.examPropData = (res && res.data && res.data.data) || {};
-      if (this.examPropData["completionRate"])
-        this.examPropData["completionRate"] = Math.round(
-          this.examPropData["completionRate"] * 100
-        );
-      this.paramList = types[this.dataType].map((item) => {
-        let info = paramInfo[item];
-        return {
-          ...info,
-          content: `${info.name}:${info.desc}`,
-        };
-      });
-    },
-  },
+  mixins: [summaryLineMixin],
 };
 </script>

+ 110 - 0
src/features/invigilation/common/mixins/summaryLineMixin.js

@@ -0,0 +1,110 @@
+import { examPropCount } from "@/api/invigilation";
+
+const paramInfo = {
+  all: {
+    name: "全部应考",
+    param: "allCount",
+    desc: "参加考试的全部考生。",
+    icon: "users",
+    unit: "人",
+  },
+  finish: {
+    name: "完成率",
+    param: "completionRate",
+    desc: "“已完成”科次数量占应考科次数量的百分比。",
+    icon: "rate",
+    unit: "%",
+  },
+  prepare: {
+    name: "已待考",
+    param: "prepareCount",
+    desc: "已进入待考界面等待开考的考生。",
+    unit: "人",
+  },
+  exam: {
+    name: "考试中",
+    param: "examCount",
+    desc: "正在答题的考生。",
+    unit: "人",
+  },
+  complete: {
+    name: "已交卷",
+    param: "alreadyComplete",
+    desc: "某科次的某次考试已完成“交卷”。",
+    unit: "人",
+  },
+  trouble: {
+    name: "通讯故障",
+    param: "clientWebsocketStatusCount",
+    desc:
+      "考生端出现断网、断电、软硬件故障等异常导致考生端与监考端无法正常连接的考生。",
+    unit: "人",
+  },
+  unfinish: {
+    name: "未参加考试",
+    param: "notComplete",
+    desc: "某科次完成“交卷”的考试次数为0。",
+    unit: "人",
+  },
+};
+const types = {
+  trouble: ["all", "prepare", "exam", "complete", "unfinish"],
+  complete: ["all", "prepare", "exam", "complete", "unfinish"],
+  progress: ["all", "prepare", "exam", "complete", "unfinish"],
+};
+// const types = {
+//   trouble: ["all", "prepare", "exam", "trouble", "unfinish"],
+//   complete: ["all", "prepare", "exam", "complete", "unfinish"],
+//   progress: ["all", "finish", "prepare", "unfinish"],
+// };
+
+export default {
+  name: "summary-line",
+  props: {
+    examId: {
+      type: String,
+    },
+    dataType: {
+      type: String,
+      required: true,
+      validator: (val) => {
+        return ["trouble", "complete", "progress"].includes(val);
+      },
+    },
+  },
+  // mounted() {
+  //   this.initData();
+  // },
+  watch: {
+    examId: {
+      immediate: true,
+      handler() {
+        this.initData();
+      },
+    },
+  },
+  data() {
+    return {
+      paramList: [],
+      examPropData: {},
+    };
+  },
+  methods: {
+    async initData() {
+      if (!this.examId) return;
+      const res = await examPropCount(this.examId).catch(() => {});
+      this.examPropData = (res && res.data && res.data.data) || {};
+      if (this.examPropData["completionRate"])
+        this.examPropData["completionRate"] = Math.round(
+          this.examPropData["completionRate"] * 100
+        );
+      this.paramList = types[this.dataType].map((item) => {
+        let info = paramInfo[item];
+        return {
+          ...info,
+          content: `${info.name}:${info.desc}`,
+        };
+      });
+    },
+  },
+};

+ 8 - 0
src/router/invigilation.js

@@ -67,6 +67,14 @@ const routes = [
         /* webpackChunkName: "monitor" */ "../features/invigilation/RealtimeMonitoring/RealtimeMonitoring"
       ),
   },
+  {
+    path: "realtime-monitoring-full",
+    name: "RealtimeMonitoringFull",
+    component: () =>
+      import(
+        /* webpackChunkName: "monitor" */ "../features/invigilation/RealtimeMonitoring/RealtimeMonitoringFull"
+      ),
+  },
   {
     path: "video-communication/:examId/:roomCode?",
     name: "VideoCommunication",

+ 64 - 1
src/styles/icons.scss

@@ -6,7 +6,7 @@
   height: 16px;
   background-repeat: no-repeat;
   background-size: 100% 100%;
-
+  
   &-base {
     background-image: url(../assets/icon-base.png);
   }
@@ -237,4 +237,67 @@
   &-arrows-down {
     background-image: url(../assets/icon-arrows-down.png);
   }
+
+  &-call-full {
+    background-image: url(../assets/icon-call-full.png);
+  }
+
+  &-tip {
+    background-image: url(../assets/icon-tip.png);
+  }
+
+  &-warn {
+    background-image: url(../assets/icon-warn.png);
+  }
+
+  &-back {
+    background-image: url(../assets/icon-back.png);
+  }
+  &-student-rate {
+    background-image: url(../assets/icon-student-rate.png);
+  }
+
+  &-send-text {
+    background-image: url(../assets/icon-send-text.png);
+    &-active {
+      background-image: url(../assets/icon-send-text-light.png);
+    }
+  }
+
+  &-send-video {
+    background-image: url(../assets/icon-send-video.png);
+    &-active {
+      background-image: url(../assets/icon-send-video-light.png);
+    }
+  }
+
+  &-sound {
+    background-image: url(../assets/icon-sound.png);
+    &-active {
+      background-image: url(../assets/icon-sound-light.png);
+    }
+  }
+
+  &-talk {
+    background-image: url(../assets/icon-talk.png);
+    &-active {
+      background-image: url(../assets/icon-talk-light.png);
+    }
+  }
+
+  &-voice {
+    background-image: url(../assets/icon-voice.png);
+    &-active {
+      background-image: url(../assets/icon-voice-light.png);
+    }
+  }
+
+  &-full {
+    background-image: url(../assets/icon-full.png);
+  }
+  
+  &-waring {
+    background-image: url(../assets/icon-waring.png);
+  }
+
 }