ExamingHome.vue 37 KB

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