ExamingHome.vue 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207
  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 examRecordDataId = this.$route.params.examRecordDataId;
  391. const examId = this.$route.params.examId;
  392. const { courseName, courseCode } = (
  393. await this.$http.post(
  394. "/api/ecs_oe_student/examControl/getCourseInfo/" + examRecordDataId,
  395. { "axios-retry": { retries: 4 }, noErrorMessage: true }
  396. )
  397. ).data;
  398. const [
  399. { data: weixinAnswerEnabled },
  400. { data: faceCheckEnabled },
  401. { data: faceLivenessEnabled },
  402. { data: examProp },
  403. { data: exam },
  404. { data: paperStruct },
  405. { data: examQuestionListOrig },
  406. ] = await Promise.all([
  407. this.$http.get(
  408. `/api/ecs_exam_work/exam/weixinAnswerEnabled/${examId}?courseCode=${courseCode}`
  409. ),
  410. this.$http.get("/api/ecs_exam_work/exam/faceCheckEnabled/" + examId),
  411. this.$http.get(
  412. "/api/ecs_exam_work/exam/identificationOfLivingEnabled/" + examId
  413. ),
  414. this.$http.get(
  415. "/api/ecs_exam_work/exam/getExamPropertyFromCacheByStudentSession/" +
  416. examId +
  417. `/SNAPSHOT_INTERVAL,PRACTICE_TYPE,FREEZE_TIME`
  418. ),
  419. this.$http.get("/api/ecs_exam_work/exam/" + examId),
  420. this.$http.get(
  421. "/api/ecs_oe_student/examRecordPaperStruct/getExamRecordPaperStruct?examRecordDataId=" +
  422. examRecordDataId
  423. ),
  424. this.$http.get("/api/ecs_oe_student/examQuestion/findExamQuestionList"),
  425. ]);
  426. this.courseName = courseName;
  427. let initFaceLivenessResult = null;
  428. if (faceLivenessEnabled) {
  429. initFaceLivenessResult = await this.initFaceLiveness();
  430. }
  431. let examQuestionList = examQuestionListOrig;
  432. if (
  433. weixinAnswerEnabled === undefined ||
  434. faceCheckEnabled === undefined ||
  435. faceLivenessEnabled === undefined ||
  436. examProp === undefined ||
  437. exam === undefined ||
  438. paperStruct === undefined ||
  439. examQuestionList === undefined ||
  440. (faceLivenessEnabled && initFaceLivenessResult === false)
  441. ) {
  442. console.log({
  443. weixinAnswerEnabled,
  444. faceCheckEnabled,
  445. faceLivenessEnabled,
  446. examProp,
  447. exam,
  448. paperStruct,
  449. examQuestionList,
  450. initFaceLivenessResult,
  451. });
  452. throw new Error("获取考试和试卷信息失败");
  453. }
  454. this.logger({
  455. action: "答题页面",
  456. detail: `end${
  457. typeof Object.fromEntries === "function" ? " " : " "
  458. }initData`,
  459. });
  460. this.logger({
  461. action: "答题页面dimension",
  462. scrollX: window.scrollX,
  463. scrollY: window.scrollY,
  464. width: window.screen.width,
  465. height: window.screen.height,
  466. screenX: window.screen.availWidth,
  467. screenY: window.screen.availHeight,
  468. clientWidth: document.documentElement.clientWidth,
  469. clientHeight: document.documentElement.clientHeight,
  470. windowInnerWidth: window.innerWidth,
  471. windowInnerHeight: window.innerHeight,
  472. windowOuterWidth: window.outerWidth,
  473. windowOuterHeight: window.outerHeight,
  474. // 是否全屏
  475. equal1:
  476. "dimesion1" +
  477. (window.screen.width === window.outerWidth &&
  478. window.screen.height === window.outerHeight),
  479. // 是否打开了调试窗口
  480. equal2:
  481. "dimesion2" +
  482. (window.innerWidth === window.outerWidth &&
  483. window.innerHeight === window.outerHeight),
  484. });
  485. this.checkMainTimeout = setTimeout(() => {
  486. if (STRICT_CHECK_HOSTS.includes(window.location.hostname)) {
  487. if (!checkMainExe()) {
  488. this.$http.post(
  489. "/api/ecs_oe_student/client/exam/process/discipline"
  490. );
  491. this.logger({
  492. action: "答题页面discipline",
  493. });
  494. }
  495. }
  496. }, 60 * 1000);
  497. exam.WEIXIN_ANSWER_ENABLED = weixinAnswerEnabled;
  498. if (faceCheckEnabled) {
  499. this.faceEnable = true;
  500. let initSnapshotTrialTimes = 0;
  501. this.initSnapInterval = setInterval(() => {
  502. const video = document.getElementById("video");
  503. const videoStartFailed =
  504. !video || video.readyState !== 4 || !video.srcObject.active;
  505. if (videoStartFailed && initSnapshotTrialTimes < 5) {
  506. initSnapshotTrialTimes++;
  507. this.logger({
  508. action: "答题页面",
  509. detail:
  510. "进入考试后60秒内抓拍-" + `(第${initSnapshotTrialTimes}次尝试)`,
  511. });
  512. } else {
  513. // 超过6次后,强行抓拍,如果抓拍不成功,则会因抓拍不成功而退出。
  514. clearInterval(this.initSnapInterval);
  515. if (videoStartFailed) {
  516. this.logger({
  517. action: "答题页面",
  518. detail: "摄像头没有正常启用-进入考试抓拍",
  519. });
  520. this.$Message.error({
  521. content: "摄像头没有正常启用",
  522. duration: 5,
  523. closable: true,
  524. });
  525. window._hmt.push([
  526. "_trackEvent",
  527. "摄像头框",
  528. "摄像头状态",
  529. "摄像头没有正常启用-进入考试抓拍",
  530. ]);
  531. this.logout("?LogoutReason=" + "摄像头没有正常启用-退出");
  532. } else {
  533. this.logger({
  534. action: "答题页面",
  535. detail:
  536. "进入考试后60秒内抓拍-" +
  537. `(第${initSnapshotTrialTimes + 1}次尝试成功)`,
  538. });
  539. this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
  540. }
  541. }
  542. }, 10 * 1000);
  543. // let initSnapshotTrialTimes = 0;
  544. // const initSnapshot = setTimeout(() => {
  545. // if (this.exam || initSnapshotTrialTimes < 6) {
  546. // this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
  547. // } else {
  548. // setTimeout(() => initSnapshot(), 5 * 1000);
  549. // }
  550. // }, 5 * 1000);
  551. if (examProp.SNAPSHOT_INTERVAL) {
  552. const SNAPSHOT_INTERVAL = JSON.parse(examProp.SNAPSHOT_INTERVAL);
  553. // 考务设置抓拍间隔
  554. this.snapInterval = setInterval(() => {
  555. this.logger({
  556. action: "答题页面",
  557. detail: "定时抓拍",
  558. SNAPSHOT_INTERVAL: examProp.SNAPSHOT_INTERVAL,
  559. });
  560. this.toggleSnapNow();
  561. }, SNAPSHOT_INTERVAL * 60 * 1000);
  562. }
  563. }
  564. if (exam.examType === "PRACTICE") {
  565. this.practiceType = examProp.PRACTICE_TYPE; // IN_PRACTICE NO_ANSWER
  566. exam.practiceType = examProp.PRACTICE_TYPE;
  567. }
  568. exam.freezeTime =
  569. examProp.FREEZE_TIME && JSON.parse(examProp.FREEZE_TIME);
  570. this.logger({
  571. page: "答题页面",
  572. examRecordDataId: this.$route.params.examRecordDataId,
  573. faceCheckEnabled: faceCheckEnabled,
  574. faceLivenessEnabled: faceLivenessEnabled,
  575. WEIXIN_ANSWER_ENABLED: exam.WEIXIN_ANSWER_ENABLED,
  576. SNAPSHOT_INTERVAL: examProp.SNAPSHOT_INTERVAL,
  577. PRACTICE_TYPE: examProp.PRACTICE_TYPE,
  578. FREEZE_TIME: examProp.FREEZE_TIME,
  579. });
  580. // parentQuestionBody
  581. // questionUnitWrapperList
  582. // questionBody => from examQuestionList
  583. // questionUnitList =>
  584. // studentAnswer
  585. // rightAnswer
  586. // init subNumber
  587. let questionId = null;
  588. let i = 1;
  589. examQuestionList = examQuestionList.map((eq) => {
  590. if (questionId == eq.questionId) {
  591. eq.subNumber = i++;
  592. } else {
  593. i = 1;
  594. questionId = eq.questionId;
  595. eq.subNumber = i++;
  596. }
  597. return eq;
  598. });
  599. let groupOrder = 1;
  600. let mainNumber = 0;
  601. examQuestionList = examQuestionList.map((eq) => {
  602. if (mainNumber == eq.mainNumber) {
  603. eq.groupOrder = groupOrder++;
  604. } else {
  605. mainNumber = eq.mainNumber;
  606. groupOrder = 1;
  607. eq.groupOrder = groupOrder++;
  608. }
  609. const questionWrapperList =
  610. paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1]
  611. .questionWrapperList;
  612. const groupName =
  613. paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1]
  614. .groupName;
  615. const groupTotal = questionWrapperList.reduce(
  616. (accumulator, questionWrapper) =>
  617. accumulator + questionWrapper.questionUnitWrapperList.length,
  618. 0
  619. );
  620. eq.groupName = groupName;
  621. eq.groupTotal = groupTotal;
  622. return eq;
  623. });
  624. examQuestionList = examQuestionList.map((eq) => {
  625. const paperStructQuestion = paperStruct.defaultPaper.questionGroupList[
  626. eq.mainNumber - 1
  627. ].questionWrapperList.find((q) => q.questionId === eq.questionId);
  628. return Object.assign(eq, {
  629. limitedPlayTimes: paperStructQuestion.limitedPlayTimes,
  630. });
  631. });
  632. this.updateExamState({
  633. exam: exam,
  634. paperStruct: paperStruct,
  635. examQuestionList: examQuestionList,
  636. allAudioPlayTimes: JSON.parse(examQuestionList[0].audioPlayTimes) || [],
  637. questionAnswerFileUrl: [],
  638. pictureAnswer: {},
  639. });
  640. // console.log(examQuestionList);
  641. // console.log(examQuestionList.find(v => v.answerType === "SINGLE_AUDIO"));
  642. const shouldOpenWS = exam.WEIXIN_ANSWER_ENABLED;
  643. if (shouldOpenWS) {
  644. // console.log("have single");
  645. const examRecordDataId = this.$route.params.examRecordDataId;
  646. openWS({ examRecordDataId });
  647. }
  648. },
  649. updateQuestion: async function (next) {
  650. // 初始化套题的答案,为回填部分选项做准备
  651. // for (let q of this.examQuestionList) {
  652. // if (q.subQuestionList.length > 0) {
  653. // q.studentAnswer = [];
  654. // for (let sq of q.subQuestionList) {
  655. // q.studentAnswer.push(sq.studentAnswer);
  656. // }
  657. // }
  658. // }
  659. next && next();
  660. if (!this.exam) return;
  661. },
  662. async initFaceLiveness() {
  663. let faceVerifyMinute = null;
  664. let identificationOfLivingBodyScheme = null;
  665. {
  666. const examRecordDataId = this.$route.params.examRecordDataId;
  667. for (let i = 0; i < 100; i++) {
  668. try {
  669. const faceBiopsyBaseInfoData = await this.$http.get(
  670. "/api/ecs_oe_student/faceBiopsy/getFaceBiopsyBaseInfo?examRecordDataId=" +
  671. examRecordDataId
  672. );
  673. console.log(faceBiopsyBaseInfoData.data);
  674. faceVerifyMinute = faceBiopsyBaseInfoData.data.faceVerifyMinute;
  675. identificationOfLivingBodyScheme =
  676. faceBiopsyBaseInfoData.data.identificationOfLivingBodyScheme;
  677. break;
  678. } catch (error) {
  679. console.log(error);
  680. if (!error.response) {
  681. await new Promise((resolve) => setTimeout(resolve, 1000));
  682. continue; // 网络不通
  683. } else {
  684. break;
  685. }
  686. }
  687. }
  688. }
  689. if (identificationOfLivingBodyScheme === null) {
  690. return false;
  691. }
  692. // 仅在线上使用活体检测
  693. // if (process.env.NODE_ENV === "production" && faceVerifyMinute) {
  694. if (faceVerifyMinute) {
  695. // 第二次开启活检时肯定有 this.remainTime 了。注意断点续考时没有这项检查
  696. this.$nextTick(async () => {
  697. await new Promise((r) =>
  698. setTimeout(() => {
  699. r();
  700. }, 10 * 1000)
  701. );
  702. console.log("活检定时");
  703. this.logger({ action: "活检定时", detail: faceVerifyMinute });
  704. const enoughTimeForFaceId = this.remainTime // 如果remainTime取到了的话
  705. ? this.remainTime / (60 * 1000) - 1 > faceVerifyMinute
  706. : true;
  707. if (!enoughTimeForFaceId) return;
  708. this.faceIdMsgTimeout = setTimeout(() => {
  709. this.logger({
  710. action: "答题页面",
  711. detail: "活体检测前抓拍",
  712. });
  713. this.toggleSnapNow();
  714. this.$Message.info({
  715. content: "30秒后开始指定动作检测",
  716. duration: 15,
  717. closable: true,
  718. });
  719. }, faceVerifyMinute * 60 * 1000 - 30 * 1000); // 活体检测提醒
  720. this.faceIdDivTimeout = setTimeout(() => {
  721. if (identificationOfLivingBodyScheme === "S1") {
  722. this.showFaceId = true;
  723. } else if (identificationOfLivingBodyScheme === "S2") {
  724. this.showFaceMotion = true;
  725. }
  726. }, faceVerifyMinute * 60 * 1000); // 定时做活体检测
  727. // }, 1 * 1000); // 定时做活体检测
  728. });
  729. }
  730. // for test
  731. // setTimeout(() => {
  732. // this.showFaceId = true;
  733. // // this.$Modal.remove();
  734. // // }, this.$route.query.faceVerifyMinute * 60 * 1000); // 定时做活体检测
  735. // }, 5 * 1000); // 定时做活体检测
  736. return true;
  737. },
  738. closeFaceId() {
  739. this.showFaceId = false;
  740. },
  741. async closeFaceMotion(faceLiveResult) {
  742. this.showFaceMotion = false;
  743. console.log(faceLiveResult);
  744. if (faceLiveResult.endExam) {
  745. this.logout("?LogoutReason=活检后台交卷");
  746. } else if (faceLiveResult.needNextVerify) {
  747. const initFaceLivenessResult = await this.initFaceLiveness();
  748. if (initFaceLivenessResult === false) {
  749. this.logger({
  750. action: "答题页面",
  751. detail: "活检接口再次获取失败-退出",
  752. });
  753. window._hmt.push([
  754. "_trackEvent",
  755. "答题页面",
  756. "活检接口再次获取失败-退出",
  757. ]);
  758. this.$Message.error({
  759. content: "获取考试和试卷信息失败,退出登录",
  760. duration: 15,
  761. closable: true,
  762. });
  763. this.logout("?LogoutReason=活检接口再次获取失败-退出");
  764. }
  765. }
  766. },
  767. async answerAllQuestions(ignoreDirty) {
  768. const answers = this.examQuestionList
  769. .filter((eq) => (ignoreDirty ? true : eq.dirty))
  770. .filter((eq) => eq.getQuestionContent)
  771. .map((eq) => {
  772. return Object.assign(
  773. {
  774. order: eq.order,
  775. studentAnswer: eq.studentAnswer,
  776. },
  777. eq.audioPlayTimes && { audioPlayTimes: eq.audioPlayTimes },
  778. eq.isSign && { isSign: eq.isSign }
  779. );
  780. });
  781. if (answers.length > 0) {
  782. try {
  783. await this.$http.post(
  784. "/api/ecs_oe_student/examQuestion/submitQuestionAnswer",
  785. answers
  786. );
  787. this.resetExamQuestionDirty();
  788. // 提交成功,返回true,供最后提交时判断。自动提交失败,不暂停。
  789. return true;
  790. } catch (error) {
  791. console.log(error);
  792. this.logger({
  793. action: "提交答案失败",
  794. errorJSON: JSON.stringify(error, (key, value) =>
  795. key === "token" ? "" : value
  796. ),
  797. errorName: error.name,
  798. errorMessage: error.message,
  799. errorStack: error.stack,
  800. });
  801. this.$Message.error({
  802. content: "提交答案失败",
  803. duration: 15,
  804. closable: true,
  805. });
  806. window._hmt.push([
  807. "_trackEvent",
  808. "答题页面",
  809. "提交答案失败",
  810. error.message +
  811. " |||| " +
  812. (((error.response || {}).data || {}).desc || ""),
  813. ]);
  814. }
  815. }
  816. },
  817. async submitPaper() {
  818. this.logger({ action: "学生点击交卷" });
  819. try {
  820. // 交卷前强制提交所有答案
  821. const ret = await this.answerAllQuestions(true);
  822. if (!ret) {
  823. // 提交答案失败,停止交卷逻辑。
  824. return;
  825. }
  826. } catch (error) {
  827. return;
  828. }
  829. if (
  830. this.exam.freezeTime &&
  831. this.remainTime >
  832. (this.exam.duration - this.exam.freezeTime) * 60 * 1000
  833. ) {
  834. this.$Message.info({
  835. content: `考试开始${this.exam.freezeTime}分钟后才允许交卷。`,
  836. duration: 5,
  837. closable: true,
  838. });
  839. return;
  840. }
  841. const answered = this.examQuestionList.filter(
  842. (q) => q.studentAnswer !== null
  843. ).length;
  844. const unanswered = this.examQuestionList.filter(
  845. (q) => q.studentAnswer === null
  846. ).length;
  847. const signed = this.examQuestionList.filter((q) => q.isSign).length;
  848. const showConfirmTime = Date.now();
  849. this.$Modal.confirm({
  850. title: "确认交卷",
  851. content: `<p>已答题目:${answered}</p><p>未答题目:${unanswered}</p><p>标记题目:${signed}</p>`,
  852. onOk: () => {
  853. this.realSubmitPaper(showConfirmTime);
  854. },
  855. });
  856. },
  857. async realSubmitPaper(showConfirmTime = 0) {
  858. this.__submitPaperStartTime = Date.now();
  859. this.$Spin.show({
  860. render: () => {
  861. return <div style="font-size: 44px">正在交卷,请耐心等待...</div>;
  862. },
  863. });
  864. this.logger({ action: "正在交卷,请耐心等待..." });
  865. if (this.faceEnable) {
  866. this.logger({ action: "交卷前抓拍" });
  867. this.toggleSnapNow();
  868. }
  869. // 确保抓拍指令在交卷前执行,同时确保5秒间隔提交答案的指令执行了
  870. let delay = 5 - (Date.now() - showConfirmTime) / 1000;
  871. if (delay < 0) {
  872. // 如果用户已经看确认框超过5秒,或者不是由确认框进来的,不延迟
  873. delay = 0;
  874. }
  875. // 给抓拍照片多一秒处理时间
  876. delay = delay + 1;
  877. // 和下行注释的sleep语句不是一样的。sleep之后还可以执行。加上clearTimeout则可拒绝。
  878. // await new Promise(resolve => setTimeout(() => resolve(), delay * 1000));
  879. setTimeout(() => this.realSubmitPaperStep2(), delay * 1000);
  880. this.submitCount = 1;
  881. },
  882. async realSubmitPaperStep2() {
  883. if (this.snapProcessingCount > 0) {
  884. this.submitCount++;
  885. if (this.submitCount < 200) {
  886. // 一分钟后,强制交卷
  887. console.log("一分钟后,强制交卷");
  888. setTimeout(() => this.realSubmitPaperStep2(), 300);
  889. return;
  890. }
  891. }
  892. if (this.submitLock) {
  893. return;
  894. } else {
  895. this.submitLock = true;
  896. }
  897. if (this.$route.name !== "OnlineExamingHome") {
  898. // 非考试页,不再交卷
  899. this.$Spin.hide();
  900. return;
  901. }
  902. try {
  903. const examId = this.$route.params.examId;
  904. const examRecordDataId = this.$route.params.examRecordDataId;
  905. const res = await this.$http.get(
  906. "/api/ecs_oe_student/examControl/endExam"
  907. );
  908. if (res.status === 200) {
  909. this.$router.replace({
  910. path: `/online-exam/exam/${examId}/examRecordData/${examRecordDataId}/end`,
  911. });
  912. // 确保交卷成功后,不会再次交卷
  913. this.submitLock = true;
  914. this.$Spin.hide();
  915. this.__submitPaperEndTime = Date.now();
  916. window._hmt.push([
  917. "_trackEvent",
  918. "交卷耗时",
  919. Number(
  920. (this.__submitPaperEndTime - this.__submitPaperStartTime) / 1000
  921. ).toPrecision(1) + "秒",
  922. ]);
  923. this.logger({
  924. action: "交卷成功",
  925. cost: this.__submitPaperEndTime - this.__submitPaperStartTime,
  926. UA: navigator.userAgent,
  927. });
  928. return;
  929. } else {
  930. this.$Message.error({
  931. content: "交卷失败",
  932. duration: 15,
  933. closable: true,
  934. });
  935. this.logger({
  936. action: "交卷失败",
  937. detail: "endExam response status is not 200",
  938. });
  939. }
  940. this.submitLock = false;
  941. } catch (e) {
  942. this.$Message.error({
  943. content: "交卷失败",
  944. duration: 15,
  945. closable: true,
  946. });
  947. console.log(e);
  948. this.logger({
  949. action: "交卷失败",
  950. errorJSON: JSON.stringify(e, (key, value) =>
  951. key === "token" ? "" : value
  952. ),
  953. errorName: e.name,
  954. errorMessage: e.message,
  955. errorStack: e.stack,
  956. });
  957. }
  958. this.submitLock = false;
  959. this.$Spin.hide();
  960. },
  961. examQuestion() {
  962. return (
  963. this.examQuestionList &&
  964. this.examQuestionList.find(
  965. (eq) => eq.order == this.$route.params.order // number == string
  966. )
  967. );
  968. },
  969. reloadPage() {
  970. window._hmt.push(["_trackEvent", "答题页面", "页面加载失败", "reload"]);
  971. this.logger({ page: "答题页面", button: "重试按钮", action: "点击" });
  972. window.location.reload();
  973. },
  974. async checkRemoteAppClicked() {
  975. this.logger({
  976. page: "答题页面",
  977. button: "确认已关闭远程桌面软件",
  978. action: "点击",
  979. });
  980. this.checkRemoteApp();
  981. },
  982. async checkRemoteApp() {
  983. if (typeof nodeRequire == "undefined") {
  984. return;
  985. }
  986. async function checkRemoteAppTxt() {
  987. let applicationNames;
  988. try {
  989. const fs = window.nodeRequire("fs");
  990. try {
  991. applicationNames = fs.readFileSync(
  992. "remoteApplication.txt",
  993. "utf-8"
  994. );
  995. } catch (error) {
  996. console.log(error);
  997. window._hmt.push([
  998. "_trackEvent",
  999. "答题页面",
  1000. "读取remoteApplication.txt出错--0",
  1001. ]);
  1002. await new Promise((resolve2) => setTimeout(() => resolve2(), 3000));
  1003. applicationNames = fs.readFileSync(
  1004. "remoteApplication.txt",
  1005. "utf-8"
  1006. );
  1007. }
  1008. } catch (error) {
  1009. console.log(error);
  1010. // this.logger({
  1011. // currentPage: "答题页面",
  1012. // errorType: "e-01",
  1013. // error: error.message,
  1014. // detail: applicationNames,
  1015. // });
  1016. window._hmt.push([
  1017. "_trackEvent",
  1018. "答题页面",
  1019. "读取remoteApplication.txt出错",
  1020. ]);
  1021. // this.$Message.error({
  1022. // content: "系统检测出错(e-01),请退出程序后重试!",
  1023. // duration: 2 * 24 * 60 * 60,
  1024. // });
  1025. return;
  1026. }
  1027. // 为避免考试过程中卡顿,不在考试过程中同步检测远程桌面软件
  1028. // if (typeof nodeRequire !== "undefined") {
  1029. // const hasSun = nodeCheckRemoteDesktop();
  1030. // if (hasSun) {
  1031. // if (applicationNames) {
  1032. // applicationNames += ",sunloginclient";
  1033. // } else {
  1034. // applicationNames = "sunloginclient";
  1035. // }
  1036. // }
  1037. // }
  1038. if (applicationNames && applicationNames.trim()) {
  1039. let names = applicationNames
  1040. .replace("qq", "QQ")
  1041. .replace("teamviewer", "TeamViewer")
  1042. .replace("lookmypc", "LookMyPC")
  1043. .replace("xt", "协通")
  1044. .replace("winaw32", "Symantec PCAnywhere")
  1045. .replace("pcaquickconnect", "Symantec PCAnywhere")
  1046. .replace("sessioncontroller", "Symantec PCAnywhere")
  1047. .replace(/sunloginclient/gi, "向日葵")
  1048. .replace(/sunloginremote/gi, "向日葵")
  1049. .replace("wemeetapp", "腾讯会议")
  1050. .replace("wechat", "微信")
  1051. .replace("todesk", "ToDesk");
  1052. names = [...new Set(names.split(",").map((v) => v.trim()))].join(
  1053. ","
  1054. );
  1055. this.disableExamingBecauseRemoteApp = true;
  1056. this.$Message.info({
  1057. content: "在考试期间,请关掉" + names + "软件,诚信考试。",
  1058. duration: 30,
  1059. });
  1060. } else {
  1061. this.disableExamingBecauseRemoteApp = false;
  1062. }
  1063. }
  1064. //如果配置中配置了 DISABLE_REMOTE_ASSISTANCE
  1065. if (
  1066. this.QECSConfig.PREVENT_CHEATING_CONFIG.includes(
  1067. "DISABLE_REMOTE_ASSISTANCE"
  1068. )
  1069. ) {
  1070. let exe = "Project1.exe";
  1071. if (fileExists("Project2.exe")) {
  1072. const remoteAppName = REMOTE_APP_NAME;
  1073. exe = `Project2.exe "${remoteAppName}" `;
  1074. }
  1075. const fs = window.nodeRequire("fs");
  1076. try {
  1077. fs.unlinkSync("remoteApplication.txt");
  1078. } catch (error) {
  1079. console.log(error);
  1080. }
  1081. await nativeExe(exe, checkRemoteAppTxt.bind(this));
  1082. } else {
  1083. this.disableExamingBecauseRemoteApp = false;
  1084. }
  1085. },
  1086. },
  1087. };
  1088. </script>
  1089. <style scoped>
  1090. .container {
  1091. display: grid;
  1092. grid-template-areas:
  1093. "header header"
  1094. "main side";
  1095. grid-template-rows: 80px minmax(0, 1fr);
  1096. grid-template-columns: 1fr 400px;
  1097. height: 100vh;
  1098. width: 100vw;
  1099. }
  1100. .header {
  1101. display: grid;
  1102. align-items: center;
  1103. justify-items: center;
  1104. grid-template-columns: 200px 280px 1fr 300px 100px;
  1105. grid-area: header;
  1106. height: 80px;
  1107. background-color: #f5f5f5;
  1108. }
  1109. .main {
  1110. display: grid;
  1111. grid-area: main;
  1112. grid-template-rows: 1fr 50px;
  1113. }
  1114. .side {
  1115. display: grid;
  1116. grid-area: side;
  1117. grid-template-rows: 1fr;
  1118. background-color: #f5f5f5;
  1119. }
  1120. .question-nav {
  1121. overflow-y: scroll;
  1122. }
  1123. .camera {
  1124. z-index: 100;
  1125. height: 300px;
  1126. }
  1127. @media screen and (max-height: 768px) {
  1128. .container {
  1129. grid-template-rows: 50px minmax(0, 1fr);
  1130. }
  1131. .header {
  1132. height: 50px;
  1133. }
  1134. }
  1135. @media screen and (max-width: 960px) {
  1136. .header {
  1137. overflow-x: scroll;
  1138. }
  1139. }
  1140. </style>
  1141. <style>
  1142. #examing-home-question img {
  1143. max-width: 100%;
  1144. height: auto !important;
  1145. }
  1146. .hide-body-scroll {
  1147. overflow: hidden !important;
  1148. }
  1149. </style>