Browse Source

feat:视频回放

zhangjie 2 years ago
parent
commit
fd65ddf2ff

+ 16 - 0
src/api/examwork-student.js

@@ -99,3 +99,19 @@ export function studentExamDetailInfo(examRecordId) {
     "/api/admin/examStudent/exam_record/paper_view?examRecordId=" + examRecordId
   );
 }
+
+export function searchStudentTrackRecord({
+  examRecordId,
+  monitorRecord,
+  log,
+  pageNumber = 1,
+  pageSize = 10,
+}) {
+  const data = pickBy(
+    { log, monitorRecord, examRecordId, pageNumber, pageSize },
+    (v) => v !== ""
+  );
+  return httpApp.post(
+    "/api/admin/student/exam_record/video/query?" + object2QueryString(data)
+  );
+}

BIN
src/assets/icon-theme.png


+ 1 - 1
src/features/examwork/ActivityManagement/ActivityAudioDialog.vue

@@ -130,7 +130,7 @@ export default {
       uploadData: {
         type: "upload",
       },
-      format: ["wmv"],
+      format: ["wmv", "mp3"],
     };
   },
   methods: {

+ 10 - 5
src/features/examwork/StudentManagement/StudentManagementDialog.vue

@@ -124,12 +124,17 @@ export default {
     // monitor record
     openMonitorRecord(row) {
       console.log(row);
-      window.sessionStorage.setItem("record", JSON.stringify(row));
-      window.open(
-        this.$router.resolve({
-          name: "StudentMonitorRecord",
-        }).href
+      window.sessionStorage.setItem(
+        "studentTrackMonitorRecord",
+        row.monitorRecord
       );
+
+      this.$router.push({
+        name: "StudentTrackRecord",
+        params: {
+          examRecordId: row.examRecordId,
+        },
+      });
     },
   },
 };

+ 324 - 0
src/features/examwork/StudentManagement/StudentTrackRecord.vue

@@ -0,0 +1,324 @@
+<template>
+  <div class="student-track-record">
+    <div class="student-track-head box-justify">
+      <h2 class="student-track-title">轨迹回放</h2>
+      <el-button size="mini" icon="el-icon-arrow-left" @click="goBack"
+        >返回列表</el-button
+      >
+    </div>
+
+    <div class="student-track-info box-justify">
+      <div class="student-track-info-left">
+        <i class="icon icon-theme"></i>{{ info.examName }}
+      </div>
+      <div class="student-track-info-list">
+        <span class="student-track-info-item">
+          <span>姓名:</span>
+          <span class="color-primary">{{ info.name }}</span>
+        </span>
+        <span class="student-track-info-item">
+          <span>证件号:</span>
+          <span class="color-primary">{{ info.identity }}</span>
+        </span>
+        <span class="student-track-info-item">
+          <span>科目(代码):</span>
+          <span class="color-primary"
+            >{{ info.courseName }}({{ info.courseCode }})</span
+          >
+        </span>
+        <span class="student-track-info-item">
+          <span>考试时间:</span>
+          <span class="color-primary">{{
+            info.firstStartTime | datetimeFilter
+          }}</span>
+        </span>
+      </div>
+    </div>
+
+    <div class="student-track-body">
+      <el-row :gutter="40" type="flex">
+        <el-col :span="12">
+          <div class="warning-track">
+            <h3 class="warning-track-title">
+              <i class="icon icon-track"></i>考试轨迹
+            </h3>
+            <div
+              class="warning-track-item"
+              v-for="log in examStudentLogList"
+              :key="log.id"
+            >
+              <div
+                :class="[
+                  'warning-track-type',
+                  log.viewType === 'common' ? 'type-common' : 'type-exception',
+                ]"
+              >
+                <i :class="['icon', `icon-track-${log.viewType}`]"></i>
+              </div>
+              <div class="warning-track-body">
+                <div class="warning-track-info">
+                  <h3>{{ log.title }}</h3>
+                  <p v-if="log.desc">{{ log.desc }}</p>
+                  <p>
+                    时间段:
+                    <span v-if="log.startTime">{{ log.startTime }} ~ </span>
+                    <span>{{ log.endTime }}</span>
+                  </p>
+                  <p v-if="log.durationTime">
+                    持续时长约:{{ log.durationTime }}
+                  </p>
+                </div>
+                <ul class="warning-track-media" v-if="log.photos">
+                  <li v-for="(photo, pindex) in log.photos" :key="pindex">
+                    <img :src="photo" @click="toViewImg(photo)" />
+                  </li>
+                </ul>
+              </div>
+            </div>
+          </div>
+        </el-col>
+        <el-col :span="12">
+          <el-form inline>
+            <el-form-item>
+              <el-select v-model="filter.monitorRecord" placeholder="视频源">
+                <el-option
+                  v-for="item in monitorRecordList"
+                  :key="item.code"
+                  :value="item.code"
+                  :label="item.name"
+                >
+                </el-option>
+              </el-select>
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" @click="toPage(1)">查询</el-button>
+            </el-form-item>
+          </el-form>
+          <el-table :data="videoRocordList" border>
+            <el-table-column prop="startTime" label="录制起点">
+              <span slot-scope="scope"
+                >{{ scope.row.startTime | datetimeFilter }}
+              </span>
+            </el-table-column>
+            <el-table-column prop="endTime" label="录制终点">
+              <span slot-scope="scope"
+                >{{ scope.row.endTime | datetimeFilter }}
+              </span>
+            </el-table-column>
+            <el-table-column label="操作" width="100">
+              <template slot-scope="scope">
+                <el-button
+                  class="btn-table-icon"
+                  type="text"
+                  @click="toDetail(scope.row)"
+                  >视频回放</el-button
+                >
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-col>
+      </el-row>
+    </div>
+
+    <!-- image-preview -->
+    <simple-image-preview
+      :cur-image="curImage"
+      ref="SimpleImagePreview"
+    ></simple-image-preview>
+  </div>
+</template>
+
+<script>
+import { VIDEO_SOURCE_TYPE } from "@/constant/constants";
+import { searchStudentTrackRecord } from "@/api/examwork-student";
+import SimpleImagePreview from "@/components/imagePreview/SimpleImagePreview";
+import {
+  formatDate,
+  timeNumberToText,
+  objTypeOf,
+  objAssign,
+} from "@/utils/utils";
+
+export default {
+  name: "StudentTrackRecord",
+  components: { SimpleImagePreview },
+  data() {
+    return {
+      filter: {
+        examRecordId: this.$route.params.examRecordId,
+        monitorRecord: "",
+        log: true,
+      },
+      info: {
+        examId: "",
+        examName: "",
+        examStudentId: "",
+        examRecordId: "",
+        courseCode: "",
+        courseName: "",
+        identity: "",
+        name: "",
+        firstStartTime: null,
+      },
+      VIDEO_SOURCE_TYPE,
+      monitorRecordList: [],
+      videoRocordList: [],
+      current: 1,
+      total: 0,
+      size: 24,
+      examStudentLogList: [],
+      curImage: { imgSrc: "" },
+    };
+  },
+  mounted() {
+    const studentTrackMonitorRecord = window.sessionStorage.getItem(
+      "studentTrackMonitorRecord"
+    );
+    if (!studentTrackMonitorRecord) {
+      this.$message.error("数据丢失,请退出本页");
+      return;
+    }
+
+    this.monitorRecordList = studentTrackMonitorRecord
+      .split(",")
+      .map((item) => {
+        return {
+          name: VIDEO_SOURCE_TYPE[item],
+          code: item,
+        };
+      });
+    this.filter.monitorRecord = this.monitorRecordList[0].code;
+    this.initData();
+  },
+  methods: {
+    async initData() {
+      await this.getList();
+      this.filter.log = false;
+    },
+    async getList() {
+      const datas = {
+        ...this.filter,
+        pageNumber: this.current,
+        pageSize: this.size,
+      };
+
+      const res = await searchStudentTrackRecord(datas);
+      this.info = objAssign(this.info, res.data.data);
+
+      const {
+        records,
+        total,
+      } = res.data.data.teStudentExamRecordVideoMessageIPage;
+      this.videoRocordList = records;
+      this.total = total;
+
+      if (datas.log) {
+        this.examStudentLogList = this.parseStudentLogs(
+          res.data.data.teExamStudentLogList
+        );
+      }
+    },
+    toPage(page) {
+      this.current = page;
+      this.getList();
+    },
+    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",
+          "NONE",
+          // exception:
+          "NET_TIME_OUT",
+          "MACHING_STOP",
+          "NET_TIME_BREAK",
+          "SOFTWARE_STOP",
+          "POWER_CUT",
+          "BREACH_HANDLE",
+          "BREACH_REVOKE",
+        ],
+      };
+      const transformInfo = {
+        FIRST_START: "身份识别",
+        ANSWERING: "进入考试",
+        RESUME_START: "身份识别",
+      };
+      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";
+        const content = info.info.split(/【|】/);
+        if (content.length === 3) {
+          info.title = content[1];
+          info.desc = content[2];
+        } else {
+          info.title = transformInfo[info.type] || content[0];
+        }
+        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 photos = [];
+          Object.keys(info.remark).map((key) => {
+            if (key.includes("PHOTO")) {
+              const kPhotos =
+                objTypeOf(info.remark[key]) === "array"
+                  ? info.remark[key]
+                  : [info.remark[key]];
+              photos = [...photos, ...kPhotos];
+            }
+          });
+          info.photos = photos;
+        } else if (info.updateTime) {
+          info.startTime = formatDate("HH:mm:ss", new Date(info.createTime));
+          info.endTime = formatDate("HH:mm:ss", new Date(info.updateTime));
+          info.durationTime = timeNumberToText(
+            info.updateTime - info.createTime
+          );
+        }
+        return info;
+      });
+      return logs;
+    },
+    toViewImg(photo) {
+      this.curImage = { imgSrc: photo };
+      this.$refs.SimpleImagePreview.open();
+    },
+    toDetail(row) {
+      console.log(row);
+    },
+    goBack() {
+      window.history.go(-1);
+    },
+  },
+};
+</script>

+ 8 - 0
src/router/index.js

@@ -163,6 +163,14 @@ const routes = [
             /* webpackChunkName: "exam" */ "../features/examwork/StudentManagement/StudentManagement.vue"
           ),
       },
+      {
+        path: "student-track-record/:examRecordId",
+        name: "StudentTrackRecord",
+        component: () =>
+          import(
+            /* webpackChunkName: "exam" */ "../features/examwork/StudentManagement/StudentTrackRecord.vue"
+          ),
+      },
       {
         path: "invigilate",
         name: "InvigilateManagement",

+ 171 - 86
src/styles/base.scss

@@ -36,6 +36,11 @@ body {
   -moz-osx-font-smoothing: grayscale;
   font-size: 14px;
 }
+.box-justify {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
 
 // fomate
 .color-primary {
@@ -629,109 +634,109 @@ body {
       }
     }
   }
-  .warning-track {
-    margin-top: 30px;
-    padding-top: 20px;
-    border-top: 1px solid #f0f4f9;
+}
+.warning-track {
+  margin-top: 30px;
+  padding-top: 20px;
+  border-top: 1px solid #f0f4f9;
 
-    &-title {
-      font-size: 16px;
-      margin-bottom: 30px;
-      font-weight: 600;
+  &-title {
+    font-size: 16px;
+    margin-bottom: 30px;
+    font-weight: 600;
 
-      .icon {
-        margin-top: -2px;
-        margin-right: 10px;
-      }
+    .icon {
+      margin-top: -2px;
+      margin-right: 10px;
     }
-    &-item {
-      min-height: 100px;
-      display: flex;
-      align-items: stretch;
-      justify-content: space-between;
+  }
+  &-item {
+    min-height: 100px;
+    display: flex;
+    align-items: stretch;
+    justify-content: space-between;
 
-      &:last-child {
-        .warning-track-type {
-          &::before {
-            display: none;
-          }
+    &:last-child {
+      .warning-track-type {
+        &::before {
+          display: none;
         }
       }
     }
-    &-body {
-      flex-grow: 2;
-      padding-bottom: 30px;
+  }
+  &-body {
+    flex-grow: 2;
+    padding-bottom: 30px;
+  }
+  &-info {
+    > h3 {
+      font-size: 16px;
+      font-weight: 600;
+      color: #202b4b;
+      line-height: 1;
+      margin-bottom: 6px;
     }
-    &-info {
-      > h3 {
-        font-size: 16px;
-        font-weight: 600;
-        color: #202b4b;
-        line-height: 1;
-        margin-bottom: 6px;
-      }
 
-      > h5 {
-        font-size: 13px;
-        font-weight: 600;
-        color: #626a82;
-        line-height: 18px;
-        margin-bottom: 4px;
-      }
+    > h5 {
+      font-size: 13px;
+      font-weight: 600;
+      color: #626a82;
+      line-height: 18px;
+      margin-bottom: 4px;
+    }
 
-      > p {
-        font-size: 13px;
-        font-weight: 400;
-        color: #626a82;
-        line-height: 16px;
-        margin: 0;
-      }
+    > p {
+      font-size: 13px;
+      font-weight: 400;
+      color: #626a82;
+      line-height: 16px;
+      margin: 0;
     }
+  }
 
-    &-type {
-      position: relative;
-      text-align: center;
-      flex-grow: 0;
-      flex-shrink: 0;
-      padding: 0 20px;
+  &-type {
+    position: relative;
+    text-align: center;
+    flex-grow: 0;
+    flex-shrink: 0;
+    padding: 0 20px;
 
-      .icon {
-        position: relative;
-        z-index: 9;
-      }
+    .icon {
+      position: relative;
+      z-index: 9;
+    }
 
-      &::before {
-        content: "";
-        display: block;
-        position: absolute;
-        height: 100%;
-        border-left: 1px dashed #abb8c9;
-        left: 50%;
-        top: 0;
-        z-index: 8;
-      }
+    &::before {
+      content: "";
+      display: block;
+      position: absolute;
+      height: 100%;
+      border-left: 1px dashed #abb8c9;
+      left: 50%;
+      top: 0;
+      z-index: 8;
     }
-    &-media {
-      padding: 0;
-      margin: 10px 0 0 0;
-      list-style: none;
+  }
+  &-media {
+    padding: 0;
+    margin: 10px 0 0 0;
+    list-style: none;
 
-      li {
-        display: inline-block;
-        vertical-align: top;
-        width: 180px;
-        height: 100px;
-        margin: 0 10px 5px 0;
-        border-radius: 6px;
-        background-color: #e8edf3;
-        overflow: hidden;
+    li {
+      display: inline-block;
+      vertical-align: top;
+      width: 180px;
+      height: 100px;
+      margin: 0 10px 5px 0;
+      border-radius: 6px;
+      background-color: #e8edf3;
+      overflow: hidden;
 
-        > img {
-          width: 100%;
-          height: 100%;
-          object-fit: contain;
-          cursor: pointer;
-        }
+      > img {
+        width: 100%;
+        height: 100%;
+        object-fit: contain;
+        cursor: pointer;
       }
     }
   }
@@ -1848,3 +1853,83 @@ body {
     display: none;
   }
 }
+
+.student-track {
+  &-record {
+    position: absolute;
+    top: 80px;
+    left: 20px;
+    bottom: 54px;
+    right: 20px;
+    z-index: auto;
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+  }
+  &-head {
+    flex-grow: 0;
+    flex-shrink: 0;
+    margin-bottom: 10px;
+  }
+  &-title {
+    font-size: 18px;
+    font-weight: bold;
+    line-height: 1;
+    color: #202b4b;
+  }
+
+  &-info {
+    flex-grow: 0;
+    flex-shrink: 0;
+    padding: 20px;
+    margin-bottom: 20px;
+    background-color: #fff;
+    border-radius: 6px;
+
+    &-left {
+      font-size: 16px;
+      font-weight: bold;
+      line-height: 1;
+      color: #202b4b;
+
+      .icon {
+        margin-right: 10px;
+        margin-top: -4px;
+      }
+    }
+
+    &-list {
+      font-size: 12px;
+      color: #626a82;
+      font-weight: bold;
+    }
+    &-item:not(:first-child) {
+      margin-left: 20px;
+    }
+  }
+
+  &-body {
+    padding: 20px;
+    background-color: #fff;
+    border-radius: 6px;
+    flex-grow: 2;
+
+    .el-row {
+      height: 100%;
+    }
+    .el-col {
+      height: 100%;
+      overflow: auto;
+
+      &:first-child {
+        border-right: 1px solid #f0f4f9;
+      }
+    }
+
+    .warning-track {
+      margin: 0;
+      border: none;
+      padding-top: 0;
+    }
+  }
+}

+ 5 - 0
src/styles/icons.scss

@@ -130,6 +130,11 @@
     height: 14px;
     background-image: url(../assets/icon-track.png);
   }
+  &-theme {
+    width: 16px;
+    height: 15px;
+    background-image: url(../assets/icon-theme.png);
+  }
   &-track-common {
     width: 36px;
     height: 36px;