RealtimeMonitoring.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742
  1. <template>
  2. <div class="realtime-monitoring">
  3. <div class="realtime-top clear-float">
  4. <p :title="curExamBatch.roomName">
  5. 考场名称:{{ curExamBatch.roomName }}
  6. </p>
  7. <p class="realtime-top-select" @click="$refs.ExamBatchDialog.open()">
  8. <span>{{ curExamBatch.label || "请选择批次" }}</span>
  9. <i class="el-icon-caret-bottom"></i>
  10. </p>
  11. <text-clock></text-clock>
  12. </div>
  13. <div class="part-box-head">
  14. <div class="part-box-head-left">
  15. <h1>实时监控台</h1>
  16. </div>
  17. <div class="part-box-head-right">
  18. <div
  19. :class="[
  20. 'realtime-switch',
  21. { 'realtime-switch-warning': hasNewWarning },
  22. ]"
  23. >
  24. <div
  25. :class="[
  26. 'realtime-switch-item',
  27. { 'realtime-switch-item-act': pageType === '0' },
  28. ]"
  29. @click="pageTypeChange('0')"
  30. >
  31. <i class="el-icon-s-fold"></i><span>列表</span>
  32. </div>
  33. <div
  34. :class="[
  35. 'realtime-switch-item',
  36. { 'realtime-switch-item-act': pageType === '1' },
  37. ]"
  38. @click="pageTypeChange('1')"
  39. >
  40. <i class="el-icon-video-camera"></i><span>视频</span>
  41. </div>
  42. </div>
  43. </div>
  44. </div>
  45. <div class="part-filter">
  46. <div class="part-filter-info">
  47. <summary-line
  48. class="part-filter-info-main"
  49. data-type="trouble"
  50. :exam-id="filter.examId"
  51. ref="SummaryLine"
  52. ></summary-line>
  53. <div class="part-filter-info-sub">
  54. <el-badge
  55. :value="communicationCount"
  56. :max="99"
  57. :hidden="!communicationCount"
  58. >
  59. <el-button
  60. icon="icon icon-ring"
  61. type="success"
  62. @click="toCommunication"
  63. >通话待办</el-button
  64. >
  65. </el-badge>
  66. </div>
  67. </div>
  68. <div class="part-filter-form">
  69. <el-form ref="FilterForm" label-position="left" inline>
  70. <el-form-item>
  71. <el-select
  72. v-model="filter.paperDownload"
  73. placeholder="试题下载"
  74. clearable
  75. >
  76. <el-option
  77. v-for="(val, key) in BOOLEAN_INVERSE_TYPE"
  78. :key="key"
  79. :value="key * 1"
  80. :label="val"
  81. ></el-option>
  82. </el-select>
  83. </el-form-item>
  84. <el-form-item>
  85. <el-select v-model="filter.status" placeholder="考试状态" clearable>
  86. <el-option
  87. v-for="(val, key) in STUDENT_ONLINE_STATUS"
  88. :key="key"
  89. :value="key"
  90. :label="val"
  91. ></el-option>
  92. </el-select>
  93. </el-form-item>
  94. <el-form-item>
  95. <el-select
  96. v-model="filter.monitorStatusSource"
  97. placeholder="通讯故障"
  98. clearable
  99. >
  100. <el-option
  101. v-for="(val, key) in BOOLEAN_TYPE"
  102. :key="key"
  103. :value="key * 1"
  104. :label="val"
  105. ></el-option>
  106. </el-select>
  107. </el-form-item>
  108. <el-form-item label="预警量">
  109. <el-input-number
  110. style="width: 52px;"
  111. v-model.trim="filter.minWarningCount"
  112. placeholder="下限"
  113. :controls="false"
  114. ></el-input-number>
  115. <span class="line-split">-</span>
  116. <el-input-number
  117. style="width: 52px;"
  118. v-model.trim="filter.maxWarningCount"
  119. placeholder="上限"
  120. :controls="false"
  121. ></el-input-number>
  122. </el-form-item>
  123. <el-form-item>
  124. <el-input
  125. v-model.trim="filter.name"
  126. placeholder="姓名"
  127. clearable
  128. ></el-input>
  129. </el-form-item>
  130. <el-form-item>
  131. <el-input
  132. v-model.trim="filter.identity"
  133. placeholder="证件号"
  134. clearable
  135. ></el-input>
  136. </el-form-item>
  137. <el-form-item>
  138. <el-button type="primary" @click="toSearch">查询</el-button>
  139. </el-form-item>
  140. </el-form>
  141. <div class="part-filter-form-action">
  142. <el-dropdown
  143. @command="viewingAngleChange"
  144. style="margin-right: 10px;"
  145. trigger="click"
  146. v-if="pageType === '1'"
  147. >
  148. <el-button type="primary"
  149. >切换视频源<i class="el-icon-arrow-down el-icon--right"></i
  150. ></el-button>
  151. <el-dropdown-menu slot="dropdown">
  152. <el-dropdown-item
  153. v-for="item in viewingAngles"
  154. :key="item.code"
  155. :command="item"
  156. >
  157. <span
  158. :class="{
  159. 'color-primary': item.code === curViewingAngle.code,
  160. }"
  161. >
  162. {{ item.name }}
  163. </span>
  164. </el-dropdown-item>
  165. </el-dropdown-menu>
  166. </el-dropdown>
  167. <!-- <el-button
  168. type="primary"
  169. icon="icon icon-handle"
  170. @click="finishInvigilation"
  171. >手动收卷</el-button
  172. > -->
  173. <el-button
  174. type="danger"
  175. icon="icon icon-over"
  176. @click="finishInvigilationExam"
  177. >结束监考</el-button
  178. >
  179. </div>
  180. </div>
  181. </div>
  182. <el-table
  183. ref="TableList"
  184. :data="dataList"
  185. @selection-change="handleSelectionChange"
  186. v-if="pageType === '0'"
  187. >
  188. <el-table-column type="selection" width="55" align="center">
  189. </el-table-column>
  190. <el-table-column prop="identity" label="证件号"></el-table-column>
  191. <el-table-column prop="name" label="姓名"></el-table-column>
  192. <el-table-column prop="courseName" label="科目名称"></el-table-column>
  193. <el-table-column prop="courseCode" label="科目代码"></el-table-column>
  194. <el-table-column prop="remainTime" label="剩余时间"></el-table-column>
  195. <el-table-column prop="paperDownload" label="试题下载">
  196. <span slot-scope="scope">
  197. {{ BOOLEAN_INVERSE_TYPE[scope.row.paperDownload] }}
  198. </span>
  199. </el-table-column>
  200. <el-table-column prop="status" label="考试状态"></el-table-column>
  201. <el-table-column prop="progress" label="进度">
  202. <span slot-scope="scope">{{ scope.row.progress }}%</span>
  203. </el-table-column>
  204. <el-table-column prop="clientWebsocketStatus" label="通讯">
  205. <template slot-scope="scope">
  206. <right-or-wrong
  207. :status="CLIENT_WEBSOCKET_STATUS[scope.row.clientWebsocketStatus]"
  208. ></right-or-wrong>
  209. </template>
  210. </el-table-column>
  211. <el-table-column
  212. v-for="source in viewingAngles"
  213. :key="source.param"
  214. :prop="source.param"
  215. :label="`${source.name}通讯`"
  216. >
  217. <template slot-scope="scope">
  218. <right-or-wrong
  219. :status="MONITOR_STATUS_SOURCE[scope.row[source.param]]"
  220. ></right-or-wrong>
  221. </template>
  222. </el-table-column>
  223. <el-table-column
  224. prop="clientCurrentIp"
  225. label="IP"
  226. v-if="curExamBatch.enableIpLimit"
  227. >
  228. </el-table-column>
  229. <el-table-column prop="updateTime" label="更新时间">
  230. <span slot-scope="scope">
  231. {{ scope.row.updateTime | datetimeFilter }}
  232. </span>
  233. </el-table-column>
  234. <el-table-column prop="warningCount" label="预警数"></el-table-column>
  235. <el-table-column prop="breachStatus" label="违纪">
  236. <template slot-scope="scope">
  237. <span :class="{ 'color-danger': !scope.row.breachStatus }">
  238. {{ !scope.row.breachStatus ? "违纪" : "正常" }}
  239. </span>
  240. </template>
  241. </el-table-column>
  242. <el-table-column label="操作" width="125" fixed="right">
  243. <template slot-scope="scope">
  244. <el-button
  245. :class="[
  246. 'btn-table-icon',
  247. { 'warn-new-tips': scope.row.warningNew },
  248. ]"
  249. type="primary"
  250. icon="icon icon-view"
  251. @click="toDetail(scope.row)"
  252. >详情</el-button
  253. >
  254. </template>
  255. </el-table-column>
  256. </el-table>
  257. <div class="invigilation-student-list" v-else>
  258. <div
  259. class="invigilation-student-item"
  260. v-for="item in dataList"
  261. :key="item.examRecordId"
  262. >
  263. <invigilation-student :data="item"></invigilation-student>
  264. </div>
  265. </div>
  266. <div class="part-page">
  267. <el-pagination
  268. background
  269. hide-on-single-page
  270. layout="prev, pager, next,total,jumper"
  271. :current-page="current"
  272. :total="total"
  273. :page-size="size"
  274. @current-change="toPage"
  275. >
  276. </el-pagination>
  277. </div>
  278. <!-- 考试批次选择 -->
  279. <exam-batch-dialog
  280. :initExamid="initExamid"
  281. @confirm="examChange"
  282. ref="ExamBatchDialog"
  283. ></exam-batch-dialog>
  284. <!-- 手动收卷 -->
  285. <handle-rollup-dialog
  286. :data-list="multipleSelection"
  287. @modified="rollupOver"
  288. ref="handleRollupDialog"
  289. ></handle-rollup-dialog>
  290. </div>
  291. </template>
  292. <script>
  293. import {
  294. invigilateVideoList,
  295. invigilateExamFinish,
  296. monitorCallCount,
  297. invigilationWarningMessage,
  298. } from "@/api/invigilation";
  299. import ExamBatchDialog from "./ExamBatchDialog";
  300. import RightOrWrong from "../common/RightOrWrong";
  301. import InvigilationStudent from "../common/InvigilationStudent";
  302. import SummaryLine from "../common/SummaryLine";
  303. import handleRollupDialog from "./handleRollupDialog";
  304. import TextClock from "../common/TextClock";
  305. import {
  306. BOOLEAN_TYPE,
  307. VIDEO_SOURCE_TYPE,
  308. BOOLEAN_INVERSE_TYPE,
  309. STUDENT_ONLINE_STATUS,
  310. CLIENT_WEBSOCKET_STATUS,
  311. MONITOR_STATUS_SOURCE,
  312. } from "@/constant/constants";
  313. import { mapState, mapMutations, mapActions } from "vuex";
  314. export default {
  315. name: "realtime-monitoring",
  316. components: {
  317. ExamBatchDialog,
  318. RightOrWrong,
  319. InvigilationStudent,
  320. SummaryLine,
  321. handleRollupDialog,
  322. TextClock,
  323. },
  324. data() {
  325. return {
  326. initExamid: this.$route.params.examId,
  327. initRoomCode: this.$route.params.roomCode,
  328. filter: {
  329. examId: "",
  330. paperDownload: null,
  331. status: null,
  332. monitorStatusSource: null,
  333. monitorVideoSource: null,
  334. name: null,
  335. identity: null,
  336. maxWarningCount: undefined,
  337. minWarningCount: undefined,
  338. },
  339. BOOLEAN_TYPE,
  340. VIDEO_SOURCE_TYPE,
  341. BOOLEAN_INVERSE_TYPE,
  342. STUDENT_ONLINE_STATUS,
  343. CLIENT_WEBSOCKET_STATUS,
  344. MONITOR_STATUS_SOURCE,
  345. hasNewWarning: false,
  346. loopRunning: false,
  347. loopSetTs: [],
  348. communicationCount: 0,
  349. curExamBatch: {},
  350. curViewingAngle: {},
  351. current: 1,
  352. total: 0,
  353. size: 24,
  354. multipleSelection: [],
  355. batchId: "",
  356. batchs: [],
  357. exams: [],
  358. subjects: [],
  359. pageType: "0",
  360. dataList: [],
  361. videoSourceStatusParams: {
  362. CLIENT_CAMERA: "cameraMonitorStatusSource",
  363. CLIENT_SCREEN: "screenMonitorStatusSource",
  364. MOBILE_FIRST: "mobileFirstMonitorStatusSource",
  365. MOBILE_SECOND: "mobileSecondMonitorStatusSource",
  366. },
  367. viewingAngles: [],
  368. };
  369. },
  370. created() {
  371. window.inviligateWarning = (id) => {
  372. this.toDetail({ examRecordId: id });
  373. };
  374. },
  375. computed: {
  376. ...mapState("invigilation", ["liveDomains"]),
  377. },
  378. methods: {
  379. ...mapActions("invigilation", ["updateDetailIds"]),
  380. ...mapMutations("invigilation", ["setDetailIds"]),
  381. clearLoopSetTs() {
  382. if (!this.loopSetTs.length) return;
  383. this.loopSetTs.forEach((sett) => {
  384. clearTimeout(sett);
  385. });
  386. this.loopSetTs = [];
  387. },
  388. async timerUpdatePage() {
  389. this.clearLoopSetTs();
  390. if (!this.loopRunning) return;
  391. let fetchAll = [this.getList()];
  392. if (this.$refs.SummaryLine)
  393. fetchAll.push(this.$refs.SummaryLine.initData());
  394. fetchAll.push(this.getMonitorCallCount());
  395. fetchAll.push(this.fetchWarningNotice());
  396. await Promise.all(fetchAll).catch(() => {});
  397. this.loopSetTs.push(
  398. setTimeout(() => {
  399. this.timerUpdatePage();
  400. }, 10 * 1000)
  401. );
  402. },
  403. examChange(examBatch) {
  404. if (!examBatch) return;
  405. this.filter.examId = examBatch.id;
  406. this.curExamBatch = examBatch;
  407. if (examBatch.monitorVideoSource) {
  408. this.viewingAngles = examBatch.monitorVideoSource
  409. .split(",")
  410. .map((item) => {
  411. return {
  412. code: item,
  413. name: this.VIDEO_SOURCE_TYPE[item],
  414. param: this.videoSourceStatusParams[item],
  415. };
  416. });
  417. } else {
  418. this.viewingAngles = [];
  419. }
  420. this.curViewingAngle = this.viewingAngles[0] || {};
  421. this.filter.monitorVideoSource = this.curViewingAngle.code || "";
  422. this.toSearch();
  423. this.getMonitorCallCount();
  424. this.fetchWarningNotice();
  425. // 正在考试的考试,开启定时更新;
  426. if (examBatch.isExaming) {
  427. this.loopRunning = true;
  428. this.clearLoopSetTs();
  429. this.loopSetTs.push(
  430. setTimeout(() => {
  431. this.timerUpdatePage();
  432. }, 10 * 1000)
  433. );
  434. } else {
  435. this.loopRunning = false;
  436. this.clearLoopSetTs();
  437. }
  438. },
  439. pageTypeChange(pageType) {
  440. this.pageType = pageType;
  441. this.multipleSelection = [];
  442. },
  443. viewingAngleChange(data) {
  444. if (data.code === this.curViewingAngle.code) return;
  445. this.curViewingAngle = data;
  446. this.filter.monitorVideoSource = data.code;
  447. this.dataList = [];
  448. this.getList();
  449. },
  450. async getList() {
  451. const datas = {
  452. ...this.filter,
  453. pageNumber: this.current,
  454. pageSize: this.size,
  455. };
  456. const res = await invigilateVideoList(datas);
  457. const domainLen = this.liveDomains.length;
  458. this.dataList = res.data.data.records.map((item, index) => {
  459. const domain = domainLen ? this.liveDomains[index % domainLen] : "";
  460. item.label = `${item.identity} ${item.courseName}(${item.courseCode}) ${item.name}`;
  461. item.liveUrl = item.monitorLiveUrl
  462. ? `${domain}/live/${item.monitorLiveUrl.toLowerCase()}.flv`
  463. : "";
  464. item.progress = item.progress ? Math.round(item.progress * 100) : 0;
  465. return item;
  466. });
  467. this.hasNewWarning = this.dataList.some((item) => item.warningNew);
  468. this.total = res.data.data.total;
  469. },
  470. toPage(page) {
  471. this.current = page;
  472. this.getList();
  473. },
  474. async toSearch() {
  475. this.current = 1;
  476. await this.getList();
  477. if (this.total > this.size) {
  478. this.updateDetailIds({
  479. filterData: this.filter,
  480. fetchFunc: invigilateVideoList,
  481. });
  482. } else {
  483. const ids = this.dataList.map((item) => item.examRecordId);
  484. this.setDetailIds([...new Set(ids)]);
  485. }
  486. },
  487. async getMonitorCallCount() {
  488. if (!this.filter.examId) return;
  489. const res = await monitorCallCount({
  490. examId: this.filter.examId,
  491. callStatus: "START",
  492. });
  493. this.communicationCount = res.data.data.count || 0;
  494. },
  495. async fetchWarningNotice() {
  496. if (!this.filter.examId) return;
  497. let noticeCaches = {};
  498. const showAlert = async (item) => {
  499. return new Promise((resolve) => {
  500. setTimeout(() => {
  501. this.$notify({
  502. duration: 5 * 1000,
  503. dangerouslyUseHTMLString: true,
  504. customClass: "msg-monitor-magbox",
  505. position: "bottom-right",
  506. offset: 50,
  507. message: `
  508. <div class="msg-monitor">
  509. <span class="msg-monitor-icon"><i class="icon icon-warning"></i></span>
  510. <span>注意:<b>${item.name}</b>发现异常,</span>
  511. <span class="msg-monitor-action" onclick="window.inviligateWarning('${item.examRecordId}')">立即处理</span>
  512. </div>
  513. `,
  514. });
  515. resolve();
  516. }, 200);
  517. });
  518. };
  519. const res = await invigilationWarningMessage(this.filter.examId);
  520. for (let i = 0, len = res.data.data.length; i < len; i++) {
  521. const item = res.data.data[i];
  522. const stdKey = item.examRecordId;
  523. if (!noticeCaches[stdKey]) {
  524. noticeCaches[stdKey] = item;
  525. await showAlert(item);
  526. }
  527. }
  528. },
  529. handleSelectionChange(val) {
  530. console.log(val);
  531. this.multipleSelection = val;
  532. },
  533. async finishInvigilation() {
  534. if (!this.multipleSelection.length) {
  535. this.$message.error("请先选择数据!");
  536. return;
  537. }
  538. this.$refs.handleRollupDialog.open();
  539. },
  540. rollupOver() {
  541. this.multipleSelection = [];
  542. this.getList();
  543. },
  544. async finishInvigilationExam() {
  545. const result = await this.$confirm(
  546. "确定要结束监考吗?",
  547. "结束监考确认提醒",
  548. {
  549. confirmButtonText: "确定",
  550. cancelButtonText: "取消",
  551. iconClass: "el-icon-warning",
  552. customClass: "el-message-box__error",
  553. }
  554. ).catch(() => {});
  555. if (!result) return;
  556. await invigilateExamFinish(this.filter.examId);
  557. this.$refs.ExamBatchDialog.getExamList();
  558. this.$message({
  559. type: "success",
  560. message: "操作成功!",
  561. });
  562. },
  563. toCommunication() {
  564. this.$router.push({
  565. name: "VideoCommunication",
  566. params: {
  567. examId: this.filter.examId,
  568. },
  569. });
  570. },
  571. toDetail(row) {
  572. this.$router.push({
  573. name: "WarningDetail",
  574. params: { recordId: row.examRecordId },
  575. });
  576. },
  577. },
  578. beforeDestroy() {
  579. this.loopRunning = false;
  580. this.clearLoopSetTs();
  581. delete window.inviligateWarning;
  582. },
  583. };
  584. </script>
  585. <style lang="scss" scoped>
  586. .realtime-top {
  587. position: relative;
  588. padding: 9px 20px 9px 73px;
  589. background: rgba(24, 134, 254, 1);
  590. border-radius: 6px;
  591. color: #fff;
  592. margin-bottom: 30px;
  593. line-height: 32px;
  594. &::before {
  595. content: "";
  596. display: block;
  597. position: absolute;
  598. width: 59px;
  599. height: 49px;
  600. left: 13px;
  601. top: 0;
  602. background-image: url(../../../assets/bg-stars.png);
  603. background-size: 100% 100%;
  604. }
  605. > p {
  606. float: left;
  607. margin: 0;
  608. }
  609. > p:first-child {
  610. margin-right: 40px;
  611. min-width: 150px;
  612. max-width: 300px;
  613. white-space: nowrap;
  614. overflow: hidden;
  615. text-overflow: ellipsis;
  616. }
  617. .realtime-top-select {
  618. display: inline-block;
  619. position: relative;
  620. height: 32px;
  621. line-height: 32px;
  622. border-radius: 6px;
  623. min-width: 200px;
  624. background-color: #fff;
  625. color: #1886fe;
  626. padding: 0 26px 0 12px;
  627. cursor: pointer;
  628. > i {
  629. position: absolute;
  630. right: 8px;
  631. top: 9px;
  632. }
  633. }
  634. .text-clock {
  635. float: right;
  636. font-size: 12px;
  637. opacity: 0.8;
  638. }
  639. }
  640. .realtime-switch {
  641. font-size: 0;
  642. &-warning {
  643. .realtime-switch-item {
  644. &::before {
  645. content: "";
  646. display: block;
  647. position: absolute;
  648. width: 10px;
  649. height: 10px;
  650. top: -5px;
  651. right: -5px;
  652. border-radius: 50%;
  653. border: 2px solid #fff;
  654. background: #fe5863;
  655. z-index: 9;
  656. }
  657. }
  658. }
  659. &-item {
  660. display: inline-block;
  661. vertical-align: top;
  662. font-size: 12px;
  663. color: #8c94ac;
  664. background: #fff;
  665. line-height: 18px;
  666. padding: 5px 14px;
  667. position: relative;
  668. cursor: pointer;
  669. > i {
  670. margin-right: 5px;
  671. }
  672. &:first-child {
  673. border-radius: 6px 0px 0px 6px;
  674. }
  675. &:last-child {
  676. border-radius: 0px 6px 6px 0px;
  677. }
  678. &-act {
  679. color: #fff;
  680. background: #5fc9fa;
  681. }
  682. }
  683. }
  684. .invigilation-student-list {
  685. background: #ffffff;
  686. border-radius: 6px;
  687. padding: 10px 10px;
  688. font-size: 0;
  689. min-height: 200px;
  690. .invigilation-student-item {
  691. font-size: 14px;
  692. display: inline-block;
  693. vertical-align: top;
  694. padding: 10px;
  695. width: 25%;
  696. }
  697. }
  698. .warn-new-tips {
  699. position: relative;
  700. &::after {
  701. content: "";
  702. display: block;
  703. position: absolute;
  704. width: 32px;
  705. height: 16px;
  706. right: -32px;
  707. top: 0;
  708. background-image: url(../../../assets/icon-new-tips.png);
  709. background-size: 100% 100%;
  710. }
  711. }
  712. </style>