zhangjie 4 anni fa
parent
commit
fa9c5285cf

+ 35 - 2
src/api/invigilation.js

@@ -9,6 +9,29 @@ export function getUserMonitorKey(recordId) {
     {}
   );
 }
+// exam-invigilation
+export function examInvigilationCount() {
+  return httpApp.post("/api/admin/report/examination_monitor/count", {});
+}
+export function examInvigilationWarnDistribution() {
+  return httpApp.post(
+    "/api/admin/report/examination_monitor/warn_distribution",
+    {}
+  );
+}
+export function examInvigilationWarnTrend() {
+  return httpApp.post("/api/admin/report/examination_monitor/warn_trend", {});
+}
+export function examInvigilationVideoRandomList(datas) {
+  const data = pickBy(datas, (v) => v !== "");
+  return httpApp.post(
+    "/api/admin/invigilate/list/video/random?" + object2QueryString(data),
+    {}
+  );
+}
+export function examInvigilationWarnMessage() {
+  return httpApp.post("/api/admin/report/examination_monitor/warn_trend", {});
+}
 
 // realtime-monitoring
 export function invigilateList(datas) {
@@ -171,10 +194,10 @@ export function updateBreachInfo(datas) {
 
   return httpApp.post("/api/admin/invigilate/breach", data);
 }
-// TODO:发送文字消息
+// 发送文字/音频消息
 export function sendWarningMsg(datas) {
   const data = pickBy(datas, (v) => v !== "");
-  return httpApp.post("/api/admin/invigilate/breach", data);
+  return httpApp.post("/api/admin/invigilate/notice", data);
 }
 
 // reexam-apply
@@ -233,6 +256,16 @@ export function progressDetailList(datas) {
     {}
   );
 }
+export function downloadProgressResult(datas) {
+  const data = pickBy(datas, (v) => v !== "");
+  return httpApp.post(
+    "/api/admin/invigilate/progress/list/export?" + object2QueryString(data),
+    {},
+    {
+      responseType: "blob",
+    }
+  );
+}
 
 // exam-report
 // report-overview

BIN
src/assets/icon-arrows-down.png


BIN
src/assets/icon-arrows-up.png


+ 175 - 161
src/features/invigilation/ExamInvigilation/ExamInvigilation.vue

@@ -14,14 +14,15 @@
               <span>在线(人)</span>
               <el-popover
                 placement="top-start"
+                popper-class="warning-popover"
                 width="200"
                 trigger="hover"
-                content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。"
+                content=""
               >
                 <i class="el-icon-question" slot="reference"></i>
               </el-popover>
             </h5>
-            <p>26000</p>
+            <p>{{ monitorCount.onlineCount }}</p>
           </div>
         </div>
         <div class="invigilation-summary-item">
@@ -30,14 +31,15 @@
               <span>待考(人)</span>
               <el-popover
                 placement="top-start"
+                popper-class="warning-popover"
                 width="200"
                 trigger="hover"
-                content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。"
+                content="已进入待考界面等待开考的考生。"
               >
                 <i class="el-icon-question" slot="reference"></i>
               </el-popover>
             </h5>
-            <p>170</p>
+            <p>{{ monitorCount.waitingCount }}</p>
           </div>
         </div>
         <div class="invigilation-summary-item">
@@ -46,14 +48,15 @@
               <span>考试中(人)</span>
               <el-popover
                 placement="top-start"
+                popper-class="warning-popover"
                 width="200"
                 trigger="hover"
-                content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。"
+                content="正在答题的考生。"
               >
                 <i class="el-icon-question" slot="reference"></i>
               </el-popover>
             </h5>
-            <p>2560</p>
+            <p>{{ monitorCount.examingCount }}</p>
           </div>
         </div>
         <div class="invigilation-summary-item">
@@ -62,14 +65,31 @@
               <span>通讯故障(人)</span>
               <el-popover
                 placement="top-start"
+                popper-class="warning-popover"
                 width="200"
                 trigger="hover"
-                content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。"
+                content="考生端出现断网、断电、软硬件故障等异常导致考生端与监考端无法正常连接的考生。"
               >
                 <i class="el-icon-question" slot="reference"></i>
               </el-popover>
             </h5>
-            <p>26000</p>
+            <p
+              :class="{
+                'color-danger': monitorCount.exceptionCountChange > 0,
+                'color-success': monitorCount.exceptionCountChange < 0,
+              }"
+            >
+              {{ monitorCount.exceptionCount }}
+              <i
+                :class="[
+                  'icon',
+                  {
+                    'icon-arrows-up': monitorCount.exceptionCountChange > 0,
+                    'icon-arrows-down': monitorCount.exceptionCountChange < 0,
+                  },
+                ]"
+              ></i>
+            </p>
           </div>
         </div>
         <div class="invigilation-summary-item">
@@ -78,14 +98,31 @@
               <span>预警(人)</span>
               <el-popover
                 placement="top-start"
+                popper-class="warning-popover"
                 width="200"
                 trigger="hover"
-                content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。"
+                content=""
               >
                 <i class="el-icon-question" slot="reference"></i>
               </el-popover>
             </h5>
-            <p>15</p>
+            <p
+              :class="{
+                'color-danger': monitorCount.warnCountChange > 0,
+                'color-success': monitorCount.warnCountChange < 0,
+              }"
+            >
+              {{ monitorCount.warnCount }}
+              <i
+                :class="[
+                  'icon',
+                  {
+                    'icon-arrows-up': monitorCount.warnCountChange > 0,
+                    'icon-arrows-down': monitorCount.warnCountChange < 0,
+                  },
+                ]"
+              ></i>
+            </p>
           </div>
         </div>
       </div>
@@ -94,9 +131,10 @@
           <span>各机构在线考试人数分布</span>
           <el-popover
             placement="top-start"
+            popper-class="warning-popover"
             width="200"
             trigger="hover"
-            content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。"
+            content=""
           >
             <i class="el-icon-question" slot="reference"></i>
           </el-popover>
@@ -104,7 +142,6 @@
         <echart-render
           :chart-data="onlineData"
           chart-type="bar"
-          v-if="chartDataReady"
         ></echart-render>
       </div>
       <div class="invigilation-warning">
@@ -115,9 +152,10 @@
                 <span>机构预警分布</span>
                 <el-popover
                   placement="top-start"
+                  popper-class="warning-popover"
                   width="200"
                   trigger="hover"
-                  content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。"
+                  content=""
                 >
                   <i class="el-icon-question" slot="reference"></i>
                 </el-popover>
@@ -125,7 +163,6 @@
               <echart-render
                 :chart-data="orgWarningData"
                 chart-type="pieAnnulus"
-                v-if="chartDataReady"
               ></echart-render>
             </div>
           </el-col>
@@ -135,9 +172,10 @@
                 <span>预警类型分布</span>
                 <el-popover
                   placement="top-start"
+                  popper-class="warning-popover"
                   width="200"
                   trigger="hover"
-                  content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。"
+                  content=""
                 >
                   <i class="el-icon-question" slot="reference"></i>
                 </el-popover>
@@ -145,7 +183,6 @@
               <echart-render
                 :chart-data="typeWarningData"
                 chart-type="pieAnnulus"
-                v-if="chartDataReady"
               ></echart-render>
             </div>
           </el-col>
@@ -156,9 +193,10 @@
           <span>预警时间趋势</span>
           <el-popover
             placement="top-start"
+            popper-class="warning-popover"
             width="200"
             trigger="hover"
-            content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。"
+            content=""
           >
             <i class="el-icon-question" slot="reference"></i>
           </el-popover>
@@ -166,7 +204,6 @@
         <echart-render
           :chart-data="warningTrendData"
           chart-type="line"
-          v-if="chartDataReady"
         ></echart-render>
       </div>
       <div class="invigilation-message part-box">
@@ -174,26 +211,24 @@
           <span>预警消息</span>
           <el-popover
             placement="top-start"
+            popper-class="warning-popover"
             width="200"
             trigger="hover"
-            content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。"
+            content=""
           >
             <i class="el-icon-question" slot="reference"></i>
           </el-popover>
         </h3>
         <div class="message-list">
-          <div class="message-item">
-            <span><i class="el-icon-warning"></i></span>
-            <span>2020-7-1 08:23</span>
-            <span
-              >张三(证件号:1001001)陌生人入境,系统已提示李四(账号:lisi)进行人工干预</span
-            >
-          </div>
-          <div class="message-item">
+          <div class="message-item" v-for="item in messages" :key="item.id">
             <span><i class="el-icon-warning"></i></span>
-            <span>2020-7-1 08:23</span>
+            <span>{{ item.createdTime }}</span>
             <span
-              >张三(证件号:1001001)陌生人入境,系统已提示李四(账号:lisi)进行人工干预</span
+              >{{ item.name }}(证件号:{{
+                item.identity
+              }})陌生人入境,系统已提示{{ item.actionName }}(账号:{{
+                item.actionAccount
+              }})进行人工干预</span
             >
           </div>
         </div>
@@ -213,145 +248,47 @@
 <script>
 import EchartRender from "../common/EchartRender";
 import InvigilationStudent from "../common/InvigilationStudent";
+import {
+  examInvigilationCount,
+  examInvigilationWarnDistribution,
+  examInvigilationWarnTrend,
+  examInvigilationVideoRandomList,
+  examInvigilationWarnMessage,
+} from "@/api/invigilation";
 
 export default {
   name: "exam-invigilation",
   components: { EchartRender, InvigilationStudent },
   data() {
     return {
+      monitorCount: {
+        onlineCount: 0,
+        waitingCount: 0,
+        examingCount: 0,
+        exceptionCount: 0,
+        warnCount: 0,
+        exceptionCountChange: false,
+        warnCountChange: false,
+      },
       onlineData: {
-        dataList: [
-          {
-            name: "数学学院",
-            count: 500,
-          },
-          {
-            name: "物理学院",
-            count: 700,
-          },
-          {
-            name: "计算机学院",
-            count: 300,
-          },
-          {
-            name: "外语学院",
-            count: 400,
-          },
-        ],
+        dataList: [],
         type: "light", // light or dark
       },
       orgWarningData: {
-        dataList: [
-          {
-            name: "数学学院",
-            count: 50,
-          },
-          {
-            name: "物理学院",
-            count: 5,
-          },
-          {
-            name: "计算机学院",
-            count: 20,
-          },
-          {
-            name: "外语学院",
-            count: 25,
-          },
-        ],
+        dataList: [],
         type: "light",
       },
       typeWarningData: {
-        dataList: [
-          {
-            name: "频繁离开座位",
-            count: 50,
-          },
-          {
-            name: "身份验证不通过",
-            count: 5,
-          },
-          {
-            name: "陌生人入境",
-            count: 20,
-          },
-          {
-            name: "疑似:开启虚拟摄像头",
-            count: 25,
-          },
-        ],
+        dataList: [],
         type: "light",
       },
       warningTrendData: {
-        dataList: [
-          {
-            name: "8:00",
-            count: 13,
-          },
-          {
-            name: "9:00",
-            count: 16,
-          },
-          {
-            name: "10:00",
-            count: 20,
-          },
-          {
-            name: "11:00",
-            count: 16,
-          },
-          {
-            name: "12:00",
-            count: 10,
-          },
-          {
-            name: "13:00",
-            count: 16,
-          },
-          {
-            name: "14:00",
-            count: 25,
-          },
-          {
-            name: "15:00",
-            count: 30,
-          },
-          {
-            name: "16:00",
-            count: 22,
-          },
-          {
-            name: "17:00",
-            count: 15,
-          },
-          {
-            name: "18:00",
-            count: 12,
-          },
-          {
-            name: "19:00",
-            count: 20,
-          },
-        ],
+        dataList: [],
         type: "light",
       },
-      chartDataReady: false,
-      students: [
-        {
-          name: "刘西西",
-          identity: "000000000000000008",
-          progress: "52%",
-          warning: false,
-          netbreak: true,
-        },
-        {
-          name: "刘西西",
-          identity: "000000000000000008",
-          progress: "52%",
-          warning: true,
-          netbreak: false,
-        },
-      ],
+      setT: null,
+      students: [],
+      messages: [],
     };
   },
   computed: {
@@ -365,9 +302,82 @@ export default {
     },
   },
   mounted() {
-    this.chartDataReady = true;
+    this.initData();
   },
   methods: {
+    async initData() {
+      if (this.setT) clearTimeout(this.setT);
+
+      const fetchAll = [
+        this.getCount(),
+        this.getWarnDistribution(),
+        this.getWarnTrend(),
+      ];
+      await Promise.all(fetchAll).catch(() => {});
+
+      this.fullScreenChange(this.checkDocIsFullscreen());
+
+      // this.getVideoList();
+
+      this.setT = setTimeout(() => {
+        this.initData();
+      }, 10 * 1000);
+    },
+    async getCount() {
+      const res = await examInvigilationCount();
+      const data = res.data.data;
+      this.monitorCount = {
+        onlineCount: data.onlineCount,
+        waitingCount: data.waitingCount,
+        examingCount: data.examingCount,
+        exceptionCount: data.exceptionCount,
+        warnCount: data.warnCount,
+        exceptionCountChange:
+          data.exceptionCount - this.monitorCount.exceptionCount,
+        warnCountChange: data.warnCount - this.monitorCount.warnCount,
+      };
+      this.onlineData.dataList = data.orgExamingCount.map((item) => {
+        return {
+          name: item.orgName,
+          count: item.count * 1,
+        };
+      });
+    },
+    async getWarnDistribution() {
+      const res = await examInvigilationWarnDistribution();
+      const data = res.data.data;
+      this.orgWarningData.dataList = data.orgDistribution.map((item) => {
+        return {
+          name: item.orgName,
+          count: item.count,
+        };
+      });
+      this.typeWarningData.dataList = data.typeDistribution.map((item) => {
+        return {
+          name: item.type,
+          count: item.count * 1,
+        };
+      });
+    },
+    async getWarnTrend() {
+      const res = await examInvigilationWarnTrend();
+      this.warningTrendData.dataList = res.data.data.map((item) => {
+        return {
+          name: item.hour,
+          count: item.count * 1,
+        };
+      });
+    },
+    async getVideoList() {
+      const res = await examInvigilationVideoRandomList({ randomNum: 4 });
+      this.students = res.data.data;
+    },
+    async getMessageList() {
+      const res = await examInvigilationWarnMessage();
+      this.messages = res.data.data;
+      // TODO:
+    },
+    // fullscreen
     exitFullscreen() {
       const exitFullscreen =
         document.exitFullscreen ||
@@ -385,17 +395,16 @@ export default {
       );
     },
     fullScreenChange(isFullScreen) {
-      this.chartDataReady = false;
-      this.$nextTick(() => {
-        const colorType = isFullScreen ? "dark" : "light";
-        this.onlineData.type = colorType;
-        this.orgWarningData.type = colorType;
-        this.typeWarningData.type = colorType;
-        this.warningTrendData.type = colorType;
-        this.chartDataReady = true;
-      });
+      const colorType = isFullScreen ? "dark" : "light";
+      this.onlineData.type = colorType;
+      this.orgWarningData.type = colorType;
+      this.typeWarningData.type = colorType;
+      this.warningTrendData.type = colorType;
     },
   },
+  beforeDestroy() {
+    if (this.setT) clearTimeout(this.setT);
+  },
 };
 </script>
 
@@ -403,6 +412,7 @@ export default {
 .exam-invigilation {
   position: relative;
   padding-right: 310px;
+  min-width: 1250px;
   .part-box-head-right {
     display: none;
   }
@@ -443,6 +453,7 @@ export default {
   }
 }
 .invigilation-analysis {
+  min-width: 940px;
   .part-box-head-left h1 {
     line-height: 25px;
   }
@@ -457,6 +468,7 @@ export default {
     width: 20%;
     padding: 0 10px;
     font-size: 14px;
+    color: #202b4b;
 
     h5 {
       font-size: 14px;
@@ -468,9 +480,11 @@ export default {
       font-size: 32px;
       line-height: 51px;
       font-weight: 600;
-      color: #202b4b;
       margin: 0;
     }
+    .icon {
+      margin-top: -4px;
+    }
 
     &:first-child {
       .part-box {

+ 1 - 4
src/features/invigilation/ExamReport/ReportOverview.vue

@@ -39,7 +39,6 @@
           <echart-render
             :chart-data="progressData"
             chart-type="pie"
-            v-if="chartDataReady"
           ></echart-render>
         </div>
       </el-col>
@@ -59,7 +58,6 @@
       <echart-render
         :chart-data="distributionData"
         chart-type="lineMark"
-        v-if="chartDataReady"
       ></echart-render>
     </div>
   </div>
@@ -82,7 +80,6 @@ export default {
   },
   data() {
     return {
-      chartDataReady: false,
       examTotal: "0",
       progressData: [
         {
@@ -145,7 +142,6 @@ export default {
           name: `${days[1] * 1}月${days[2] * 1}日`,
         };
       });
-      this.chartDataReady = true;
     },
   },
 };
@@ -240,6 +236,7 @@ export default {
   font-weight: 500;
   color: #202b4b;
   line-height: 22px;
+  z-index: 9;
 }
 .overview-progress {
   height: 346px;

+ 9 - 42
src/features/invigilation/OnlinePatrol/OnlinePatrol.vue

@@ -205,7 +205,7 @@
     </div>
 
     <div class="patrol-analysis part-box">
-      <div class="patrol-analysis-legend">
+      <div class="patrol-analysis-legend" v-if="statData.length">
         <div
           class="legend-item"
           v-for="item in statInfo"
@@ -222,7 +222,6 @@
       <echart-render
         :chart-data="statData"
         chart-type="barGroup"
-        v-if="statDataReady"
       ></echart-render>
     </div>
   </div>
@@ -273,29 +272,7 @@ export default {
       examActivities: [],
       examRooms: [],
       examCourses: [],
-      dataList: [
-        {
-          breachStatus: 0,
-          examActivityCode: "",
-          examActivityId: 0,
-          examId: 0,
-          examName: "",
-          examRecordId: 0,
-          examStudentId: 0,
-          exceptionCount: 0,
-          identity: "",
-          multipleFaceCount: 0,
-          name: "",
-          roomCode: "",
-          roomName: "",
-          seq: 0,
-          status: "",
-          statusCode: "",
-          updateTime: "",
-          warningCount: 0,
-        },
-      ],
-      statDataReady: false,
+      dataList: [],
       statInfo: [
         {
           name: "通讯故障",
@@ -312,7 +289,7 @@ export default {
     };
   },
   mounted() {
-    // this.initData();
+    this.initData();
   },
   methods: {
     async initData() {
@@ -342,13 +319,7 @@ export default {
       this.getPatrolReportList();
     },
     async getExamBatchList() {
-      const user = this.$store.state.user;
-      const userId =
-        user.roleCodes.includes("INVIGILATE") ||
-        user.roleCodes.includes("INSPECTION")
-          ? user.id
-          : null;
-      const res = await examBatchList(userId);
+      const res = await examBatchList();
       this.examBatchs = res.data.data;
     },
     async getExamActivityRoomList() {
@@ -365,7 +336,7 @@ export default {
     },
     toDetail(row) {
       this.$router.push({
-        name: "RealtimeMonitoring",
+        name: "PatrolExamDetail",
         params: { examId: row.examId },
       });
     },
@@ -397,14 +368,10 @@ export default {
       });
       statItem.order = orderInfo[statItem.order];
 
-      this.statDataReady = false;
-      this.$nextTick(() => {
-        this.statData.sort((a, b) => {
-          return statItem.order === "desc"
-            ? b[statItem.key] - a[statItem.key]
-            : a[statItem.key] - b[statItem.key];
-        });
-        this.statDataReady = true;
+      this.statData.sort((a, b) => {
+        return statItem.order === "desc"
+          ? b[statItem.key] - a[statItem.key]
+          : a[statItem.key] - b[statItem.key];
       });
     },
   },

+ 350 - 0
src/features/invigilation/OnlinePatrol/PatrolWarningDetail.vue

@@ -0,0 +1,350 @@
+<template>
+  <div class="patrol-warning-detail warning-detail">
+    <div class="warning-detail-head">
+      <div class="warning-detail-title">
+        <h2>预警详情</h2>
+        <el-button size="mini" icon="el-icon-arrow-left" @click="goBack">
+          返回列表
+        </el-button>
+      </div>
+      <div class="warning-detail-student">
+        <div class="student-head">
+          <div class="student-head-left">
+            <p><i class="icon icon-user-act"></i></p>
+            <p>
+              <span>姓名:</span><span>{{ detailInfo.examStudentName }}</span>
+            </p>
+            <p>
+              <span>证件号:</span><span>{{ detailInfo.identity }}</span>
+            </p>
+            <p>
+              <span>科目(代码):</span
+              ><span>{{ detailInfo.courseNameCode }}</span>
+            </p>
+          </div>
+          <div class="student-head-right">
+            <el-button
+              class="el-icon-btn"
+              size="mini"
+              type="primary"
+              icon="el-icon-arrow-left"
+              title="查看上一个"
+              @click="changeStudent(0)"
+              :disabled="holding"
+            ></el-button>
+            <el-button
+              class="el-icon-btn"
+              size="mini"
+              type="primary"
+              icon="el-icon-arrow-right"
+              title="查看下一个"
+              @click="changeStudent(1)"
+              :disabled="holding"
+            ></el-button>
+          </div>
+        </div>
+        <div class="student-views">
+          <div class="student-avatar">
+            <img
+              :src="detailInfo.examStudentAvatar"
+              alt=""
+              v-if="detailInfo.examStudentAvatar"
+            />
+            <div class="avatar-default" v-else>
+              <i class="el-icon-user-solid"></i>
+            </div>
+          </div>
+          <div class="student-video">
+            <div class="student-video-item">
+              <flv-media
+                ref="FirstViewVideo"
+                :data="firstViewVideo"
+                v-if="firstViewVideoReady"
+              ></flv-media>
+              <div class="student-video-none" v-else>
+                <i class="el-icon-video-camera-solid"></i>
+              </div>
+            </div>
+            <div class="student-video-item">
+              <flv-media
+                ref="ThirdViewVideo"
+                :data="secondViewVideo"
+                v-if="secondViewVideoReady"
+              ></flv-media>
+              <div class="student-video-none" v-else>
+                <i class="el-icon-video-camera-solid"></i>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="student-exception">
+          <ul>
+            <li v-for="(log, index) in exceptionSummary" :key="index">
+              <i>{{ index + 1 }}</i>
+              <h4>{{ log.info }}</h4>
+              <p>
+                时间段:
+                <span v-if="log.startTime">{{ log.startTime }} ~ </span>
+                <span>{{ log.endTime }}</span>
+              </p>
+              <p v-if="log.durationTime">持续时长约:{{ log.durationTime }}</p>
+            </li>
+          </ul>
+        </div>
+      </div>
+    </div>
+
+    <div class="warning-detail-body">
+      <div class="warning-body-head clear-float">
+        <div class="warning-body-head-action">
+          <h3>考试轨迹</h3>
+        </div>
+        <div class="warning-body-head-info summary-line">
+          <p class="summary-line-item">
+            <i class="line-point line-point-danger"></i>
+            <span class="line-name">系统预警</span>
+            <span>{{ detailInfo.warningCount }}次</span>
+          </p>
+          <p class="summary-line-item">
+            <i class="line-point line-point-danger"></i>
+            <span class="line-name">陌生人脸</span>
+            <span>{{ detailInfo.multipleFaceCount }}次</span>
+          </p>
+          <p class="summary-line-item">
+            <i class="line-point line-point-danger"></i>
+            <span class="line-name">异常处理</span>
+            <span>{{ detailInfo.exceptionCount }}次</span>
+          </p>
+          <p class="summary-line-item">
+            <span></span>
+            <span>
+              <b>违纪状态:</b>
+              <b :class="{ 'color-danger': detailInfo.breachStatus }">
+                {{ detailInfo.breachStatus ? "违纪" : "正常" }}
+              </b>
+            </span>
+          </p>
+        </div>
+      </div>
+      <div class="warning-body-main">
+        <div
+          class="warning-history"
+          v-for="log in detailInfo.examStudentLogList"
+          :key="log.id"
+        >
+          <div class="warning-history-info">
+            <h3>{{ log.info }}</h3>
+            <p>
+              时间段:
+              <span v-if="log.startTime">{{ log.startTime }} ~ </span>
+              <span>{{ log.endTime }}</span>
+            </p>
+            <p v-if="log.durationTime">持续时长约:{{ log.durationTime }}</p>
+          </div>
+          <div
+            :class="[
+              'warning-history-type',
+              log.viewType === 'common' ? 'type-common' : 'type-exception',
+            ]"
+          >
+            <i
+              :class="[
+                'icon',
+                {
+                  'icon-current-step': log.viewType === 'common',
+                  'icon-warning-act': log.viewType === 'warning',
+                  'icon-net-break': log.viewType === 'exception',
+                },
+              ]"
+            ></i>
+          </div>
+          <div class="warning-history-media">
+            <ul class="media-list" v-if="log.photos">
+              <li v-for="(photo, pindex) in log.photos" :key="pindex">
+                <img :src="photo" />
+              </li>
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import FlvMedia from "../common/FlvMedia";
+import { invigilateDetail, warningStudentDetail } from "@/api/invigilation";
+import { formatDate, timeNumberToText } from "@/utils/utils";
+import { mapState } from "vuex";
+
+export default {
+  name: "patrol-warning-detail",
+  components: { FlvMedia },
+  data() {
+    return {
+      recordId: this.$route.params.recordId,
+      detailInfo: {},
+      curDetail: {},
+      serialIds: [],
+      exceptionSummary: [],
+      firstViewVideo: {
+        id: "111",
+        src: "",
+        name: "第一视角",
+      },
+      secondViewVideo: {
+        id: "222",
+        src: "",
+        name: "第二视角",
+      },
+      firstViewVideoReady: false,
+      secondViewVideoReady: false,
+      holding: false,
+    };
+  },
+  computed: {
+    ...mapState("invigilation", ["detailIds"]),
+  },
+  watch: {
+    $route: {
+      handler() {
+        this.initData();
+      },
+    },
+  },
+  mounted() {
+    this.initData();
+  },
+  methods: {
+    async initData() {
+      this.recordId = this.$route.params.recordId;
+      await this.getInvigilateDetail().catch(() => {});
+      await this.getStudentVideo().catch(() => {});
+      this.holding = false;
+    },
+    async getStudentVideo() {
+      const res = await warningStudentDetail({ recordId: this.recordId });
+      const records = res.data.data.map((item) => {
+        item.src = `http://live.qmth.com.cn/live/${item.liveUrl.toLowerCase()}.flv`;
+        item.name = item.source;
+        return item;
+      });
+      console.log(records.map((item) => item.src));
+      this.firstViewVideo = records[0] || {};
+      this.secondViewVideo = records[2] || {};
+
+      if (records.length) this.initSubscribeVideo();
+    },
+    async getInvigilateDetail() {
+      const res = await invigilateDetail(this.recordId);
+      this.detailInfo = res.data.data;
+      this.detailInfo.examStudentLogList = this.parseStudentLogs(
+        this.detailInfo.examStudentLogList
+      );
+      this.exceptionSummary = this.detailInfo.examStudentLogList
+        .filter((item) => item.viewType === "warning")
+        .slice(0, 3);
+    },
+    parseStudentLogs(examStudentLogList) {
+      const statusTypes = {
+        common: [
+          "FIRST_START",
+          "RESUME_START",
+          "IN_PROCESS",
+          "PREPARE",
+          "ANSWERING",
+          "BREAK_OFF",
+          "RESUME_PREPARE",
+          "FINISHED",
+          "FIRST_PREPARE",
+        ],
+        warning: [
+          "FACE_COUNT_ERROR",
+          "FACE_COMPARE_ERROR",
+          "EYE_CLOSE_ERROR",
+          "LIVENESS_ACTION_ERROR",
+          "REALNESS",
+        ],
+        exception: [
+          "NET_TIME_OUT",
+          "MACHING_STOP",
+          "NET_TIME_BREAK",
+          "SOFTWARE_STOP",
+          "POWER_CUT",
+        ],
+      };
+      let statusTypeMap = {};
+      Object.keys(statusTypes).map((k) => {
+        statusTypes[k].map((item) => {
+          statusTypeMap[item] = k;
+        });
+      });
+
+      const logs = examStudentLogList.map((item) => {
+        let info = { ...item };
+        info.endTime = formatDate("HH:mm:ss", new Date(info.createTime));
+        info.viewType = statusTypeMap[info.type] || "common";
+        if (info.remark && info.remark.includes('{"')) {
+          info.remark = JSON.parse(info.remark);
+          if (info.remark["MIN_CREATE_TIME"]) {
+            info.startTime = formatDate(
+              "HH:mm:ss",
+              new Date(info.remark["MIN_CREATE_TIME"])
+            );
+            info.durationTime = timeNumberToText(
+              info.createTime - info.remark["MIN_CREATE_TIME"]
+            );
+          }
+          let facePhoto = info.remark["FACE_VERIFY_PHOTO"]
+            ? [info.remark["FACE_VERIFY_PHOTO"]]
+            : "";
+          info.photos = info.remark["PHOTOS"] || facePhoto;
+        }
+        return info;
+      });
+      return logs;
+    },
+    changeStudent(type) {
+      let index = this.detailIds.indexOf(this.recordId);
+      if (type) {
+        if (index >= this.detailIds.length - 1) {
+          this.$message.error("当前没有下一个学生了");
+          return;
+        }
+        index++;
+      } else {
+        if (index <= 0) {
+          this.$message.error("当前没有上一个学生了");
+          return;
+        }
+        index--;
+      }
+
+      if (this.holding) return;
+      this.holding = true;
+
+      this.closeSubscribeVideo();
+      console.log(this.detailIds[index]);
+
+      this.$router.replace({
+        name: "WarningDetail",
+        params: {
+          recordId: this.detailIds[index],
+        },
+      });
+    },
+    // video relative
+    initSubscribeVideo() {
+      if (this.firstViewVideo.id) this.firstViewVideoReady = true;
+      if (this.secondViewVideo.id) this.secondViewVideoReady = true;
+    },
+    closeSubscribeVideo() {
+      this.firstViewVideoReady = false;
+      this.secondViewVideoReady = false;
+    },
+    goBack() {
+      window.history.go(-1);
+    },
+  },
+};
+</script>

+ 23 - 5
src/features/invigilation/ProgressDetail/ProgressDetail.vue

@@ -67,7 +67,11 @@
           :exam-id="filter.examId"
         ></summary-line>
         <div class="part-filter-info-sub">
-          <el-button type="primary" icon="icon icon-upload" @click="toExport"
+          <el-button
+            type="primary"
+            icon="icon icon-upload"
+            @click="toExport"
+            :loading="isDownload"
             >导出查询结果</el-button
           >
         </div>
@@ -120,8 +124,10 @@ import {
   examBatchList,
   examActivityRoomList,
   progressDetailList,
+  downloadProgressResult,
 } from "@/api/invigilation";
 import SummaryLine from "../common/SummaryLine";
+import { downloadBlob } from "@/utils/utils";
 
 export default {
   name: "progress-detail",
@@ -130,8 +136,8 @@ export default {
     return {
       filter: {
         examId: "",
-        roomCode: null,
-        courseCode: null,
+        roomCode: "",
+        courseCode: "",
         name: "",
       },
       current: 1,
@@ -142,6 +148,7 @@ export default {
       examRooms: [],
       examCourses: [],
       dataList: [],
+      isDownload: false,
     };
   },
   mounted() {
@@ -192,8 +199,19 @@ export default {
       this.filter.courseCode = null;
       this.getExamActivityRoomList();
     },
-    toExport() {
-      // TODO:
+    async toExport() {
+      this.isDownload = true;
+      const res = await downloadBlob(() => {
+        return downloadProgressResult(this.filter);
+      }, `进度查询结果-${Date.now()}.xls`).catch(() => {});
+
+      this.isDownload = false;
+
+      if (res) {
+        this.$message.success("导出成功!");
+      } else {
+        this.$message.error("导出失败,请重新尝试!");
+      }
     },
   },
 };

+ 14 - 2
src/features/invigilation/RealtimeMonitoring/ExamBatchDialog.vue

@@ -25,6 +25,9 @@ import { examMonitorBatchList } from "@/api/invigilation";
 
 export default {
   name: "exam-batch-dialog",
+  props: {
+    initExamid: String,
+  },
   data() {
     return {
       dialogVisible: false,
@@ -50,9 +53,18 @@ export default {
         };
       });
 
-      this.examBatchId = this.examBatchs[0] && this.examBatchs[0].id;
+      let selectedExam = null;
+      if (this.initExamid) {
+        selectedExam = this.examBatchs.find(
+          (item) => item.id === this.initExamid
+        );
+      } else {
+        selectedExam = this.examBatchs[0];
+      }
+
+      this.examBatchId = selectedExam && selectedExam.id;
 
-      this.$emit("confirm", this.examBatchs[0]);
+      this.$emit("confirm", selectedExam);
     },
     cancel() {
       this.dialogVisible = false;

+ 20 - 4
src/features/invigilation/RealtimeMonitoring/RealtimeMonitoring.vue

@@ -11,7 +11,7 @@
 
     <div class="part-box-head">
       <div class="part-box-head-left">
-        <h1>实时监控台</h1>
+        <h1>{{ IS_INSPECTION ? "查看考场" : "实时监控台" }}</h1>
       </div>
       <div class="part-box-head-right">
         <div
@@ -49,7 +49,7 @@
           data-type="trouble"
           :exam-id="filter.examId"
         ></summary-line>
-        <div class="part-filter-info-sub">
+        <div class="part-filter-info-sub" v-if="!this.IS_INSPECTION">
           <el-badge
             :value="communicationCount"
             :max="99"
@@ -155,12 +155,14 @@
             type="primary"
             icon="icon icon-handle"
             @click="finishInvigilation"
+            v-if="!this.IS_INSPECTION"
             >手动收卷</el-button
           >
           <el-button
             type="danger"
             icon="icon icon-over"
             @click="finishInvigilationExam"
+            v-if="!this.IS_INSPECTION"
             >结束监考</el-button
           >
         </div>
@@ -251,6 +253,7 @@
 
     <!-- 考试批次选择 -->
     <exam-batch-dialog
+      :initExamid="initExamid"
       @confirm="examChange"
       ref="ExamBatchDialog"
     ></exam-batch-dialog>
@@ -297,6 +300,7 @@ export default {
   },
   data() {
     return {
+      initExamid: this.$route.params.examId,
       filter: {
         examId: "",
         paperDownload: null,
@@ -310,6 +314,7 @@ export default {
       STUDENT_ONLINE_STATUS,
       CLIENT_WEBSOCKET_STATUS,
       MONITOR_STATUS_SOURCE,
+      IS_INSPECTION: false,
       hasNewWarning: false,
       communicationCount: 0,
       curExamBatch: {},
@@ -343,7 +348,13 @@ export default {
       ],
     };
   },
-  mounted() {
+  created() {
+    const user = this.$store.state.user;
+    this.IS_INSPECTION =
+      user.roleCodes.includes("INSPECTION") &&
+      !user.roleCodes.includes("INVIGILATE");
+    if (this.IS_INSPECTION) return;
+
     window.inviligateWarning = (id) => {
       this.toDetail({ examRecordId: id });
     };
@@ -392,6 +403,8 @@ export default {
       this.filter.examId = examBatch.id;
       this.curExamBatch = examBatch;
       this.toSearch();
+      if (this.IS_INSPECTION) return;
+
       this.getMonitorCallCount();
       this.fetchWarningNotice();
     },
@@ -479,8 +492,11 @@ export default {
       }, 10 * 1000);
     },
     toDetail(row) {
+      const routerName = this.IS_INSPECTION
+        ? "PatrolWarningDetail"
+        : "WarningDetail";
       this.$router.push({
-        name: "WarningDetail",
+        name: routerName,
         params: { recordId: row.examRecordId },
       });
     },

+ 15 - 28
src/features/invigilation/RealtimeMonitoring/VideoCommunication.vue

@@ -18,9 +18,16 @@
         <el-col :md="6" :lg="6" :xl="4" v-for="item in students" :key="item.id">
           <div class="student-item">
             <div class="student-cover">
-              <img :src="item.stdAvatar" :alt="item.stdName" />
+              <img
+                :src="item.examStudentAvatar"
+                :alt="item.examStudentId"
+                v-if="item.examStudentAvatar"
+              />
+              <div class="avatar-default" v-else>
+                <i class="el-icon-user-solid"></i>
+              </div>
             </div>
-            <h4 class="student-name">{{ item.monitorKey }}</h4>
+            <h4 class="student-name">{{ item.examStudentName }}</h4>
             <el-button round type="success" @click="answer(item, 0)"
               >语音通话</el-button
             >
@@ -81,12 +88,6 @@ import {
   getUserMonitorKey,
 } from "@/api/invigilation";
 
-const stdAvatars = [
-  "/img/avatars/1.jpg",
-  "/img/avatars/2.jpg",
-  "/img/avatars/3.jpg",
-];
-
 export default {
   name: "video-communication",
   data() {
@@ -94,14 +95,7 @@ export default {
       examId: this.$route.params.examId,
       callStatus: "START",
       dialogVisible: false,
-      students: [
-        // {
-        //   id: 1,
-        //   roomId: "8888",
-        //   stdAvatar: "/img/avatars/1.jpg",
-        //   stdName: "邹朵朵",
-        // },
-      ],
+      students: [],
       current: 1,
       total: 0,
       size: 100,
@@ -114,7 +108,7 @@ export default {
     };
   },
   mounted() {
-    this.getCommunicationList();
+    // this.getCommunicationList();
   },
   methods: {
     async getCommunicationList() {
@@ -126,15 +120,7 @@ export default {
         pageNumber: this.current,
         pageSize: this.size,
       }).catch(() => {});
-      this.students = res.data.data.records.map((item, index) => {
-        // TODO:用户头像临时处理方法
-        const lindex = index % 3;
-        return {
-          ...item,
-          stdAvatar: stdAvatars[lindex],
-          stdName: "",
-        };
-      });
+      this.students = res.data.data.records;
       this.total = res.data.data.total;
       // 当前页没有数据,同时当前页不是第一页,则自动跳到前一页。
       if (!this.students.length && this.current > 1) {
@@ -279,12 +265,13 @@ export default {
     }
   }
   .student-cover {
-    max-height: 200px;
+    height: 200px;
     border-radius: 6px;
     overflow: hidden;
     img {
-      display: block;
       width: 100%;
+      height: 100%;
+      object-fit: contain;
     }
   }
   .student-name {

+ 2 - 331
src/features/invigilation/RealtimeMonitoring/WarningDetail.vue

@@ -267,13 +267,13 @@
 
 <script>
 import { createClient, createStream } from "@/plugins/trtc";
-import { invigilateFinish } from "@/api/invigilation";
-import FlvMedia from "../common/FlvMedia";
 import {
   invigilateDetail,
+  invigilateFinish,
   warningStudentDetail,
   getUserMonitorKey,
 } from "@/api/invigilation";
+import FlvMedia from "../common/FlvMedia";
 import StudentBreachDialog from "./StudentBreachDialog";
 import WarningTextMessageDialog from "./WarningTextMessageDialog";
 import { formatDate, timeNumberToText } from "@/utils/utils";
@@ -620,332 +620,3 @@ export default {
   },
 };
 </script>
-
-<style lang="scss" scoped>
-.warning-detail {
-  &-head {
-    margin: -30px -30px 0;
-    padding: 30px;
-    background: #fff;
-    border: 1px solid #f0f4f9;
-  }
-  &-title {
-    overflow: hidden;
-    padding-bottom: 20px;
-    border-bottom: 1px solid #f0f4f9;
-    > h2 {
-      float: left;
-      font-weight: 600;
-      font-size: 18px;
-      line-height: 28px;
-      margin: 0;
-    }
-    > .el-button {
-      float: right;
-    }
-  }
-}
-.student-head {
-  padding: 20px 0;
-  overflow: hidden;
-  &-left {
-    float: left;
-    > p {
-      display: inline-block;
-      vertical-align: middle;
-      margin: 0;
-      line-height: 28px;
-      height: 28px;
-      margin-right: 15px;
-
-      &:first-child {
-        margin-right: 10px;
-      }
-
-      > span {
-        color: #626a82;
-
-        &:last-child {
-          color: #202b4b;
-          margin-left: 5px;
-          font-weight: 600;
-        }
-      }
-    }
-  }
-  &-right {
-    float: right;
-  }
-}
-.student-views {
-  height: 240px;
-  margin: 0 -15px;
-}
-.student-avatar {
-  padding: 0 15px;
-  width: 210px;
-  height: 100%;
-  float: left;
-  > img {
-    display: block;
-    width: 100%;
-    height: 100%;
-    border-radius: 6px;
-  }
-}
-.student-video {
-  margin-left: 210px;
-  height: 240px;
-  font-size: 0;
-  position: relative;
-
-  &-item {
-    position: relative;
-    display: inline-block;
-    vertical-align: top;
-    font-size: 14px;
-    padding: 0 15px;
-    width: 50%;
-    height: 100%;
-  }
-  video {
-    display: block;
-    width: 100%;
-    height: 100%;
-    border-radius: 6px;
-    box-shadow: 0 0 1px #333;
-    background: #606060;
-  }
-
-  &-main {
-    position: absolute;
-    top: 0;
-    left: 15px;
-    right: 15px;
-    bottom: 0;
-    z-index: 9;
-    border-radius: 6px;
-  }
-
-  &-none {
-    height: 100%;
-    background: #606060;
-    border-radius: 6px;
-    font-size: 50px;
-    text-align: center;
-    padding-top: 90px;
-    color: #202b4b;
-  }
-}
-.student-exception {
-  margin-top: 30px;
-  ul,
-  li {
-    margin: 0;
-    padding: 0;
-  }
-  ul {
-    white-space: nowrap;
-    font-size: 0;
-  }
-  li {
-    display: inline-block;
-    vertical-align: top;
-    font-size: 14px;
-    position: relative;
-    padding: 0 15px 0 38px;
-    width: 33.33%;
-
-    > i {
-      display: block;
-      position: absolute;
-      font-size: 29px;
-      line-height: 1;
-      color: #d9dfe8;
-      top: 3px;
-      left: 5px;
-      z-index: 9;
-      font-style: normal;
-
-      &::after {
-        content: "";
-        display: block;
-        position: absolute;
-        background: #fff;
-        width: 15px;
-        height: 15px;
-        transform: rotate(45deg);
-        bottom: -2px;
-        right: -10px;
-        z-index: 9;
-      }
-    }
-
-    &:not(:last-child):after {
-      content: "";
-      display: block;
-      position: absolute;
-      background-image: url(../../../assets/bg-split-line.png);
-      background-position: 100% 100%;
-      width: 30px;
-      height: 52px;
-      top: 6px;
-      right: 10px;
-      z-index: 9;
-    }
-    h4 {
-      font-size: 16px;
-      font-weight: 600;
-      color: #202b4b;
-      line-height: 22px;
-      margin-bottom: 4px;
-    }
-    p {
-      margin: 0;
-      font-weight: 400;
-      color: #626a82;
-      line-height: 20px;
-      white-space: normal;
-    }
-  }
-}
-.warning-detail-body {
-  margin-top: 30px;
-  border-radius: 6px;
-  background: #fff;
-}
-.warning-body-head {
-  border-bottom: 1px solid #f0f4f9;
-  padding: 20px;
-  &-action {
-    float: left;
-    > h3 {
-      line-height: 32px;
-      display: inline-block;
-      vertical-align: middle;
-      font-size: 18px;
-      font-weight: 600;
-      margin: 0 30px 0 0;
-    }
-
-    .el-button >>> i.icon {
-      margin: 0;
-    }
-  }
-  &-call {
-    margin: 0 10px;
-  }
-  &-info {
-    float: right;
-    padding: 0;
-    > p {
-      height: 32px;
-      line-height: 32px;
-      margin-right: 20px;
-    }
-    .el-button {
-      vertical-align: middle;
-    }
-  }
-}
-.warning-body-main {
-  min-height: 400px;
-  padding: 15px;
-}
-.warning-history {
-  display: flex;
-  min-height: 100px;
-  align-items: center;
-  &-info {
-    padding: 15px 30px 15px 0;
-    text-align: right;
-    width: 210px;
-    flex-shrink: 0;
-    > h3 {
-      font-size: 16px;
-      font-weight: 600;
-      color: #202b4b;
-      line-height: 22px;
-      margin-bottom: 4px;
-    }
-
-    > h5 {
-      font-size: 13px;
-      font-weight: 600;
-      color: #626a82;
-      line-height: 18px;
-      margin-bottom: 4px;
-    }
-
-    > p {
-      font-size: 12px;
-      font-weight: 400;
-      color: #626a82;
-      line-height: 17px;
-      margin: 0;
-    }
-  }
-
-  &-type {
-    position: relative;
-    width: 36px;
-    height: 36px;
-    padding-top: 5px;
-    text-align: center;
-    border-radius: 50%;
-    border: 2px solid #1cd1a1;
-    background-color: #fff;
-    z-index: 9;
-    flex-shrink: 0;
-    &.type-exception {
-      border-color: #fe5863;
-    }
-    > i {
-      width: 18px;
-      height: 17px;
-    }
-    i.icon-current-step {
-      width: 12px;
-    }
-  }
-
-  &-media {
-    padding: 10px 0 10px 20px;
-    position: relative;
-    min-height: 100px;
-    z-index: 8;
-
-    &::before {
-      content: "";
-      display: block;
-      position: absolute;
-      height: 100%;
-      border-left: 1px solid #f0f4f9;
-      left: -18px;
-      top: 0;
-    }
-  }
-  .media-list {
-    padding: 0;
-    margin: 0;
-    list-style: none;
-
-    li {
-      display: inline-block;
-      vertical-align: top;
-      width: 160px;
-      height: 100px;
-      margin: 5px 10px;
-      border-radius: 6px;
-      background-color: #e8edf3;
-      overflow: hidden;
-
-      > img {
-        width: 100%;
-        height: 100%;
-        object-fit: contain;
-      }
-    }
-  }
-}
-</style>

+ 17 - 4
src/features/invigilation/common/EchartRender.vue

@@ -3,8 +3,8 @@
     <vue-charts
       :options="chartOption"
       :init-options="initOptions"
-      v-if="chartOption"
       autoresize
+      v-if="chartOption"
       ref="vueChart"
     ></vue-charts>
     <p class="chart-none" v-else>暂无数据</p>
@@ -69,6 +69,14 @@ export default {
       initOptions: { renderer: "canvas" },
     };
   },
+  watch: {
+    chartData: {
+      deep: true,
+      handler() {
+        this.getOptions();
+      },
+    },
+  },
   mounted() {
     if (this.rendererType) this.initOptions.renderer = this.rendererType;
     this.getOptions();
@@ -82,6 +90,7 @@ export default {
       return this.$refs.vueChart && this.$refs.vueChart.getDataURL(options);
     },
     getLineOption(datas) {
+      if (!datas.dataList.length) return;
       const linearColor = new echarts.graphic.LinearGradient(0, 1, 0, 0, [
         {
           offset: 1,
@@ -343,7 +352,7 @@ export default {
         legend: {
           show: true,
           orient: "vertical",
-          left: "50%",
+          left: "60%",
           top: "middle",
           itemGap: 20,
           itemWidth: 8,
@@ -353,7 +362,7 @@ export default {
               a: {
                 color: color.aColor,
                 fontSize: 14,
-                width: 150,
+                width: 100,
                 padding: [0, 0, 0, 5],
               },
               b: {
@@ -514,6 +523,7 @@ export default {
       };
     },
     getBarGroupOption(dataList) {
+      if (!dataList.length) return;
       const PLACE_STATES = {
         login: {
           name: "已登录",
@@ -685,9 +695,12 @@ export default {
   position: relative;
 }
 .chart-box .chart-none {
-  padding-top: 150px;
+  position: relative;
+  top: 50%;
+  transform: translateY(-50%);
   font-size: 20px;
   text-align: center;
   color: #8c94ac;
+  margin: 0;
 }
 </style>

+ 1 - 1
src/features/invigilation/common/SummaryLine.vue

@@ -11,7 +11,7 @@
       ></i>
       <el-popover
         placement="bottom-start"
-        popper-class="summary-line-popover"
+        popper-class="warning-popover"
         width="300"
         trigger="hover"
         :content="item.content"

+ 22 - 0
src/router/invigilation.js

@@ -29,6 +29,28 @@ const routes = [
         /* webpackChunkName: "inspection" */ "../features/invigilation/OnlinePatrol/OnlinePatrol"
       ),
   },
+  {
+    path: "online-patrol/exam-detail/:examId",
+    name: "PatrolExamDetail",
+    component: () =>
+      import(
+        /* webpackChunkName: "inspection" */ "../features/invigilation/RealtimeMonitoring/RealtimeMonitoring"
+      ),
+    meta: {
+      relate: "OnlinePatrol",
+    },
+  },
+  {
+    path: "online-patrol/warning-detail/:recordId",
+    name: "PatrolWarningDetail",
+    component: () =>
+      import(
+        /* webpackChunkName: "inspection" */ "../features/invigilation/OnlinePatrol/PatrolWarningDetail"
+      ),
+    meta: {
+      relate: "OnlinePatrol",
+    },
+  },
   {
     path: "realtime-monitoring/:examId?",
     name: "RealtimeMonitoring",

+ 1 - 1
src/store/modules/invigilation.js

@@ -40,7 +40,7 @@ const mutations = {
 const actions = {
   async fetchRealtimeMonitoringCount({ commit }, datas) {
     const res = await invigilateCount(datas);
-    commit("setRealtimeMonitoring", res.data.data.count);
+    commit("setRealtimeMonitoring", res.data.data.length);
   },
   async fetchWarningManageCount({ commit }, datas) {
     const res = await invigilationWarningCount(datas);

+ 335 - 2
src/styles/base.scss

@@ -288,7 +288,7 @@ body {
     }
   }
 }
-.el-popover.summary-line-popover {
+.el-popover.warning-popover {
   background: #ff9f43;
   box-shadow: 0px 0px 6px 0px rgba(255, 158, 59, 0.5);
   border-radius: 6px;
@@ -296,9 +296,12 @@ body {
   color: #fff;
   font-size: 12px;
   line-height: 17px;
-  .popper__arrow::after {
+  &[x-placement^="bottom"] .popper__arrow::after {
     border-bottom-color: #ff9f43 !important;
   }
+  &[x-placement^="top"] .popper__arrow::after {
+    border-top-color: #ff9f43 !important;
+  }
 }
 
 // realtim-monitoring msg-monitor
@@ -343,6 +346,336 @@ body {
   }
 }
 
+// warning-detail
+.warning-detail {
+  &-head {
+    margin: -30px -30px 0;
+    padding: 30px;
+    background: #fff;
+    border: 1px solid #f0f4f9;
+  }
+  &-title {
+    overflow: hidden;
+    padding-bottom: 20px;
+    border-bottom: 1px solid #f0f4f9;
+    > h2 {
+      float: left;
+      font-weight: 600;
+      font-size: 18px;
+      line-height: 28px;
+      margin: 0;
+    }
+    > .el-button {
+      float: right;
+    }
+  }
+}
+.warning-detail-student {
+  .student-head {
+    padding: 20px 0;
+    overflow: hidden;
+    &-left {
+      float: left;
+      > p {
+        display: inline-block;
+        vertical-align: middle;
+        margin: 0;
+        line-height: 28px;
+        height: 28px;
+        margin-right: 15px;
+
+        &:first-child {
+          margin-right: 10px;
+        }
+
+        > span {
+          color: #626a82;
+
+          &:last-child {
+            color: #202b4b;
+            margin-left: 5px;
+            font-weight: 600;
+          }
+        }
+      }
+    }
+    &-right {
+      float: right;
+    }
+  }
+  .student-views {
+    height: 240px;
+    margin: 0 -15px;
+  }
+  .student-avatar {
+    padding: 0 15px;
+    width: 210px;
+    height: 100%;
+    float: left;
+    > img {
+      display: block;
+      width: 100%;
+      height: 100%;
+      border-radius: 6px;
+    }
+  }
+  .student-video {
+    margin-left: 210px;
+    height: 240px;
+    font-size: 0;
+    position: relative;
+
+    &-item {
+      position: relative;
+      display: inline-block;
+      vertical-align: top;
+      font-size: 14px;
+      padding: 0 15px;
+      width: 50%;
+      height: 100%;
+    }
+    video {
+      display: block;
+      width: 100%;
+      height: 100%;
+      border-radius: 6px;
+      box-shadow: 0 0 1px #333;
+      background: #606060;
+    }
+
+    &-main {
+      position: absolute;
+      top: 0;
+      left: 15px;
+      right: 15px;
+      bottom: 0;
+      z-index: 9;
+      border-radius: 6px;
+    }
+
+    &-none {
+      height: 100%;
+      background: #606060;
+      border-radius: 6px;
+      font-size: 50px;
+      text-align: center;
+      padding-top: 90px;
+      color: #202b4b;
+    }
+  }
+  .student-exception {
+    margin-top: 30px;
+    ul,
+    li {
+      margin: 0;
+      padding: 0;
+    }
+    ul {
+      white-space: nowrap;
+      font-size: 0;
+    }
+    li {
+      display: inline-block;
+      vertical-align: top;
+      font-size: 14px;
+      position: relative;
+      padding: 0 15px 0 38px;
+      width: 33.33%;
+
+      > i {
+        display: block;
+        position: absolute;
+        font-size: 29px;
+        line-height: 1;
+        color: #d9dfe8;
+        top: 3px;
+        left: 5px;
+        z-index: 9;
+        font-style: normal;
+
+        &::after {
+          content: "";
+          display: block;
+          position: absolute;
+          background: #fff;
+          width: 15px;
+          height: 15px;
+          transform: rotate(45deg);
+          bottom: -2px;
+          right: -10px;
+          z-index: 9;
+        }
+      }
+
+      &:not(:last-child):after {
+        content: "";
+        display: block;
+        position: absolute;
+        background-image: url(../assets/bg-split-line.png);
+        background-position: 100% 100%;
+        width: 30px;
+        height: 52px;
+        top: 6px;
+        right: 10px;
+        z-index: 9;
+      }
+      h4 {
+        font-size: 16px;
+        font-weight: 600;
+        color: #202b4b;
+        line-height: 22px;
+        margin-bottom: 4px;
+      }
+      p {
+        margin: 0;
+        font-weight: 400;
+        color: #626a82;
+        line-height: 20px;
+        white-space: normal;
+      }
+    }
+  }
+}
+.warning-detail-body {
+  margin-top: 30px;
+  border-radius: 6px;
+  background: #fff;
+}
+.warning-body-head {
+  border-bottom: 1px solid #f0f4f9;
+  padding: 20px;
+  &-action {
+    float: left;
+    > h3 {
+      line-height: 32px;
+      display: inline-block;
+      vertical-align: middle;
+      font-size: 18px;
+      font-weight: 600;
+      margin: 0 30px 0 0;
+    }
+
+    .el-button >>> i.icon {
+      margin: 0;
+    }
+  }
+  &-call {
+    margin: 0 10px;
+  }
+  &-info {
+    float: right;
+    padding: 0;
+    > p {
+      height: 32px;
+      line-height: 32px;
+      margin-right: 20px;
+    }
+    .el-button {
+      vertical-align: middle;
+    }
+  }
+}
+.warning-body-main {
+  min-height: 400px;
+  padding: 15px;
+}
+.warning-history {
+  display: flex;
+  min-height: 100px;
+  align-items: center;
+  &-info {
+    padding: 15px 30px 15px 0;
+    text-align: right;
+    width: 210px;
+    flex-shrink: 0;
+    > h3 {
+      font-size: 16px;
+      font-weight: 600;
+      color: #202b4b;
+      line-height: 22px;
+      margin-bottom: 4px;
+    }
+
+    > h5 {
+      font-size: 13px;
+      font-weight: 600;
+      color: #626a82;
+      line-height: 18px;
+      margin-bottom: 4px;
+    }
+
+    > p {
+      font-size: 12px;
+      font-weight: 400;
+      color: #626a82;
+      line-height: 17px;
+      margin: 0;
+    }
+  }
+
+  &-type {
+    position: relative;
+    width: 36px;
+    height: 36px;
+    padding-top: 5px;
+    text-align: center;
+    border-radius: 50%;
+    border: 2px solid #1cd1a1;
+    background-color: #fff;
+    z-index: 9;
+    flex-shrink: 0;
+    &.type-exception {
+      border-color: #fe5863;
+    }
+    > i {
+      width: 18px;
+      height: 17px;
+    }
+    i.icon-current-step {
+      width: 12px;
+    }
+  }
+
+  &-media {
+    padding: 10px 0 10px 20px;
+    position: relative;
+    min-height: 100px;
+    z-index: 8;
+
+    &::before {
+      content: "";
+      display: block;
+      position: absolute;
+      height: 100%;
+      border-left: 1px solid #f0f4f9;
+      left: -18px;
+      top: 0;
+    }
+  }
+  .media-list {
+    padding: 0;
+    margin: 0;
+    list-style: none;
+
+    li {
+      display: inline-block;
+      vertical-align: top;
+      width: 160px;
+      height: 100px;
+      margin: 5px 10px;
+      border-radius: 6px;
+      background-color: #e8edf3;
+      overflow: hidden;
+
+      > img {
+        width: 100%;
+        height: 100%;
+        object-fit: contain;
+      }
+    }
+  }
+}
+
 // communication-dialog
 .communication-dialog {
   background-color: transparent;

+ 6 - 0
src/styles/icons.scss

@@ -130,4 +130,10 @@
   &-current-step {
     background-image: url(../assets/icon-current-step.png);
   }
+  &-arrows-up {
+    background-image: url(../assets/icon-arrows-up.png);
+  }
+  &-arrows-down {
+    background-image: url(../assets/icon-arrows-down.png);
+  }
 }

+ 34 - 0
src/utils/utils.js

@@ -71,6 +71,40 @@ export async function downloadFileURL(url, name) {
   document.body.removeChild(link);
 }
 
+/**
+ * 将目标对象中有的属性值与源对象中的属性值合并
+ * @param {Object} target 目标对象
+ * @param {Object} sources 源对象
+ */
+export function objAssign(target, sources) {
+  let targ = { ...target };
+  for (let k in targ) {
+    targ[k] = Object.prototype.hasOwnProperty.call(sources, k)
+      ? sources[k]
+      : targ[k];
+  }
+  return targ;
+}
+
+/**
+ * 文件流下载
+ * @param {Object} option 文件下载设置
+ */
+export async function downloadBlob(fetchFunc, fileName) {
+  const res = await fetchFunc().catch(() => {});
+  if (!res) return;
+
+  const blobUrl = URL.createObjectURL(new Blob([res.data]));
+  let a = document.createElement("a");
+  a.download = fileName;
+  a.href = blobUrl;
+  document.body.appendChild(a);
+  a.click();
+  a.parentNode.removeChild(a);
+
+  return true;
+}
+
 export function objTypeOf(obj) {
   const toString = Object.prototype.toString;
   const map = {

+ 4 - 4
src/views/Layout/Layout.vue

@@ -17,7 +17,7 @@ import NavBar from "./components/NavBar.vue";
 import { sysMenu } from "@/api/system-user";
 import localMenu from "./components/menu";
 import { deepCopy } from "@/utils/utils";
-import { mapMutations } from "vuex";
+import { mapActions } from "vuex";
 
 export default {
   name: "Layout",
@@ -38,7 +38,7 @@ export default {
     this.getMenu();
   },
   methods: {
-    ...mapMutations("inviligation", [
+    ...mapActions("invigilation", [
       "fetchRealtimeMonitoringCount",
       "fetchWarningManageCount",
       "fetchReexamPendingCount",
@@ -47,7 +47,7 @@ export default {
       const nav = this.navs.find((item) => item.name === name);
       this.curMenus = nav.children || [];
 
-      // if (name === "Invigilation") this.initNavTips();
+      if (name === "Invigilation") this.initNavTips();
     },
     async getMenu() {
       const res = await sysMenu();
@@ -131,7 +131,7 @@ export default {
         },
       };
       const validNavNames = Object.keys(validNav);
-      this.menus.forEach((nav) => {
+      this.curMenus.forEach((nav) => {
         nav.children.forEach((subnav) => {
           if (validNavNames.includes(subnav.name)) {
             validNav[subnav.name].valid = true;