ExamingHome.vue 37 KB

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