ExamingHome.vue 37 KB

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