ExamingHome.vue 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208
  1. <template>
  2. <div v-if="exam && examQuestion()" :key="exam.id" class="container">
  3. <div class="header">
  4. <RemainTime></RemainTime>
  5. <div style="display: flex; flex-direction: column">
  6. <div style="margin-bottom: 12px">{{ courseName }}</div>
  7. <OverallProgress
  8. :exam-question-list="examQuestionList"
  9. ></OverallProgress>
  10. </div>
  11. <div>
  12. {{ $store.state.user.displayName }} -&nbsp;
  13. {{ $store.state.user.studentCodeList.join(",") }}
  14. </div>
  15. <QuestionFilters :exam-question-list="examQuestionList"></QuestionFilters>
  16. <i-button class="qm-primary-button" @click="submitPaper">交卷</i-button>
  17. </div>
  18. <div id="examing-home-question" class="main">
  19. <QuestionView :exam-question="examQuestion()"></QuestionView>
  20. <ArrowNavView
  21. :previous-question-order="previousQuestionOrder"
  22. :next-question-order="nextQuestionOrder"
  23. ></ArrowNavView>
  24. </div>
  25. <div :class="['side']">
  26. <div :class="['question-nav']">
  27. <QuestionNavView :paper-struct="paperStruct" />
  28. </div>
  29. <div
  30. v-if="faceEnable && startVideoAfterDelay"
  31. class="camera"
  32. :style="{ display: showFaceMotion ? 'none' : 'block' }"
  33. >
  34. <FaceRecognition
  35. v-if="faceEnable"
  36. width="400"
  37. height="300"
  38. :show-recognize-button="false"
  39. />
  40. </div>
  41. <div v-if="faceEnable && !startVideoAfterDelay" class="camera">
  42. <div
  43. style="
  44. width: 400px;
  45. height: 300px;
  46. border: 1px solid lightgrey;
  47. display: flex;
  48. align-items: center;
  49. justify-content: center;
  50. "
  51. >
  52. <div>正在打开摄像头...</div>
  53. </div>
  54. </div>
  55. </div>
  56. <Modal
  57. v-model="showFaceId"
  58. :mask-closable="false"
  59. :closable="false"
  60. width="800"
  61. :styles="{ top: '10px' }"
  62. >
  63. <FaceId v-if="showFaceId" @close-faceid="closeFaceId" />
  64. <p slot="footer"></p>
  65. </Modal>
  66. <Modal
  67. v-model="showFaceMotion"
  68. :mask-closable="false"
  69. :closable="false"
  70. width="800"
  71. :styles="{ top: '10px' }"
  72. >
  73. <FaceMotion v-if="showFaceMotion" @close-face-motion="closeFaceMotion" />
  74. <p slot="footer"></p>
  75. </Modal>
  76. <FaceTracking v-if="faceEnable && startVideoAfterDelay && PRODUCTION" />
  77. <div
  78. v-if="disableExamingBecauseRemoteApp"
  79. style="
  80. top: 0;
  81. left: 0;
  82. width: 100vw;
  83. height: 100vh;
  84. background-color: rgba(77, 77, 77, 0.75);
  85. z-index: 100;
  86. position: absolute;
  87. "
  88. >
  89. <h3 style="margin-top: 80px">请关闭远程桌面软件再进行考试!</h3>
  90. <i-button @click="checkRemoteAppClicked">确认已关闭远程桌面软件</i-button>
  91. </div>
  92. </div>
  93. <div v-else>
  94. 正在等待数据返回...
  95. <i-button v-if="timeouted" class="qm-primary-button" @click="reloadPage">
  96. 重试
  97. </i-button>
  98. </div>
  99. </template>
  100. <script>
  101. import RemainTime from "./RemainTime.vue";
  102. import OverallProgress from "./OverallProgress.vue";
  103. import QuestionFilters from "./QuestionFilters.vue";
  104. import QuestionView from "./QuestionView.vue";
  105. import ArrowNavView from "./ArrowNavView.vue";
  106. import QuestionNavView from "./QuestionNavView.vue";
  107. import FaceTracking from "./FaceTracking.vue";
  108. import FaceId from "./FaceId.vue";
  109. import FaceMotion from "./FaceMotion/FaceMotion";
  110. import FaceRecognition from "../../../components/FaceRecognition/FaceRecognition";
  111. import { openWS, closeWsWithoutReconnect } from "./ws.js";
  112. import { createNamespacedHelpers, mapState as globalMapState } from "vuex";
  113. const { mapState, mapMutations } = createNamespacedHelpers("examingHomeModule");
  114. import nativeExe, { fileExists } from "@/utils/nativeExe";
  115. import { REMOTE_APP_NAME } from "@/constants/constant-namelist";
  116. import { STRICT_CHECK_HOSTS } from "@/constants/constants";
  117. import { checkMainExe } from "@/utils/util";
  118. export default {
  119. name: "ExamingHome",
  120. components: {
  121. RemainTime,
  122. OverallProgress,
  123. QuestionFilters,
  124. QuestionView,
  125. ArrowNavView,
  126. QuestionNavView,
  127. FaceRecognition,
  128. FaceId,
  129. FaceMotion,
  130. FaceTracking,
  131. },
  132. beforeRouteUpdate(from, to, next) {
  133. window._hmt.push(["_trackEvent", "答题页面", "题目切换"]);
  134. if (process.env.NODE_ENV === "development") {
  135. console.log("beforeRouteUpdate from: " + this.$route.fullPath);
  136. }
  137. this.answerAllQuestions();
  138. next();
  139. },
  140. data() {
  141. return {
  142. showFaceId: false,
  143. showFaceMotion: false,
  144. faceEnable: false,
  145. timeouted: false,
  146. startVideoAfterDelay: false,
  147. PRODUCTION: process.env.NODE_ENV === "production",
  148. disableExamingBecauseRemoteApp: false,
  149. courseName: "",
  150. };
  151. },
  152. computed: {
  153. ...globalMapState(["QECSConfig"]),
  154. ...mapState([
  155. "exam",
  156. "paperStruct",
  157. "examQuestionList",
  158. "snapNow",
  159. "snapProcessingCount",
  160. "shouldSubmitPaper",
  161. "remainTime",
  162. "questionAnswerFileUrl",
  163. "uploadModalVisible",
  164. "exceedSwitchCount",
  165. ]),
  166. previousQuestionOrder: (vm) => {
  167. if (vm.examQuestion().order > 1) {
  168. return vm.examQuestion().order - 1;
  169. } else {
  170. return null;
  171. }
  172. },
  173. nextQuestionOrder: (vm) => {
  174. if (vm.examQuestion().order < vm.examQuestionList.length) {
  175. return vm.examQuestion().order + 1;
  176. } else {
  177. return null;
  178. }
  179. },
  180. },
  181. watch: {
  182. $route: function () {
  183. this.examQuestion();
  184. },
  185. shouldSubmitPaper() {
  186. this.logger({ action: "时间到自动交卷" });
  187. this.realSubmitPaper();
  188. },
  189. questionAnswerFileUrl(value) {
  190. // console.log(this.examQuestion.studentAnswer);
  191. // console.log("watch", value);
  192. const examRecordDataId = this.$route.params.examRecordDataId;
  193. const that = this;
  194. for (const q of value) {
  195. if (!q.saved) {
  196. let acknowledgeStatus = "CONFIRMED";
  197. // 目前只针对音频题有丢弃的可能
  198. if (
  199. q.transferFileType === "PIC" &&
  200. (q.order != this.$route.params.order || !this.uploadModalVisible)
  201. ) {
  202. acknowledgeStatus = "DISCARDED";
  203. }
  204. this.$http
  205. .post(
  206. "/api/ecs_oe_student/examControl/saveUploadedFileAcknowledgeStatus",
  207. {
  208. examRecordDataId,
  209. filePath: q.fileUrl,
  210. order: q.order,
  211. acknowledgeStatus,
  212. }
  213. )
  214. .then(() => {
  215. if (q.transferFileType === "AUDIO") {
  216. that.updateExamQuestion({
  217. order: q.order,
  218. studentAnswer: q.fileUrl,
  219. });
  220. } else if (
  221. acknowledgeStatus === "CONFIRMED" &&
  222. q.transferFileType === "PIC"
  223. ) {
  224. that.updatePicture(q);
  225. }
  226. q.saved = true;
  227. if (acknowledgeStatus === "CONFIRMED")
  228. this.$Message.info({
  229. content: "小程序作答已更新",
  230. duration: 5,
  231. closable: true,
  232. });
  233. })
  234. .catch(() => {
  235. this.$Message.error({
  236. content: "更新小程序答案失败!",
  237. duration: 15,
  238. closable: true,
  239. });
  240. });
  241. }
  242. }
  243. },
  244. exceedSwitchCount(val) {
  245. if (val) {
  246. this.logger({ action: "切屏超出次数自动交卷" });
  247. this.realSubmitPaper();
  248. }
  249. },
  250. // examQuestionList(val, oldVal) {
  251. // // console.log(val, oldVal);
  252. // }
  253. remainTime(val) {
  254. if (val === 5 * 60 * 1000) {
  255. this.reaminModalCreated = true;
  256. this.$Modal.info({
  257. render: () => (
  258. <div>
  259. <h3>温馨提醒</h3>
  260. <div style="margin-top: 20px; margin-left: 20px; flex: 1">
  261. <div style="margin-bottom: 1.5em">
  262. 还有<span style="font-weight: bold; color: red;"> 五 </span>
  263. 分钟即将结束本场考试,请合理分配时间!
  264. </div>
  265. </div>
  266. </div>
  267. ),
  268. onOk: () => {
  269. this.reaminModalClosed = true;
  270. },
  271. });
  272. } else if (val === 5 * 60 * 1000 - 10 * 1000) {
  273. if (this.reaminModalCreated && !this.reaminModalClosed) {
  274. this.$Modal.remove();
  275. }
  276. }
  277. },
  278. },
  279. async created() {
  280. this.timeoutTimeout = setTimeout(() => (this.timeouted = true), 30 * 1000);
  281. this.logger({
  282. action: "答题页面",
  283. detail: "进入答题页面-created",
  284. examRecordDataId: this.$route.params.examRecordDataId,
  285. });
  286. try {
  287. await this.initData();
  288. } catch (error) {
  289. this.logger({
  290. action: "答题页面",
  291. detail: "获取考试和试卷信息失败,退出登录",
  292. });
  293. this.$Message.error({
  294. content: "获取考试和试卷信息失败,退出登录",
  295. duration: 15,
  296. closable: true,
  297. });
  298. this.logout("?LogoutReason=获取考试和试卷信息失败");
  299. return;
  300. }
  301. this.submitInterval = setInterval(
  302. () => this.answerAllQuestions(),
  303. 5 * 1000 // 10秒检查是否有更改需要提交答案
  304. );
  305. this.checkRemoteAppInterval = setInterval(
  306. () => this.checkRemoteApp(),
  307. 3 * 60 * 1000 // 10秒检查是否有更改需要提交答案
  308. );
  309. console.log("考试开始 ", this.$route.params.examRecordDataId);
  310. },
  311. async mounted() {
  312. // iview bug: https://github.com/iview/iview/issues/4061
  313. // document.body.style = "";
  314. // 避免macos上下塘动。避免产生滚动条。
  315. document.body.classList.toggle("hide-body-scroll", true);
  316. // NotAllowedError: play() failed because the user didn't interact with the document first. https://goo.gl/xX8pDD
  317. this.startVideoAfterDelayTimeout = setTimeout(() => {
  318. this.startVideoAfterDelay = true;
  319. }, 10 * 1000);
  320. window._hmt.push(["_trackEvent", "答题页面", "进入页面"]);
  321. if (typeof nodeRequire != "undefined") {
  322. try {
  323. var fs = window.nodeRequire("fs");
  324. if (fs.existsSync("multiCamera.exe")) {
  325. await new Promise((resolve, reject) => {
  326. window.nodeRequire("node-cmd").get("multiCamera.exe", () => {
  327. try {
  328. let cameraInfos = fs.readFileSync("CameraInfo.txt", "utf-8");
  329. if (cameraInfos && cameraInfos.trim()) {
  330. cameraInfos = cameraInfos.trim();
  331. cameraInfos = cameraInfos.replace(/\r\n/g, "");
  332. cameraInfos = cameraInfos.replace(/\n/g, "");
  333. console.log(cameraInfos);
  334. }
  335. resolve();
  336. } catch (error) {
  337. reject("读取摄像头列表失败");
  338. }
  339. });
  340. });
  341. }
  342. } catch (error) {
  343. console.log(error);
  344. }
  345. }
  346. },
  347. beforeDestroy() {
  348. clearTimeout(this.timeoutTimeout);
  349. clearInterval(this.submitInterval);
  350. clearInterval(this.initSnapInterval);
  351. clearInterval(this.snapInterval);
  352. clearTimeout(this.faceIdMsgTimeout);
  353. clearTimeout(this.checkMainTimeout);
  354. clearTimeout(this.faceIdDivTimeout);
  355. clearTimeout(this.startVideoAfterDelayTimeout);
  356. clearInterval(this.checkRemoteAppInterval);
  357. closeWsWithoutReconnect();
  358. this.updateExamState({
  359. exam: null,
  360. paperStruct: null,
  361. examQuestionList: null,
  362. questionAnswerFileUrl: [],
  363. pictureAnswer: {},
  364. snapNow: false,
  365. snapProcessingCount: 0,
  366. exceedSwitchCount: false,
  367. });
  368. // TODO: 是否是个错误点?this.$Modal 不存在?
  369. this.$Modal.remove();
  370. // 避免macos上下塘动。避免产生滚动条。
  371. document.body.classList.toggle("hide-body-scroll", false);
  372. },
  373. // beforeRouteUpdate(to, from, next) {
  374. // this.updateQuestion(next);
  375. // },
  376. methods: {
  377. ...mapMutations([
  378. "updateExamState",
  379. "updateExamQuestion",
  380. "toggleSnapNow",
  381. "updateExamResult",
  382. "resetExamQuestionDirty",
  383. "updatePicture",
  384. ]),
  385. async initData() {
  386. this.logger({
  387. action: "答题页面",
  388. detail: "before initData",
  389. });
  390. const [
  391. { data: weixinAnswerEnabled },
  392. { data: faceCheckEnabled },
  393. { data: faceLivenessEnabled },
  394. { data: examProp },
  395. { data: exam },
  396. { data: paperStruct },
  397. { data: examQuestionListOrig },
  398. { data: courseName },
  399. ] = await Promise.all([
  400. this.$http.get(
  401. "/api/ecs_exam_work/exam/weixinAnswerEnabled/" +
  402. this.$route.params.examId
  403. ),
  404. this.$http.get(
  405. "/api/ecs_exam_work/exam/faceCheckEnabled/" +
  406. this.$route.params.examId
  407. ),
  408. this.$http.get(
  409. "/api/ecs_exam_work/exam/identificationOfLivingEnabled/" +
  410. this.$route.params.examId
  411. ),
  412. this.$http.get(
  413. "/api/ecs_exam_work/exam/getExamPropertyFromCacheByStudentSession/" +
  414. this.$route.params.examId +
  415. `/SNAPSHOT_INTERVAL,PRACTICE_TYPE,FREEZE_TIME`
  416. ),
  417. this.$http.get("/api/ecs_exam_work/exam/" + this.$route.params.examId),
  418. this.$http.get(
  419. "/api/ecs_oe_student/examRecordPaperStruct/getExamRecordPaperStruct?examRecordDataId=" +
  420. this.$route.params.examRecordDataId
  421. ),
  422. this.$http.get("/api/ecs_oe_student/examQuestion/findExamQuestionList"),
  423. this.$http.get(
  424. "/api/ecs_oe_student/examControl/courseName/" +
  425. this.$route.params.examRecordDataId
  426. ),
  427. ]);
  428. this.courseName = courseName;
  429. let initFaceLivenessResult = null;
  430. if (faceLivenessEnabled) {
  431. initFaceLivenessResult = await this.initFaceLiveness();
  432. }
  433. let examQuestionList = examQuestionListOrig;
  434. if (
  435. weixinAnswerEnabled === undefined ||
  436. faceCheckEnabled === undefined ||
  437. faceLivenessEnabled === undefined ||
  438. examProp === undefined ||
  439. exam === undefined ||
  440. paperStruct === undefined ||
  441. examQuestionList === undefined ||
  442. (faceLivenessEnabled && initFaceLivenessResult === false)
  443. ) {
  444. console.log({
  445. weixinAnswerEnabled,
  446. faceCheckEnabled,
  447. faceLivenessEnabled,
  448. examProp,
  449. exam,
  450. paperStruct,
  451. examQuestionList,
  452. initFaceLivenessResult,
  453. });
  454. throw new Error("获取考试和试卷信息失败");
  455. }
  456. this.logger({
  457. action: "答题页面",
  458. detail: `end${
  459. typeof Object.fromEntries === "function" ? " " : " "
  460. }initData`,
  461. });
  462. this.logger({
  463. action: "答题页面dimension",
  464. scrollX: window.scrollX,
  465. scrollY: window.scrollY,
  466. width: window.screen.width,
  467. height: window.screen.height,
  468. screenX: window.screen.availWidth,
  469. screenY: window.screen.availHeight,
  470. clientWidth: document.documentElement.clientWidth,
  471. clientHeight: document.documentElement.clientHeight,
  472. windowInnerWidth: window.innerWidth,
  473. windowInnerHeight: window.innerHeight,
  474. windowOuterWidth: window.outerWidth,
  475. windowOuterHeight: window.outerHeight,
  476. // 是否全屏
  477. equal1:
  478. "dimesion1" +
  479. (window.screen.width === window.outerWidth &&
  480. window.screen.height === window.outerHeight),
  481. // 是否打开了调试窗口
  482. equal2:
  483. "dimesion2" +
  484. (window.innerWidth === window.outerWidth &&
  485. window.innerHeight === window.outerHeight),
  486. });
  487. this.checkMainTimeout = setTimeout(() => {
  488. if (STRICT_CHECK_HOSTS.includes(window.location.hostname)) {
  489. if (!checkMainExe()) {
  490. this.$http.post(
  491. "/api/ecs_oe_student/client/exam/process/discipline"
  492. );
  493. this.logger({
  494. action: "答题页面discipline",
  495. });
  496. }
  497. }
  498. }, 60 * 1000);
  499. exam.WEIXIN_ANSWER_ENABLED = weixinAnswerEnabled;
  500. if (faceCheckEnabled) {
  501. this.faceEnable = true;
  502. let initSnapshotTrialTimes = 0;
  503. this.initSnapInterval = setInterval(() => {
  504. const video = document.getElementById("video");
  505. const videoStartFailed =
  506. !video || video.readyState !== 4 || !video.srcObject.active;
  507. if (videoStartFailed && initSnapshotTrialTimes < 5) {
  508. initSnapshotTrialTimes++;
  509. this.logger({
  510. action: "答题页面",
  511. detail:
  512. "进入考试后60秒内抓拍-" + `(第${initSnapshotTrialTimes}次尝试)`,
  513. });
  514. } else {
  515. // 超过6次后,强行抓拍,如果抓拍不成功,则会因抓拍不成功而退出。
  516. clearInterval(this.initSnapInterval);
  517. if (videoStartFailed) {
  518. this.logger({
  519. action: "答题页面",
  520. detail: "摄像头没有正常启用-进入考试抓拍",
  521. });
  522. this.$Message.error({
  523. content: "摄像头没有正常启用",
  524. duration: 5,
  525. closable: true,
  526. });
  527. window._hmt.push([
  528. "_trackEvent",
  529. "摄像头框",
  530. "摄像头状态",
  531. "摄像头没有正常启用-进入考试抓拍",
  532. ]);
  533. this.logout("?LogoutReason=" + "摄像头没有正常启用-退出");
  534. } else {
  535. this.logger({
  536. action: "答题页面",
  537. detail:
  538. "进入考试后60秒内抓拍-" +
  539. `(第${initSnapshotTrialTimes + 1}次尝试成功)`,
  540. });
  541. this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
  542. }
  543. }
  544. }, 10 * 1000);
  545. // let initSnapshotTrialTimes = 0;
  546. // const initSnapshot = setTimeout(() => {
  547. // if (this.exam || initSnapshotTrialTimes < 6) {
  548. // this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
  549. // } else {
  550. // setTimeout(() => initSnapshot(), 5 * 1000);
  551. // }
  552. // }, 5 * 1000);
  553. if (examProp.SNAPSHOT_INTERVAL) {
  554. const SNAPSHOT_INTERVAL = JSON.parse(examProp.SNAPSHOT_INTERVAL);
  555. // 考务设置抓拍间隔
  556. this.snapInterval = setInterval(() => {
  557. this.logger({
  558. action: "答题页面",
  559. detail: "定时抓拍",
  560. SNAPSHOT_INTERVAL: examProp.SNAPSHOT_INTERVAL,
  561. });
  562. this.toggleSnapNow();
  563. }, SNAPSHOT_INTERVAL * 60 * 1000);
  564. }
  565. }
  566. if (exam.examType === "PRACTICE") {
  567. this.practiceType = examProp.PRACTICE_TYPE; // IN_PRACTICE NO_ANSWER
  568. exam.practiceType = examProp.PRACTICE_TYPE;
  569. }
  570. exam.freezeTime =
  571. examProp.FREEZE_TIME && JSON.parse(examProp.FREEZE_TIME);
  572. this.logger({
  573. page: "答题页面",
  574. examRecordDataId: this.$route.params.examRecordDataId,
  575. faceCheckEnabled: faceCheckEnabled,
  576. faceLivenessEnabled: faceLivenessEnabled,
  577. WEIXIN_ANSWER_ENABLED: exam.WEIXIN_ANSWER_ENABLED,
  578. SNAPSHOT_INTERVAL: examProp.SNAPSHOT_INTERVAL,
  579. PRACTICE_TYPE: examProp.PRACTICE_TYPE,
  580. FREEZE_TIME: examProp.FREEZE_TIME,
  581. });
  582. // parentQuestionBody
  583. // questionUnitWrapperList
  584. // questionBody => from examQuestionList
  585. // questionUnitList =>
  586. // studentAnswer
  587. // rightAnswer
  588. // init subNumber
  589. let questionId = null;
  590. let i = 1;
  591. examQuestionList = examQuestionList.map((eq) => {
  592. if (questionId == eq.questionId) {
  593. eq.subNumber = i++;
  594. } else {
  595. i = 1;
  596. questionId = eq.questionId;
  597. eq.subNumber = i++;
  598. }
  599. return eq;
  600. });
  601. let groupOrder = 1;
  602. let mainNumber = 0;
  603. examQuestionList = examQuestionList.map((eq) => {
  604. if (mainNumber == eq.mainNumber) {
  605. eq.groupOrder = groupOrder++;
  606. } else {
  607. mainNumber = eq.mainNumber;
  608. groupOrder = 1;
  609. eq.groupOrder = groupOrder++;
  610. }
  611. const questionWrapperList =
  612. paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1]
  613. .questionWrapperList;
  614. const groupName =
  615. paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1]
  616. .groupName;
  617. const groupTotal = questionWrapperList.reduce(
  618. (accumulator, questionWrapper) =>
  619. accumulator + questionWrapper.questionUnitWrapperList.length,
  620. 0
  621. );
  622. eq.groupName = groupName;
  623. eq.groupTotal = groupTotal;
  624. return eq;
  625. });
  626. examQuestionList = examQuestionList.map((eq) => {
  627. const paperStructQuestion = paperStruct.defaultPaper.questionGroupList[
  628. eq.mainNumber - 1
  629. ].questionWrapperList.find((q) => q.questionId === eq.questionId);
  630. return Object.assign(eq, {
  631. limitedPlayTimes: paperStructQuestion.limitedPlayTimes,
  632. });
  633. });
  634. this.updateExamState({
  635. exam: exam,
  636. paperStruct: paperStruct,
  637. examQuestionList: examQuestionList,
  638. allAudioPlayTimes: JSON.parse(examQuestionList[0].audioPlayTimes) || [],
  639. questionAnswerFileUrl: [],
  640. pictureAnswer: {},
  641. });
  642. // console.log(examQuestionList);
  643. // console.log(examQuestionList.find(v => v.answerType === "SINGLE_AUDIO"));
  644. const shouldOpenWS = exam.WEIXIN_ANSWER_ENABLED;
  645. if (shouldOpenWS) {
  646. // console.log("have single");
  647. const examRecordDataId = this.$route.params.examRecordDataId;
  648. openWS({ examRecordDataId });
  649. }
  650. },
  651. updateQuestion: async function (next) {
  652. // 初始化套题的答案,为回填部分选项做准备
  653. // for (let q of this.examQuestionList) {
  654. // if (q.subQuestionList.length > 0) {
  655. // q.studentAnswer = [];
  656. // for (let sq of q.subQuestionList) {
  657. // q.studentAnswer.push(sq.studentAnswer);
  658. // }
  659. // }
  660. // }
  661. next && next();
  662. if (!this.exam) return;
  663. },
  664. async initFaceLiveness() {
  665. let faceVerifyMinute = null;
  666. let identificationOfLivingBodyScheme = null;
  667. {
  668. const examRecordDataId = this.$route.params.examRecordDataId;
  669. for (let i = 0; i < 100; i++) {
  670. try {
  671. const faceBiopsyBaseInfoData = await this.$http.get(
  672. "/api/ecs_oe_student/faceBiopsy/getFaceBiopsyBaseInfo?examRecordDataId=" +
  673. examRecordDataId
  674. );
  675. console.log(faceBiopsyBaseInfoData.data);
  676. faceVerifyMinute = faceBiopsyBaseInfoData.data.faceVerifyMinute;
  677. identificationOfLivingBodyScheme =
  678. faceBiopsyBaseInfoData.data.identificationOfLivingBodyScheme;
  679. break;
  680. } catch (error) {
  681. console.log(error);
  682. if (!error.response) {
  683. await new Promise((resolve) => setTimeout(resolve, 1000));
  684. continue; // 网络不通
  685. } else {
  686. break;
  687. }
  688. }
  689. }
  690. }
  691. if (identificationOfLivingBodyScheme === null) {
  692. return false;
  693. }
  694. // 仅在线上使用活体检测
  695. // if (process.env.NODE_ENV === "production" && faceVerifyMinute) {
  696. if (faceVerifyMinute) {
  697. // 第二次开启活检时肯定有 this.remainTime 了。注意断点续考时没有这项检查
  698. this.$nextTick(async () => {
  699. await new Promise((r) =>
  700. setTimeout(() => {
  701. r();
  702. }, 10 * 1000)
  703. );
  704. console.log("活检定时");
  705. this.logger({ action: "活检定时", detail: faceVerifyMinute });
  706. const enoughTimeForFaceId = this.remainTime // 如果remainTime取到了的话
  707. ? this.remainTime / (60 * 1000) - 1 > faceVerifyMinute
  708. : true;
  709. if (!enoughTimeForFaceId) return;
  710. this.faceIdMsgTimeout = setTimeout(() => {
  711. this.logger({
  712. action: "答题页面",
  713. detail: "活体检测前抓拍",
  714. });
  715. this.toggleSnapNow();
  716. this.$Message.info({
  717. content: "30秒后开始指定动作检测",
  718. duration: 15,
  719. closable: true,
  720. });
  721. }, faceVerifyMinute * 60 * 1000 - 30 * 1000); // 活体检测提醒
  722. this.faceIdDivTimeout = setTimeout(() => {
  723. if (identificationOfLivingBodyScheme === "S1") {
  724. this.showFaceId = true;
  725. } else if (identificationOfLivingBodyScheme === "S2") {
  726. this.showFaceMotion = true;
  727. }
  728. }, faceVerifyMinute * 60 * 1000); // 定时做活体检测
  729. // }, 1 * 1000); // 定时做活体检测
  730. });
  731. }
  732. // for test
  733. // setTimeout(() => {
  734. // this.showFaceId = true;
  735. // // this.$Modal.remove();
  736. // // }, this.$route.query.faceVerifyMinute * 60 * 1000); // 定时做活体检测
  737. // }, 5 * 1000); // 定时做活体检测
  738. return true;
  739. },
  740. closeFaceId() {
  741. this.showFaceId = false;
  742. },
  743. async closeFaceMotion(faceLiveResult) {
  744. this.showFaceMotion = false;
  745. console.log(faceLiveResult);
  746. if (faceLiveResult.endExam) {
  747. this.logout("?LogoutReason=活检后台交卷");
  748. } else if (faceLiveResult.needNextVerify) {
  749. const initFaceLivenessResult = await this.initFaceLiveness();
  750. if (initFaceLivenessResult === false) {
  751. this.logger({
  752. action: "答题页面",
  753. detail: "活检接口再次获取失败-退出",
  754. });
  755. window._hmt.push([
  756. "_trackEvent",
  757. "答题页面",
  758. "活检接口再次获取失败-退出",
  759. ]);
  760. this.$Message.error({
  761. content: "获取考试和试卷信息失败,退出登录",
  762. duration: 15,
  763. closable: true,
  764. });
  765. this.logout("?LogoutReason=活检接口再次获取失败-退出");
  766. }
  767. }
  768. },
  769. async answerAllQuestions(ignoreDirty) {
  770. const answers = this.examQuestionList
  771. .filter((eq) => (ignoreDirty ? true : eq.dirty))
  772. .filter((eq) => eq.getQuestionContent)
  773. .map((eq) => {
  774. return Object.assign(
  775. {
  776. order: eq.order,
  777. studentAnswer: eq.studentAnswer,
  778. },
  779. eq.audioPlayTimes && { audioPlayTimes: eq.audioPlayTimes },
  780. eq.isSign && { isSign: eq.isSign }
  781. );
  782. });
  783. if (answers.length > 0) {
  784. try {
  785. await this.$http.post(
  786. "/api/ecs_oe_student/examQuestion/submitQuestionAnswer",
  787. answers
  788. );
  789. this.resetExamQuestionDirty();
  790. // 提交成功,返回true,供最后提交时判断。自动提交失败,不暂停。
  791. return true;
  792. } catch (error) {
  793. console.log(error);
  794. this.logger({
  795. action: "提交答案失败",
  796. errorJSON: JSON.stringify(error, (key, value) =>
  797. key === "token" ? "" : value
  798. ),
  799. errorName: error.name,
  800. errorMessage: error.message,
  801. errorStack: error.stack,
  802. });
  803. this.$Message.error({
  804. content: "提交答案失败",
  805. duration: 15,
  806. closable: true,
  807. });
  808. window._hmt.push([
  809. "_trackEvent",
  810. "答题页面",
  811. "提交答案失败",
  812. error.message +
  813. " |||| " +
  814. (((error.response || {}).data || {}).desc || ""),
  815. ]);
  816. }
  817. }
  818. },
  819. async submitPaper() {
  820. this.logger({ action: "学生点击交卷" });
  821. try {
  822. // 交卷前强制提交所有答案
  823. const ret = await this.answerAllQuestions(true);
  824. if (!ret) {
  825. // 提交答案失败,停止交卷逻辑。
  826. return;
  827. }
  828. } catch (error) {
  829. return;
  830. }
  831. if (
  832. this.exam.freezeTime &&
  833. this.remainTime >
  834. (this.exam.duration - this.exam.freezeTime) * 60 * 1000
  835. ) {
  836. this.$Message.info({
  837. content: `考试开始${this.exam.freezeTime}分钟后才允许交卷。`,
  838. duration: 5,
  839. closable: true,
  840. });
  841. return;
  842. }
  843. const answered = this.examQuestionList.filter(
  844. (q) => q.studentAnswer !== null
  845. ).length;
  846. const unanswered = this.examQuestionList.filter(
  847. (q) => q.studentAnswer === null
  848. ).length;
  849. const signed = this.examQuestionList.filter((q) => q.isSign).length;
  850. const showConfirmTime = Date.now();
  851. this.$Modal.confirm({
  852. title: "确认交卷",
  853. content: `<p>已答题目:${answered}</p><p>未答题目:${unanswered}</p><p>标记题目:${signed}</p>`,
  854. onOk: () => {
  855. this.realSubmitPaper(showConfirmTime);
  856. },
  857. });
  858. },
  859. async realSubmitPaper(showConfirmTime = 0) {
  860. this.__submitPaperStartTime = Date.now();
  861. this.$Spin.show({
  862. render: () => {
  863. return <div style="font-size: 44px">正在交卷,请耐心等待...</div>;
  864. },
  865. });
  866. this.logger({ action: "正在交卷,请耐心等待..." });
  867. if (this.faceEnable) {
  868. this.logger({ action: "交卷前抓拍" });
  869. this.toggleSnapNow();
  870. }
  871. // 确保抓拍指令在交卷前执行,同时确保5秒间隔提交答案的指令执行了
  872. let delay = 5 - (Date.now() - showConfirmTime) / 1000;
  873. if (delay < 0) {
  874. // 如果用户已经看确认框超过5秒,或者不是由确认框进来的,不延迟
  875. delay = 0;
  876. }
  877. // 给抓拍照片多一秒处理时间
  878. delay = delay + 1;
  879. // 和下行注释的sleep语句不是一样的。sleep之后还可以执行。加上clearTimeout则可拒绝。
  880. // await new Promise(resolve => setTimeout(() => resolve(), delay * 1000));
  881. setTimeout(() => this.realSubmitPaperStep2(), delay * 1000);
  882. this.submitCount = 1;
  883. },
  884. async realSubmitPaperStep2() {
  885. if (this.snapProcessingCount > 0) {
  886. this.submitCount++;
  887. if (this.submitCount < 200) {
  888. // 一分钟后,强制交卷
  889. console.log("一分钟后,强制交卷");
  890. setTimeout(() => this.realSubmitPaperStep2(), 300);
  891. return;
  892. }
  893. }
  894. if (this.submitLock) {
  895. return;
  896. } else {
  897. this.submitLock = true;
  898. }
  899. if (this.$route.name !== "OnlineExamingHome") {
  900. // 非考试页,不再交卷
  901. this.$Spin.hide();
  902. return;
  903. }
  904. try {
  905. const examId = this.$route.params.examId;
  906. const examRecordDataId = this.$route.params.examRecordDataId;
  907. const res = await this.$http.get(
  908. "/api/ecs_oe_student/examControl/endExam"
  909. );
  910. if (res.status === 200) {
  911. this.$router.replace({
  912. path: `/online-exam/exam/${examId}/examRecordData/${examRecordDataId}/end`,
  913. });
  914. // 确保交卷成功后,不会再次交卷
  915. this.submitLock = true;
  916. this.$Spin.hide();
  917. this.__submitPaperEndTime = Date.now();
  918. window._hmt.push([
  919. "_trackEvent",
  920. "交卷耗时",
  921. Number(
  922. (this.__submitPaperEndTime - this.__submitPaperStartTime) / 1000
  923. ).toPrecision(1) + "秒",
  924. ]);
  925. this.logger({
  926. action: "交卷成功",
  927. cost: this.__submitPaperEndTime - this.__submitPaperStartTime,
  928. UA: navigator.userAgent,
  929. });
  930. return;
  931. } else {
  932. this.$Message.error({
  933. content: "交卷失败",
  934. duration: 15,
  935. closable: true,
  936. });
  937. this.logger({
  938. action: "交卷失败",
  939. detail: "endExam response status is not 200",
  940. });
  941. }
  942. this.submitLock = false;
  943. } catch (e) {
  944. this.$Message.error({
  945. content: "交卷失败",
  946. duration: 15,
  947. closable: true,
  948. });
  949. console.log(e);
  950. this.logger({
  951. action: "交卷失败",
  952. errorJSON: JSON.stringify(e, (key, value) =>
  953. key === "token" ? "" : value
  954. ),
  955. errorName: e.name,
  956. errorMessage: e.message,
  957. errorStack: e.stack,
  958. });
  959. }
  960. this.submitLock = false;
  961. this.$Spin.hide();
  962. },
  963. examQuestion() {
  964. return (
  965. this.examQuestionList &&
  966. this.examQuestionList.find(
  967. (eq) => eq.order == this.$route.params.order // number == string
  968. )
  969. );
  970. },
  971. reloadPage() {
  972. window._hmt.push(["_trackEvent", "答题页面", "页面加载失败", "reload"]);
  973. this.logger({ page: "答题页面", button: "重试按钮", action: "点击" });
  974. window.location.reload();
  975. },
  976. async checkRemoteAppClicked() {
  977. this.logger({
  978. page: "答题页面",
  979. button: "确认已关闭远程桌面软件",
  980. action: "点击",
  981. });
  982. this.checkRemoteApp();
  983. },
  984. async checkRemoteApp() {
  985. if (typeof nodeRequire == "undefined") {
  986. return;
  987. }
  988. async function checkRemoteAppTxt() {
  989. let applicationNames;
  990. try {
  991. const fs = window.nodeRequire("fs");
  992. try {
  993. applicationNames = fs.readFileSync(
  994. "remoteApplication.txt",
  995. "utf-8"
  996. );
  997. } catch (error) {
  998. console.log(error);
  999. window._hmt.push([
  1000. "_trackEvent",
  1001. "答题页面",
  1002. "读取remoteApplication.txt出错--0",
  1003. ]);
  1004. await new Promise((resolve2) => setTimeout(() => resolve2(), 3000));
  1005. applicationNames = fs.readFileSync(
  1006. "remoteApplication.txt",
  1007. "utf-8"
  1008. );
  1009. }
  1010. } catch (error) {
  1011. console.log(error);
  1012. // this.logger({
  1013. // currentPage: "答题页面",
  1014. // errorType: "e-01",
  1015. // error: error.message,
  1016. // detail: applicationNames,
  1017. // });
  1018. window._hmt.push([
  1019. "_trackEvent",
  1020. "答题页面",
  1021. "读取remoteApplication.txt出错",
  1022. ]);
  1023. // this.$Message.error({
  1024. // content: "系统检测出错(e-01),请退出程序后重试!",
  1025. // duration: 2 * 24 * 60 * 60,
  1026. // });
  1027. return;
  1028. }
  1029. // 为避免考试过程中卡顿,不在考试过程中同步检测远程桌面软件
  1030. // if (typeof nodeRequire !== "undefined") {
  1031. // const hasSun = nodeCheckRemoteDesktop();
  1032. // if (hasSun) {
  1033. // if (applicationNames) {
  1034. // applicationNames += ",sunloginclient";
  1035. // } else {
  1036. // applicationNames = "sunloginclient";
  1037. // }
  1038. // }
  1039. // }
  1040. if (applicationNames && applicationNames.trim()) {
  1041. let names = applicationNames
  1042. .replace("qq", "QQ")
  1043. .replace("teamviewer", "TeamViewer")
  1044. .replace("lookmypc", "LookMyPC")
  1045. .replace("xt", "协通")
  1046. .replace("winaw32", "Symantec PCAnywhere")
  1047. .replace("pcaquickconnect", "Symantec PCAnywhere")
  1048. .replace("sessioncontroller", "Symantec PCAnywhere")
  1049. .replace(/sunloginclient/gi, "向日葵")
  1050. .replace(/sunloginremote/gi, "向日葵")
  1051. .replace("wemeetapp", "腾讯会议")
  1052. .replace("wechat", "微信");
  1053. names = [...new Set(names.split(",").map((v) => v.trim()))].join(
  1054. ","
  1055. );
  1056. this.disableExamingBecauseRemoteApp = true;
  1057. this.$Message.info({
  1058. content: "在考试期间,请关掉" + names + "软件,诚信考试。",
  1059. duration: 30,
  1060. });
  1061. } else {
  1062. this.disableExamingBecauseRemoteApp = false;
  1063. }
  1064. }
  1065. //如果配置中配置了 DISABLE_REMOTE_ASSISTANCE
  1066. if (
  1067. this.QECSConfig.PREVENT_CHEATING_CONFIG.includes(
  1068. "DISABLE_REMOTE_ASSISTANCE"
  1069. )
  1070. ) {
  1071. let exe = "Project1.exe";
  1072. if (fileExists("Project2.exe")) {
  1073. const remoteAppName = REMOTE_APP_NAME;
  1074. exe = `Project2.exe "${remoteAppName}" `;
  1075. }
  1076. const fs = window.nodeRequire("fs");
  1077. try {
  1078. fs.unlinkSync("remoteApplication.txt");
  1079. } catch (error) {
  1080. console.log(error);
  1081. }
  1082. await nativeExe(exe, checkRemoteAppTxt.bind(this));
  1083. } else {
  1084. this.disableExamingBecauseRemoteApp = false;
  1085. }
  1086. },
  1087. },
  1088. };
  1089. </script>
  1090. <style scoped>
  1091. .container {
  1092. display: grid;
  1093. grid-template-areas:
  1094. "header header"
  1095. "main side";
  1096. grid-template-rows: 80px minmax(0, 1fr);
  1097. grid-template-columns: 1fr 400px;
  1098. height: 100vh;
  1099. width: 100vw;
  1100. }
  1101. .header {
  1102. display: grid;
  1103. align-items: center;
  1104. justify-items: center;
  1105. grid-template-columns: 200px 280px 1fr 300px 100px;
  1106. grid-area: header;
  1107. height: 80px;
  1108. background-color: #f5f5f5;
  1109. }
  1110. .main {
  1111. display: grid;
  1112. grid-area: main;
  1113. grid-template-rows: 1fr 50px;
  1114. }
  1115. .side {
  1116. display: grid;
  1117. grid-area: side;
  1118. grid-template-rows: 1fr;
  1119. background-color: #f5f5f5;
  1120. }
  1121. .question-nav {
  1122. overflow-y: scroll;
  1123. }
  1124. .camera {
  1125. z-index: 100;
  1126. height: 300px;
  1127. }
  1128. @media screen and (max-height: 768px) {
  1129. .container {
  1130. grid-template-rows: 50px minmax(0, 1fr);
  1131. }
  1132. .header {
  1133. height: 50px;
  1134. }
  1135. }
  1136. @media screen and (max-width: 960px) {
  1137. .header {
  1138. overflow-x: scroll;
  1139. }
  1140. }
  1141. </style>
  1142. <style>
  1143. #examing-home-question img {
  1144. max-width: 100%;
  1145. height: auto !important;
  1146. }
  1147. .hide-body-scroll {
  1148. overflow: hidden !important;
  1149. }
  1150. </style>