WarningDetail.vue 28 KB

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