WarningDetail.vue 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965
  1. <template>
  2. <div class="warning-detail">
  3. <div class="warning-detail-head">
  4. <h2>预警详情</h2>
  5. <el-button size="mini" icon="el-icon-arrow-left" @click="goBack"
  6. >返回列表</el-button
  7. >
  8. <!-- <el-button
  9. @click="initSubscribeVideo"
  10. type="primary"
  11. size="mini"
  12. icon="el-icon-arrow-left"
  13. >开始视频</el-button
  14. >
  15. <el-button
  16. @click="closeSubscribeVideo"
  17. type="danger"
  18. size="mini"
  19. icon="el-icon-arrow-left"
  20. >关闭视频</el-button
  21. > -->
  22. </div>
  23. <div class="warning-detail-body">
  24. <div class="detail-body-head">
  25. <div class="detail-body-head-left">
  26. <p>
  27. <i class="icon icon-person"></i>
  28. <span>{{ detailInfo.examStudentName }}</span>
  29. </p>
  30. <p>
  31. <span>证件号:</span><span>{{ detailInfo.identity }}</span>
  32. </p>
  33. <p>
  34. <span>班级:</span><span>{{ detailInfo.classNo }}</span>
  35. </p>
  36. <p>
  37. <span>科目(代码):</span
  38. ><span>{{ detailInfo.courseNameCode }}</span>
  39. </p>
  40. <p>
  41. <span>考试时间:</span
  42. ><span
  43. >{{ detailInfo.examActivityStartTime | datetimeFilter }}~{{
  44. detailInfo.examActivityEndTime | datetimeFilter
  45. }}</span
  46. >
  47. </p>
  48. <p>
  49. <span>考场:</span><span>{{ detailInfo.roomName }}</span>
  50. </p>
  51. <p>
  52. <span>监考员:</span><span>{{ detailInfo.invigilateUsers }}</span>
  53. </p>
  54. </div>
  55. <div class="detail-body-head-right">
  56. <el-button
  57. type="primary"
  58. title="查看上一个"
  59. :disabled="holding"
  60. @click="changeStudent(0)"
  61. >上一个
  62. <!-- <i class="icon icon-arrow-left"></i> -->
  63. </el-button>
  64. <el-button
  65. type="primary"
  66. title="查看下一个"
  67. :disabled="holding"
  68. @click="changeStudent(1)"
  69. >下一个
  70. <!-- <i class="icon icon-arrow-right"></i> -->
  71. </el-button>
  72. </div>
  73. </div>
  74. <div class="warning-detail-main">
  75. <div class="warning-action">
  76. <div class="student-avatar">
  77. <img
  78. :src="detailInfo.basePhotoPath"
  79. :alt="detailInfo.examStudentName"
  80. v-if="detailInfo.basePhotoPath"
  81. />
  82. <div class="avatar-default" v-else>
  83. <i class="el-icon-user-solid"></i>
  84. </div>
  85. <div class="avatar-title">学生底照</div>
  86. </div>
  87. <div class="warning-summary">
  88. <div class="warning-summary-row">
  89. <p class="warning-summary-col">
  90. <i class="icon icon-bell"></i>
  91. <span class="line-name"
  92. >系统预警
  93. <em :class="{ 'color-danger': detailInfo.warningCount > 0 }"
  94. >{{ detailInfo.warningCount }}次</em
  95. ></span
  96. >
  97. </p>
  98. <p class="warning-summary-col">
  99. <i class="icon icon-face"></i>
  100. <span class="line-name"
  101. >陌生人脸
  102. <em>{{ detailInfo.multipleFaceCount }}次</em>
  103. </span>
  104. </p>
  105. </div>
  106. <div class="warning-summary-row">
  107. <p class="warning-summary-col">
  108. <i class="icon icon-info"></i>
  109. <span class="line-name"
  110. >异常处理
  111. <em>{{ detailInfo.exceptionCount }}次</em>
  112. </span>
  113. </p>
  114. <p class="warning-summary-col">
  115. <i class="icon icon-success"></i>
  116. <span class="line-name"
  117. >违纪状态
  118. <em :class="{ 'color-danger': isBreach }">
  119. {{ isBreach ? "违纪" : "正常" }}</em
  120. >
  121. </span>
  122. </p>
  123. </div>
  124. <div class="summary-bg">
  125. <div class="summary-bg-line"></div>
  126. <div class="summary-bg-line"></div>
  127. <div class="summary-bg-spin"></div>
  128. <div class="summary-bg-spin"></div>
  129. <div class="summary-bg-spin"></div>
  130. <div class="summary-bg-spin"></div>
  131. </div>
  132. </div>
  133. <div class="action-list">
  134. <div v-if="actionValid" class="action-item">
  135. <el-button
  136. icon="icon icon-text-message"
  137. size="mideum"
  138. @click="toSendTextMsg"
  139. >文字提醒</el-button
  140. >
  141. </div>
  142. <div v-if="actionValid" class="action-item">
  143. <el-button
  144. icon="icon icon-record"
  145. size="mideum"
  146. @click="toSendAudioMsg"
  147. >录音提醒</el-button
  148. >
  149. </div>
  150. <div
  151. v-if="detailInfo.monitorVideoSource && actionValid"
  152. class="action-item"
  153. >
  154. <el-button
  155. icon="icon icon-call"
  156. size="mideum"
  157. :loading="holding"
  158. @click="answer(0)"
  159. >语音通话</el-button
  160. >
  161. </div>
  162. <div
  163. v-if="detailInfo.monitorVideoSource && actionValid"
  164. class="action-item"
  165. >
  166. <el-button
  167. icon="icon icon-media"
  168. size="mideum"
  169. :loading="holding"
  170. @click="answer(1)"
  171. >视频通话</el-button
  172. >
  173. </div>
  174. <div class="action-item">
  175. <el-button
  176. icon="icon icon-info-danger"
  177. size="mideum"
  178. @click="toBreach(0)"
  179. >违纪处理</el-button
  180. >
  181. </div>
  182. <div
  183. v-if="detailInfo.statusCode === 'ANSWERING'"
  184. class="action-item"
  185. >
  186. <el-button
  187. icon="icon icon-paper-danger"
  188. size="mideum"
  189. @click="toFinish"
  190. >强制收卷</el-button
  191. >
  192. </div>
  193. </div>
  194. </div>
  195. <div class="warning-content">
  196. <div class="warning-videos">
  197. <div
  198. v-for="item in viewVideos"
  199. :key="item.liveUrl"
  200. class="student-video-item"
  201. >
  202. <div class="student-video-container">
  203. <div class="student-video-tips">{{ item.name }}</div>
  204. <flv-media
  205. :ref="item.ref"
  206. :live-url="item.liveUrl"
  207. @muted-change="videoAllMuted"
  208. ></flv-media>
  209. </div>
  210. </div>
  211. </div>
  212. <!-- track -->
  213. <div class="warning-track">
  214. <h3 class="warning-track-title">
  215. <i class="icon icon-track"></i>考试轨迹
  216. </h3>
  217. <div
  218. class="warning-track-item"
  219. v-for="log in detailInfo.examStudentLogList"
  220. :key="log.id"
  221. >
  222. <div
  223. :class="[
  224. 'warning-track-type',
  225. log.viewType === 'common' ? 'type-common' : 'type-exception',
  226. ]"
  227. >
  228. <i :class="['icon', `icon-track-${log.viewType}`]"></i>
  229. </div>
  230. <div class="warning-track-body">
  231. <div class="warning-track-info">
  232. <div class="warning-track-info-title">
  233. <template v-if="log.type === 'BREACH_HANDLE'">
  234. <span
  235. v-if="!log.breachLogStatus"
  236. class="warning-track-cancel"
  237. @click="toBreach(1, log.objId)"
  238. >撤销违纪</span
  239. >
  240. <span v-else>已撤销</span>
  241. </template>
  242. <h3>{{ log.title }}</h3>
  243. <b
  244. v-if="log.ipChange"
  245. :class="{ 'color-danger': log.ipChange }"
  246. >[ip变动]</b
  247. >
  248. </div>
  249. <p v-if="log.desc">{{ log.desc }}</p>
  250. <p>
  251. 时间段:
  252. <span v-if="log.startTime">{{ log.startTime }} ~ </span>
  253. <span>{{ log.endTime }}</span>
  254. </p>
  255. <p v-if="log.durationTime">
  256. 持续时长约:{{ log.durationTime }}
  257. </p>
  258. <p v-if="log.ip" :class="{ 'color-danger': log.ipChange }">
  259. ip:{{ log.ip }},属地:{{ log.province }}{{ log.city }}
  260. </p>
  261. </div>
  262. <ul class="warning-track-media" v-if="log.photos">
  263. <li v-for="(photo, pindex) in log.photos" :key="pindex">
  264. <img :src="photo" @click="toViewImg(photo)" />
  265. </li>
  266. </ul>
  267. </div>
  268. </div>
  269. </div>
  270. </div>
  271. </div>
  272. </div>
  273. <!-- student-breach-dialog -->
  274. <student-breach-dialog
  275. :instance="curDetail"
  276. @modified="breachFinish"
  277. ref="StudentBreachDialog"
  278. ></student-breach-dialog>
  279. <!-- warning-text-message-dialog -->
  280. <warning-text-message-dialog
  281. :record-id="examRecordId"
  282. ref="WarningTextMessageDialog"
  283. ></warning-text-message-dialog>
  284. <!-- audio-record-dialog -->
  285. <audio-record-dialog
  286. :record-id="examRecordId"
  287. ref="AudioRecordDialog"
  288. ></audio-record-dialog>
  289. <!-- image-preview -->
  290. <simple-image-preview
  291. :cur-image="curImage"
  292. ref="SimpleImagePreview"
  293. ></simple-image-preview>
  294. <!-- 通话弹出层 -->
  295. <div
  296. v-if="dialogVisible"
  297. class="communication-dialog"
  298. v-move-ele.prevent.stop
  299. >
  300. <div class="communication-box" v-show="!isWaiting">
  301. <div class="communication-host" id="communication-host"></div>
  302. <div class="communication-guest" id="communication-guest"></div>
  303. <div class="communication-action" @mousedown.stop>
  304. <el-button round type="danger" @click.stop="hangup"
  305. >结束通话</el-button
  306. >
  307. </div>
  308. <div class="communication-info">
  309. <span>持续时长:<second-timer ref="SecondTimer"></second-timer></span>
  310. </div>
  311. </div>
  312. <div class="communication-wait" v-show="isWaiting">
  313. <p class="communication-wait-tips">等待接听…</p>
  314. <div class="communication-wait-avatar">
  315. <img
  316. :src="detailInfo.basePhotoPath"
  317. :alt="detailInfo.examStudentName"
  318. />
  319. </div>
  320. <p class="communication-wait-username">
  321. {{ detailInfo.examStudentName }}
  322. </p>
  323. <div class="communication-wait-action" @mousedown.stop>
  324. <el-button round type="danger" @click="hangup">取消通话</el-button>
  325. </div>
  326. </div>
  327. </div>
  328. </div>
  329. </template>
  330. <script>
  331. import {
  332. checkSystemRequirements,
  333. createClient,
  334. createStream,
  335. } from "@/plugins/trtc";
  336. import {
  337. invigilateDetail,
  338. invigilateFinish,
  339. communicationCalling,
  340. communicationOver,
  341. warningStudentDetail,
  342. getUserMonitorKey,
  343. } from "@/api/invigilation";
  344. import FlvMedia from "../common/FlvMedia";
  345. import StudentBreachDialog from "./StudentBreachDialog";
  346. import WarningTextMessageDialog from "./WarningTextMessageDialog";
  347. import AudioRecordDialog from "./audioRecord/AudioRecordDialog";
  348. import SimpleImagePreview from "@/components/imagePreview/SimpleImagePreview";
  349. import SecondTimer from "../common/SecondTimer";
  350. import {
  351. formatDate,
  352. timeNumberToText,
  353. objTypeOf,
  354. snakeToHump,
  355. } from "@/utils/utils";
  356. import MoveEle from "@/plugins/move-ele";
  357. import { mapState } from "vuex";
  358. const domEmpty = (dom) => {
  359. dom.childNodes.forEach((childNode) => {
  360. dom.removeChild(childNode);
  361. });
  362. };
  363. export default {
  364. name: "WarningDetail",
  365. components: {
  366. FlvMedia,
  367. StudentBreachDialog,
  368. WarningTextMessageDialog,
  369. AudioRecordDialog,
  370. SimpleImagePreview,
  371. SecondTimer,
  372. },
  373. directives: { MoveEle },
  374. data() {
  375. return {
  376. examRecordId: this.$route.params.examRecordId,
  377. autoAnswerInfo: null,
  378. detailInfo: {},
  379. curDetail: {},
  380. serialIds: [],
  381. exceptionSummary: [],
  382. viewVideos: [],
  383. viewVideoReady: false,
  384. holding: false,
  385. // communication
  386. userMonitor: {},
  387. client: null,
  388. localStream: null,
  389. dialogVisible: false,
  390. isWaiting: false,
  391. subscribeSetTs: [],
  392. loopRunning: false,
  393. loopSetTs: [],
  394. isHandup: false,
  395. curImage: { imgSrc: "" },
  396. };
  397. },
  398. computed: {
  399. ...mapState("invigilation", ["detailIds", "liveDomains"]),
  400. isBreach() {
  401. return (
  402. !this.detailInfo.breachStatus && this.detailInfo.breachStatus !== null
  403. );
  404. },
  405. actionValid() {
  406. return (
  407. this.detailInfo.statusCode &&
  408. ["FIRST_PREPARE", "ANSWERING"].includes(this.detailInfo.statusCode)
  409. );
  410. },
  411. },
  412. watch: {
  413. $route: {
  414. handler() {
  415. this.initData();
  416. },
  417. },
  418. },
  419. mounted() {
  420. const autoAnswerInfo = window.sessionStorage.getItem("autoAnswerInfo");
  421. this.autoAnswerInfo = autoAnswerInfo ? JSON.parse(autoAnswerInfo) : null;
  422. this.initData();
  423. },
  424. methods: {
  425. async initData() {
  426. this.examRecordId = this.$route.params.examRecordId;
  427. await this.getInvigilateDetail().catch(() => {});
  428. await this.getStudentVideo().catch(() => {});
  429. this.holding = false;
  430. // 学生正在考试,开启定时更新
  431. // 自动更新新增正在候考阶段
  432. if (["FIRST_PREPARE", "ANSWERING"].includes(this.detailInfo.statusCode)) {
  433. this.loopRunning = true;
  434. this.clearLoopSetTs();
  435. this.loopSetTs.push(
  436. setTimeout(() => {
  437. this.timerUpdatePage();
  438. }, 10 * 1000)
  439. );
  440. } else {
  441. this.loopRunning = false;
  442. this.clearLoopSetTs();
  443. }
  444. // 自动应答
  445. if (this.autoAnswerInfo) {
  446. this.autoAnswer();
  447. }
  448. },
  449. clearSubscribeSetTs() {
  450. if (!this.subscribeSetTs.length) return;
  451. this.subscribeSetTs.forEach((sett) => {
  452. clearTimeout(sett);
  453. });
  454. this.subscribeSetTs = [];
  455. },
  456. clearLoopSetTs() {
  457. if (!this.loopSetTs.length) return;
  458. this.loopSetTs.forEach((sett) => {
  459. clearTimeout(sett);
  460. });
  461. this.loopSetTs = [];
  462. },
  463. async timerUpdatePage() {
  464. this.clearLoopSetTs();
  465. if (!this.loopRunning) return;
  466. await this.getInvigilateDetail().catch(() => {});
  467. this.loopSetTs.push(
  468. setTimeout(() => {
  469. this.timerUpdatePage();
  470. }, 10 * 1000)
  471. );
  472. },
  473. async getStudentVideo() {
  474. this.viewVideos = [];
  475. const res = await warningStudentDetail({
  476. examRecordId: this.examRecordId,
  477. });
  478. const orderSources = [
  479. "CLIENT_CAMERA",
  480. "CLIENT_SCREEN",
  481. "MOBILE_FIRST",
  482. "MOBILE_SECOND",
  483. ];
  484. const sourceNames = {
  485. CLIENT_CAMERA: "电脑摄像头",
  486. CLIENT_SCREEN: "考生屏幕",
  487. MOBILE_FIRST: "手机主机位",
  488. MOBILE_SECOND: "手机辅机位",
  489. };
  490. let records = {};
  491. res.data.data.forEach((item, index) => {
  492. const domain = this.liveDomains[index] || this.liveDomains[0];
  493. item.liveUrl = item.liveUrl
  494. ? `${domain}/live/${item.liveUrl.toLowerCase()}.flv`
  495. : "";
  496. item.name = sourceNames[item.source];
  497. item.ref = snakeToHump(item.source) + "Video";
  498. records[item.source] = item;
  499. });
  500. orderSources.forEach((source) => {
  501. if (records[source]) {
  502. this.viewVideos.push(records[source]);
  503. }
  504. });
  505. // 展示所有
  506. // this.viewVideos = orderSources.map((source) => {
  507. // return (
  508. // records[source] || {
  509. // liveUrl: null,
  510. // source,
  511. // name: sourceNames[source],
  512. // ref: snakeToHump(source) + "Video",
  513. // }
  514. // );
  515. // });
  516. this.initSubscribeVideo();
  517. },
  518. async getInvigilateDetail() {
  519. const res = await invigilateDetail(this.examRecordId);
  520. this.detailInfo = res.data.data;
  521. this.detailInfo.examStudentLogList = this.parseStudentLogs(
  522. this.detailInfo.examStudentLogList
  523. );
  524. this.exceptionSummary = this.detailInfo.examStudentLogList
  525. .filter((item) => item.viewType === "warning")
  526. .slice(0, 3);
  527. },
  528. parseStudentLogs(examStudentLogList) {
  529. const statusTypes = {
  530. common: [
  531. "FIRST_START",
  532. "RESUME_START",
  533. "IN_PROCESS",
  534. "PREPARE",
  535. "ANSWERING",
  536. "BREAK_OFF",
  537. "RESUME_PREPARE",
  538. "FINISHED",
  539. "FIRST_PREPARE",
  540. ],
  541. warning: [
  542. "FACE_COUNT_ERROR",
  543. "FACE_COMPARE_ERROR",
  544. "EYE_CLOSE_ERROR",
  545. "LIVENESS_ACTION_ERROR",
  546. "REALNESS",
  547. "NONE",
  548. // exception:
  549. "NET_TIME_OUT",
  550. "MACHING_STOP",
  551. "NET_TIME_BREAK",
  552. "SOFTWARE_STOP",
  553. "POWER_CUT",
  554. "BREACH_HANDLE",
  555. "BREACH_REVOKE",
  556. "OTHER",
  557. ],
  558. };
  559. const transformInfo = {
  560. FIRST_START: "身份识别",
  561. ANSWERING: "进入考试",
  562. RESUME_START: "身份识别",
  563. };
  564. let statusTypeMap = {};
  565. Object.keys(statusTypes).map((k) => {
  566. statusTypes[k].map((item) => {
  567. statusTypeMap[item] = k;
  568. });
  569. });
  570. const dateTimeFormat = "YYYY/MM/DD HH:mm:ss";
  571. const logs = examStudentLogList.map((item) => {
  572. let info = { ...item };
  573. info.endTime = formatDate(dateTimeFormat, new Date(info.createTime));
  574. info.viewType = statusTypeMap[info.type] || "common";
  575. const content = info.info.split(/【|】/);
  576. if (content.length === 3) {
  577. info.title = content[1];
  578. info.desc = content[2];
  579. } else {
  580. info.title = transformInfo[info.type] || content[0];
  581. }
  582. if (info.remark && info.remark.includes('{"')) {
  583. info.remark = JSON.parse(info.remark);
  584. if (info.remark["MIN_CREATE_TIME"]) {
  585. info.startTime = formatDate(
  586. dateTimeFormat,
  587. new Date(info.remark["MIN_CREATE_TIME"])
  588. );
  589. info.durationTime = timeNumberToText(
  590. info.createTime - info.remark["MIN_CREATE_TIME"]
  591. );
  592. }
  593. let photos = [];
  594. Object.keys(info.remark).map((key) => {
  595. if (key.includes("PHOTO")) {
  596. const kPhotos =
  597. objTypeOf(info.remark[key]) === "array"
  598. ? info.remark[key]
  599. : [info.remark[key]];
  600. photos = [...photos, ...kPhotos];
  601. }
  602. });
  603. info.photos = photos;
  604. } else if (info.updateTime) {
  605. info.startTime = formatDate(
  606. dateTimeFormat,
  607. new Date(info.createTime)
  608. );
  609. info.endTime = formatDate(dateTimeFormat, new Date(info.updateTime));
  610. info.durationTime = timeNumberToText(
  611. info.updateTime - info.createTime
  612. );
  613. }
  614. // 撤销违纪的特别处理
  615. if (item.type === "BREACH_HANDLE" && item.title.includes('{"')) {
  616. const tinfo = JSON.parse(item.title);
  617. info.breachLogStatus = tinfo.breachLogStatus;
  618. }
  619. return info;
  620. });
  621. return logs;
  622. },
  623. changeStudent(type) {
  624. let index = this.detailIds.indexOf(this.examRecordId);
  625. if (type) {
  626. if (index >= this.detailIds.length - 1) {
  627. this.$message.error("当前没有下一个学生了");
  628. return;
  629. }
  630. index++;
  631. } else {
  632. if (index <= 0) {
  633. this.$message.error("当前没有上一个学生了");
  634. return;
  635. }
  636. index--;
  637. }
  638. if (this.holding) return;
  639. this.holding = true;
  640. this.closeSubscribeVideo();
  641. console.log(this.detailIds[index]);
  642. this.$router.replace({
  643. name: "WarningDetail",
  644. params: {
  645. examRecordId: this.detailIds[index],
  646. },
  647. });
  648. },
  649. toBreach(status = 0, breachLogId = null) {
  650. this.curDetail = {
  651. examStudentName: this.detailInfo.examStudentName,
  652. identity: this.detailInfo.identity,
  653. courseNameCode: this.detailInfo.courseNameCode,
  654. description: "",
  655. examRecordId: [this.detailInfo.examRecordId],
  656. // 状态,0:新建,1:撤销
  657. status,
  658. // 违纪日志id,新建违纪时为null,撤销违纪时需填值
  659. breachLogId,
  660. type: "",
  661. };
  662. this.$refs.StudentBreachDialog.open();
  663. },
  664. async toFinish() {
  665. const result = await this.$confirm(
  666. "试卷若被强制回收,考试再无法重置继续完成考试,请您慎重选择!您确定要强制回收改考试试卷吗?",
  667. "强制收卷确认提醒",
  668. {
  669. confirmButtonText: "确定",
  670. cancelButtonText: "取消",
  671. iconClass: "el-icon-warning",
  672. customClass: "el-message-box__error",
  673. }
  674. ).catch(() => {});
  675. if (!result) return;
  676. await invigilateFinish({
  677. examRecordId: [this.detailInfo.examRecordId],
  678. type: "INTERRUPT",
  679. });
  680. this.$message.success("强制收卷成功!");
  681. this.goBack();
  682. },
  683. toSendTextMsg() {
  684. this.$refs.WarningTextMessageDialog.open();
  685. },
  686. toSendAudioMsg() {
  687. this.$refs.AudioRecordDialog.open();
  688. },
  689. breachFinish() {
  690. this.getInvigilateDetail();
  691. },
  692. // video relative
  693. notifyError(content) {
  694. this.$notify({
  695. type: "error",
  696. message: content,
  697. });
  698. },
  699. async initClient(examRecordId) {
  700. const res = await getUserMonitorKey(examRecordId);
  701. this.userMonitor = res.data.data;
  702. this.client = createClient({
  703. mode: "live",
  704. sdkAppId: this.userMonitor.appId * 1,
  705. userId: this.userMonitor.monitorUserId,
  706. userSig: this.userMonitor.monitorUserSig,
  707. useStringRoomId: true,
  708. });
  709. },
  710. async getLocalMedia(isVideo) {
  711. const localStream = createStream({
  712. userId: this.userMonitor.monitorUserId,
  713. audio: true,
  714. video: !!isVideo,
  715. });
  716. const errorTips = {
  717. NotFoundError: "找不到硬件设备,请确保硬件设备正常。",
  718. NotAllowedError: "不授权摄像头/麦克风访问无法进行音视频通话。",
  719. NotReadableError:
  720. "暂时无法访问摄像头/麦克风,请确保当前没有其他应用请求访问摄像头/麦克风,并重试。",
  721. OverConstrainedError: "设备异常",
  722. AbortError: "设备异常",
  723. };
  724. let initLocalStreamResult = true;
  725. await localStream.initialize().catch((error) => {
  726. console.log(errorTips[error.name]);
  727. this.notifyError(errorTips[error.name] || "未知错误");
  728. initLocalStreamResult = false;
  729. localStream.close();
  730. });
  731. return initLocalStreamResult && localStream;
  732. },
  733. async autoAnswer() {
  734. await this.answer(this.autoAnswerInfo.isVideo);
  735. // 更改学生的通话申请状态
  736. await communicationCalling({
  737. recordId: this.examRecordId,
  738. source: this.autoAnswerInfo.source,
  739. });
  740. },
  741. async answer(isVideo) {
  742. const result = await checkSystemRequirements().catch(() => {
  743. this.$message.error(
  744. `您的浏览器不支持当前音视频通讯版本。建议使用最新版的chrome浏览器!`
  745. );
  746. });
  747. if (!result) return;
  748. // 客户端两路视频公用一个userId:
  749. // main:有音频,有视频
  750. // auxiliary:无音频,有视频
  751. // 手机端userId各不同
  752. if (this.holding) return;
  753. this.holding = true;
  754. this.videoAllMuted();
  755. await this.initClient(this.examRecordId).catch(() => {});
  756. if (!this.client) {
  757. this.holding = false;
  758. return;
  759. }
  760. this.localStream = await this.getLocalMedia(isVideo);
  761. if (!this.localStream) {
  762. this.holding = false;
  763. return;
  764. }
  765. this.dialogVisible = true;
  766. this.holding = false;
  767. // 添加远程用户视频发布监听
  768. this.client.on("stream-added", (event) => {
  769. console.log(event);
  770. console.log(event.stream.getUserId(), this.userMonitor.sourceUserId);
  771. const remoteStream = event.stream;
  772. if (remoteStream.getUserId() !== this.userMonitor.sourceUserId) return;
  773. if (remoteStream.getType() !== "main") return;
  774. console.log(`有效视频${remoteStream.getUserId()},准备订阅`);
  775. if (this.autoAnswerInfo) {
  776. // 存在自动应答信息时,不再延迟订阅学生音视频流
  777. this.client
  778. .subscribe(remoteStream, { audio: true, video: true })
  779. .catch((error) => {
  780. console.log(`${remoteStream.getUserId()}视频订阅失败!`, error);
  781. this.notifyError("学生视频获取失败!");
  782. });
  783. } else {
  784. // 延迟订阅视频
  785. this.subscribeSetTs.push(
  786. setTimeout(() => {
  787. this.client
  788. .subscribe(remoteStream, { audio: true, video: true })
  789. .catch((error) => {
  790. console.log(
  791. `${remoteStream.getUserId()}视频订阅失败!`,
  792. error
  793. );
  794. this.notifyError("学生视频获取失败!");
  795. });
  796. }, 5000)
  797. );
  798. }
  799. });
  800. this.client.on("stream-subscribed", (event) => {
  801. const remoteStream = event.stream;
  802. console.log(event);
  803. console.log(`${remoteStream.getUserId()}视频已订阅!`);
  804. this.isWaiting = false;
  805. this.$nextTick(() => {
  806. if (!this.$refs.SecondTimer.recoding) this.$refs.SecondTimer.start();
  807. domEmpty(document.getElementById("communication-host"));
  808. remoteStream.play("communication-host", { objectFit: "contain" });
  809. });
  810. });
  811. this.client.on("stream-removed", (event) => {
  812. const remoteStream = event.stream;
  813. if (
  814. remoteStream.getUserId() !== this.userMonitor.sourceUserId ||
  815. remoteStream.getType() !== "main" ||
  816. this.isHandup
  817. )
  818. return;
  819. console.log(event);
  820. console.log(`${remoteStream.getUserId()}已退出房间!`);
  821. this.notifyError("对方已挂断!");
  822. this.hangup();
  823. });
  824. // 加入房间
  825. let roomJoinResult = true;
  826. await this.client
  827. .join({
  828. roomId: this.userMonitor.monitorKey,
  829. role: "audience",
  830. })
  831. .catch((error) => {
  832. roomJoinResult = false;
  833. console.log("加入房间失败!", error);
  834. this.notifyError("发起通信失败!");
  835. });
  836. if (!roomJoinResult) return;
  837. console.log("加入房间成功!");
  838. // 切换角色,连麦互动
  839. let switchResult = true;
  840. await this.client.switchRole("anchor").catch((error) => {
  841. console.log("切换角色失败!", error);
  842. this.notifyError("角色错误!");
  843. switchResult = false;
  844. });
  845. if (!switchResult) return;
  846. // 发布本地视频
  847. let publishStreamResult = true;
  848. this.client.publish(this.localStream).catch((error) => {
  849. console.log("发布本地视频失败!", error);
  850. this.notifyError("本地音视频推送失败!");
  851. publishStreamResult = false;
  852. });
  853. if (!publishStreamResult) return;
  854. console.log("发布本地音视频成功!");
  855. // 播放本地视频
  856. this.localStream.play("communication-guest", { muted: true });
  857. this.isHandup = false;
  858. },
  859. async hangup() {
  860. if (this.isHandup) return;
  861. this.isHandup = true;
  862. this.clearSubscribeSetTs();
  863. this.$refs.SecondTimer.end();
  864. // 取消发布本地视频
  865. await this.client.unpublish(this.localStream).catch((error) => {
  866. console.log("取消发布本地视频失败!", error);
  867. });
  868. this.localStream.close();
  869. this.localStream = null;
  870. // 离开房间
  871. let result = true;
  872. await this.client.leave().catch((error) => {
  873. console.log("离开房间失败!", error);
  874. this.notifyError("操作异常,请重新尝试!");
  875. result = false;
  876. });
  877. if (!result) return;
  878. this.client.off("*");
  879. this.client = null;
  880. this.userMonitor = {};
  881. this.dialogVisible = false;
  882. this.isWaiting = true;
  883. // this.initSubscribeVideo();
  884. if (this.autoAnswerInfo) {
  885. // 结束学生的通话
  886. await communicationOver({
  887. recordId: this.examRecordId,
  888. source: this.autoAnswerInfo.source,
  889. }).catch(() => {
  890. console.log("结束通话状态异常!");
  891. });
  892. this.goBack();
  893. }
  894. },
  895. initSubscribeVideo() {
  896. this.viewVideoReady = true;
  897. },
  898. closeSubscribeVideo() {
  899. this.viewVideoReady = false;
  900. },
  901. videoAllMuted() {
  902. this.viewVideos
  903. .filter((vv) => vv.liveUrl)
  904. .forEach((vv) => {
  905. this.$refs[vv.ref][0].mutedPlayer(true);
  906. });
  907. },
  908. toViewImg(photo) {
  909. this.curImage = { imgSrc: photo };
  910. this.$refs.SimpleImagePreview.open();
  911. },
  912. goBack() {
  913. window.history.go(-1);
  914. },
  915. },
  916. beforeDestroy() {
  917. window.sessionStorage.removeItem("autoAnswerInfo");
  918. this.loopRunning = false;
  919. this.clearLoopSetTs();
  920. this.clearSubscribeSetTs();
  921. if (this.client) {
  922. this.client.leave();
  923. this.client.off("*");
  924. }
  925. },
  926. };
  927. </script>