ExamingHome.vue 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874
  1. <template>
  2. <div v-if="exam && examQuestion()" :key="exam.id" class="container">
  3. <div class="header">
  4. <RemainTime></RemainTime>
  5. <OverallProgress :exam-question-list="examQuestionList"></OverallProgress>
  6. <div>
  7. {{ this.$store.state.user.displayName }} -&nbsp;
  8. {{ this.$store.state.user.studentCodeList.join(",") }}
  9. </div>
  10. <QuestionFilters :exam-question-list="examQuestionList"></QuestionFilters>
  11. <i-button class="qm-primary-button" @click="submitPaper">交卷</i-button>
  12. </div>
  13. <div id="examing-home-question" class="main">
  14. <QuestionView :exam-question="examQuestion()"></QuestionView>
  15. <ArrowNavView
  16. :previous-question-order="previousQuestionOrder"
  17. :next-question-order="nextQuestionOrder"
  18. ></ArrowNavView>
  19. </div>
  20. <div :class="['side']">
  21. <div :class="['question-nav']">
  22. <QuestionNavView :paper-struct="paperStruct" />
  23. </div>
  24. <div
  25. v-if="faceEnable && startVideoAfterDelay"
  26. class="camera"
  27. :style="{ display: showFaceMotion ? 'none' : 'block' }"
  28. >
  29. <FaceRecognition
  30. v-if="faceEnable"
  31. width="400"
  32. height="300"
  33. :show-recognize-button="false"
  34. />
  35. </div>
  36. <div v-if="faceEnable && !startVideoAfterDelay" class="camera">
  37. <div
  38. style="width: 400px; height: 300px; border: 1px solid lightgrey; display: flex; align-items: center; justify-content: center;"
  39. >
  40. <div>
  41. 正在打开摄像头...
  42. </div>
  43. </div>
  44. </div>
  45. </div>
  46. <Modal
  47. v-model="showFaceId"
  48. :mask-closable="false"
  49. :closable="false"
  50. width="800"
  51. :styles="{ top: '10px' }"
  52. >
  53. <FaceId v-if="showFaceId" @closeFaceId="closeFaceId" />
  54. <p slot="footer"></p>
  55. </Modal>
  56. <Modal
  57. v-model="showFaceMotion"
  58. :mask-closable="false"
  59. :closable="false"
  60. width="800"
  61. :styles="{ top: '10px' }"
  62. >
  63. <FaceMotion v-if="showFaceMotion" @closeFaceMotion="closeFaceMotion" />
  64. <p slot="footer"></p>
  65. </Modal>
  66. <FaceTracking v-if="faceEnable && startVideoAfterDelay && PRODUCTION" />
  67. </div>
  68. <div v-else>
  69. 正在等待数据返回...
  70. <i-button v-if="timeouted" class="qm-primary-button" @click="reloadPage">
  71. 重试
  72. </i-button>
  73. </div>
  74. </template>
  75. <script>
  76. import RemainTime from "./RemainTime.vue";
  77. import OverallProgress from "./OverallProgress.vue";
  78. import QuestionFilters from "./QuestionFilters.vue";
  79. import QuestionView from "./QuestionView.vue";
  80. import ArrowNavView from "./ArrowNavView.vue";
  81. import QuestionNavView from "./QuestionNavView.vue";
  82. import FaceTracking from "./FaceTracking.vue";
  83. import FaceId from "./FaceId.vue";
  84. import FaceMotion from "./FaceMotion/FaceMotion";
  85. import FaceRecognition from "../../../components/FaceRecognition/FaceRecognition";
  86. import { openWS, closeWsWithoutReconnect } from "./ws.js";
  87. import { createNamespacedHelpers } from "vuex";
  88. const { mapState, mapMutations } = createNamespacedHelpers("examingHomeModule");
  89. export default {
  90. name: "ExamingHome",
  91. components: {
  92. RemainTime,
  93. OverallProgress,
  94. QuestionFilters,
  95. QuestionView,
  96. ArrowNavView,
  97. QuestionNavView,
  98. FaceRecognition,
  99. FaceId,
  100. FaceMotion,
  101. FaceTracking,
  102. },
  103. data() {
  104. return {
  105. showFaceId: false,
  106. showFaceMotion: false,
  107. faceEnable: false,
  108. timeouted: false,
  109. startVideoAfterDelay: false,
  110. PRODUCTION: process.env.NODE_ENV === "production",
  111. };
  112. },
  113. computed: {
  114. ...mapState([
  115. "exam",
  116. "paperStruct",
  117. "examQuestionList",
  118. "snapNow",
  119. "snapProcessingCount",
  120. "shouldSubmitPaper",
  121. "remainTime",
  122. "questionAnswerFileUrl",
  123. "uploadModalVisible",
  124. ]),
  125. previousQuestionOrder: vm => {
  126. if (vm.examQuestion().order > 1) {
  127. return vm.examQuestion().order - 1;
  128. } else {
  129. return null;
  130. }
  131. },
  132. nextQuestionOrder: vm => {
  133. if (vm.examQuestion().order < vm.examQuestionList.length) {
  134. return vm.examQuestion().order + 1;
  135. } else {
  136. return null;
  137. }
  138. },
  139. },
  140. watch: {
  141. $route: function() {
  142. this.examQuestion();
  143. },
  144. shouldSubmitPaper() {
  145. this.realSubmitPaper();
  146. },
  147. questionAnswerFileUrl(value) {
  148. // console.log(this.examQuestion.studentAnswer);
  149. // console.log("watch", value);
  150. const examRecordDataId = this.$route.params.examRecordDataId;
  151. const that = this;
  152. for (const q of value) {
  153. if (!q.saved) {
  154. let acknowledgeStatus = "CONFIRMED";
  155. // 目前只针对音频题有丢弃的可能
  156. if (
  157. q.transferFileType === "PIC" &&
  158. (q.order != this.$route.params.order || !this.uploadModalVisible)
  159. ) {
  160. acknowledgeStatus = "DISCARDED";
  161. }
  162. this.$http
  163. .post(
  164. "/api/ecs_oe_student/examControl/saveUploadedFileAcknowledgeStatus",
  165. {
  166. examRecordDataId,
  167. filePath: q.fileUrl,
  168. order: q.order,
  169. acknowledgeStatus,
  170. }
  171. )
  172. .then(() => {
  173. if (q.transferFileType === "AUDIO") {
  174. that.updateExamQuestion({
  175. order: q.order,
  176. studentAnswer: q.fileUrl,
  177. });
  178. } else if (
  179. acknowledgeStatus === "CONFIRMED" &&
  180. q.transferFileType === "PIC"
  181. ) {
  182. that.updatePicture(q);
  183. }
  184. q.saved = true;
  185. if (acknowledgeStatus === "CONFIRMED")
  186. this.$Message.info({
  187. content: "小程序作答已更新",
  188. duration: 5,
  189. closable: true,
  190. });
  191. })
  192. .catch(() => {
  193. this.$Message.error({
  194. content: "更新小程序答案失败!",
  195. duration: 15,
  196. closable: true,
  197. });
  198. });
  199. }
  200. }
  201. },
  202. // examQuestionList(val, oldVal) {
  203. // // console.log(val, oldVal);
  204. // }
  205. },
  206. async created() {
  207. this.timeoutTimeout = setTimeout(() => (this.timeouted = true), 30 * 1000);
  208. try {
  209. await this.initData();
  210. } catch (error) {
  211. this.$Message.error({
  212. content: "获取考试和试卷信息失败,退出登录",
  213. duration: 15,
  214. closable: true,
  215. });
  216. this.logout("?LogoutReason=获取考试和试卷信息失败");
  217. return;
  218. }
  219. this.submitInterval = setInterval(
  220. () => this.answerAllQuestions(),
  221. 5 * 1000 // 10秒检查是否有更改需要提交答案
  222. );
  223. },
  224. async mounted() {
  225. // iview bug: https://github.com/iview/iview/issues/4061
  226. // document.body.style = "";
  227. // 避免macos上下塘动。避免产生滚动条。
  228. document.body.classList.toggle("hide-body-scroll", true);
  229. // NotAllowedError: play() failed because the user didn't interact with the document first. https://goo.gl/xX8pDD
  230. this.startVideoAfterDelayTimeout = setTimeout(() => {
  231. this.startVideoAfterDelay = true;
  232. }, 10 * 1000);
  233. window._hmt.push(["_trackEvent", "正在考试页面", "进入页面"]);
  234. if (typeof nodeRequire != "undefined") {
  235. try {
  236. var fs = window.nodeRequire("fs");
  237. if (fs.existsSync("multiCamera.exe")) {
  238. await new Promise((resolve, reject) => {
  239. window.nodeRequire("node-cmd").get("multiCamera.exe", () => {
  240. try {
  241. let cameraInfos = fs.readFileSync("CameraInfo.txt", "utf-8");
  242. if (cameraInfos && cameraInfos.trim()) {
  243. cameraInfos = cameraInfos.trim();
  244. cameraInfos = cameraInfos.replace(/\r\n/g, "");
  245. cameraInfos = cameraInfos.replace(/\n/g, "");
  246. console.log(cameraInfos);
  247. this.serverLog("debug/S-001001", cameraInfos);
  248. }
  249. resolve();
  250. } catch (error) {
  251. reject("读取摄像头列表失败");
  252. }
  253. });
  254. });
  255. }
  256. } catch (error) {
  257. console.log(error);
  258. }
  259. }
  260. },
  261. beforeRouteUpdate(from, to, next) {
  262. window._hmt.push(["_trackEvent", "正在考试页面", "题目切换"]);
  263. if (process.env.NODE_ENV === "development") {
  264. console.log("beforeRouteUpdate from: " + this.$route.fullPath);
  265. }
  266. this.answerAllQuestions();
  267. next();
  268. },
  269. beforeDestroy() {
  270. clearTimeout(this.timeoutTimeout);
  271. clearInterval(this.submitInterval);
  272. clearInterval(this.initSnapInterval);
  273. clearInterval(this.snapInterval);
  274. clearTimeout(this.faceIdMsgTimeout);
  275. clearTimeout(this.faceIdDivTimeout);
  276. clearTimeout(this.startVideoAfterDelayTimeout);
  277. closeWsWithoutReconnect();
  278. this.updateExamState({
  279. exam: null,
  280. paperStruct: null,
  281. examQuestionList: null,
  282. questionAnswerFileUrl: [],
  283. pictureAnswer: {},
  284. snapNow: false,
  285. snapProcessingCount: 0,
  286. });
  287. this.$Modal.remove();
  288. // 避免macos上下塘动。避免产生滚动条。
  289. document.body.classList.toggle("hide-body-scroll", false);
  290. },
  291. // beforeRouteUpdate(to, from, next) {
  292. // this.updateQuestion(next);
  293. // },
  294. methods: {
  295. ...mapMutations([
  296. "updateExamState",
  297. "updateExamQuestion",
  298. "toggleSnapNow",
  299. "updateExamResult",
  300. "resetExamQuestionDirty",
  301. "updatePicture",
  302. ]),
  303. async initData() {
  304. const [
  305. weixinAnswerEnabledData,
  306. faceCheckEnabledData,
  307. examPropData,
  308. examData,
  309. paperStructData,
  310. examQuestionListData,
  311. ] = await Promise.all([
  312. this.$http.get(
  313. "/api/ecs_exam_work/exam/weixinAnswerEnabled/" +
  314. this.$route.params.examId
  315. ),
  316. this.$http.get(
  317. "/api/ecs_exam_work/exam/faceCheckEnabled/" +
  318. this.$route.params.examId
  319. ),
  320. this.$http.get(
  321. "/api/ecs_exam_work/exam/getExamPropertyFromCacheByStudentSession/" +
  322. this.$route.params.examId +
  323. `/SNAPSHOT_INTERVAL,PRACTICE_TYPE,FREEZE_TIME`
  324. ),
  325. this.$http.get("/api/ecs_exam_work/exam/" + this.$route.params.examId),
  326. this.$http.get(
  327. "/api/ecs_oe_student/examRecordPaperStruct/getExamRecordPaperStruct?examRecordDataId=" +
  328. this.$route.params.examRecordDataId
  329. ),
  330. this.$http.get("/api/ecs_oe_student/examQuestion/findExamQuestionList"),
  331. ]);
  332. // console.log(weixinAnswerEnabledData, examPropData);
  333. const [
  334. weixinAnswerEnabled,
  335. faceCheckEnabled,
  336. examProp,
  337. exam,
  338. paperStruct,
  339. ] = [
  340. weixinAnswerEnabledData.data,
  341. faceCheckEnabledData.data,
  342. examPropData.data,
  343. examData.data,
  344. paperStructData.data,
  345. ];
  346. this.initFaceLiveness();
  347. let examQuestionList = examQuestionListData.data;
  348. if (
  349. weixinAnswerEnabled === undefined ||
  350. faceCheckEnabled === undefined ||
  351. examProp === undefined ||
  352. exam === undefined ||
  353. paperStruct === undefined ||
  354. examQuestionListData === undefined
  355. ) {
  356. this.$Message.error({
  357. content: "获取考试和试卷信息失败",
  358. duration: 15,
  359. closable: true,
  360. });
  361. this.logout("?LogoutReason=获取考试和试卷信息失败");
  362. return;
  363. }
  364. exam.WEIXIN_ANSWER_ENABLED = weixinAnswerEnabled;
  365. if (faceCheckEnabled) {
  366. this.faceEnable = true;
  367. // setTimeout(() => {
  368. // this.serverLog("debug/S-002001", "进入考试后60秒内抓拍");
  369. // this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
  370. // }, 60 * 1000); // 60内秒钟后抓拍
  371. let initSnapshotTrialTimes = 0;
  372. this.initSnapInterval = setInterval(() => {
  373. const video = document.getElementById("video");
  374. const videoStartFailed =
  375. !video || video.readyState !== 4 || !video.srcObject.active;
  376. if (videoStartFailed && initSnapshotTrialTimes < 5) {
  377. initSnapshotTrialTimes++;
  378. } else {
  379. // 超过6次后,强行抓拍,如果抓拍不成功,则会因抓拍不成功而退出。
  380. clearInterval(this.initSnapInterval);
  381. if (videoStartFailed) {
  382. this.$Message.error({
  383. content: "摄像头没有正常启用",
  384. duration: 5,
  385. closable: true,
  386. });
  387. window._hmt.push([
  388. "_trackEvent",
  389. "摄像头框",
  390. "摄像头状态",
  391. "摄像头没有正常启用-进入考试抓拍",
  392. ]);
  393. this.logout("?LogoutReason=" + "摄像头没有正常启用-退出");
  394. } else {
  395. this.serverLog(
  396. "debug/S-002001",
  397. "进入考试后60秒内抓拍-" +
  398. `(第${initSnapshotTrialTimes + 1}次尝试)`
  399. );
  400. this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
  401. }
  402. }
  403. }, 10 * 1000);
  404. // let initSnapshotTrialTimes = 0;
  405. // const initSnapshot = setTimeout(() => {
  406. // if (this.exam || initSnapshotTrialTimes < 6) {
  407. // this.serverLog("debug/S-002001", "进入考试后60秒内抓拍");
  408. // this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
  409. // } else {
  410. // setTimeout(() => initSnapshot(), 5 * 1000);
  411. // }
  412. // }, 5 * 1000); // 60内秒钟后抓拍
  413. if (examProp.SNAPSHOT_INTERVAL) {
  414. const SNAPSHOT_INTERVAL = JSON.parse(examProp.SNAPSHOT_INTERVAL);
  415. // 考务设置抓拍间隔
  416. this.snapInterval = setInterval(() => {
  417. // this.serverLog(
  418. // "debug/S-002001",
  419. // "根据抓拍间隔抓拍:抓拍间隔=" + SNAPSHOT_INTERVAL + "分钟"
  420. // );
  421. this.toggleSnapNow();
  422. }, SNAPSHOT_INTERVAL * 60 * 1000);
  423. }
  424. }
  425. if (exam.examType === "PRACTICE") {
  426. this.practiceType = examProp.PRACTICE_TYPE; // IN_PRACTICE NO_ANSWER
  427. exam.practiceType = examProp.PRACTICE_TYPE;
  428. }
  429. exam.freezeTime =
  430. examProp.FREEZE_TIME && JSON.parse(examProp.FREEZE_TIME);
  431. // parentQuestionBody
  432. // questionUnitWrapperList
  433. // questionBody => from examQuestionList
  434. // questionUnitList =>
  435. // studentAnswer
  436. // rightAnswer
  437. // init subNumber
  438. let questionId = null;
  439. let i = 1;
  440. examQuestionList = examQuestionList.map(eq => {
  441. if (questionId == eq.questionId) {
  442. eq.subNumber = i++;
  443. } else {
  444. i = 1;
  445. questionId = eq.questionId;
  446. eq.subNumber = i++;
  447. }
  448. return eq;
  449. });
  450. let groupOrder = 1;
  451. let mainNumber = 0;
  452. examQuestionList = examQuestionList.map(eq => {
  453. if (mainNumber == eq.mainNumber) {
  454. eq.groupOrder = groupOrder++;
  455. } else {
  456. mainNumber = eq.mainNumber;
  457. groupOrder = 1;
  458. eq.groupOrder = groupOrder++;
  459. }
  460. const questionWrapperList =
  461. paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1]
  462. .questionWrapperList;
  463. const groupName =
  464. paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1]
  465. .groupName;
  466. const groupTotal = questionWrapperList.reduce(
  467. (accumulator, questionWrapper) =>
  468. accumulator + questionWrapper.questionUnitWrapperList.length,
  469. 0
  470. );
  471. eq.groupName = groupName;
  472. eq.groupTotal = groupTotal;
  473. return eq;
  474. });
  475. examQuestionList = examQuestionList.map(eq => {
  476. const paperStructQuestion = paperStruct.defaultPaper.questionGroupList[
  477. eq.mainNumber - 1
  478. ].questionWrapperList.find(q => q.questionId === eq.questionId);
  479. return Object.assign(eq, {
  480. limitedPlayTimes: paperStructQuestion.limitedPlayTimes,
  481. });
  482. });
  483. this.updateExamState({
  484. exam: exam,
  485. paperStruct: paperStruct,
  486. examQuestionList: examQuestionList,
  487. allAudioPlayTimes: JSON.parse(examQuestionList[0].audioPlayTimes) || [],
  488. questionAnswerFileUrl: [],
  489. pictureAnswer: {},
  490. });
  491. // console.log(examQuestionList);
  492. // console.log(examQuestionList.find(v => v.answerType === "SINGLE_AUDIO"));
  493. const shouldOpenWS = exam.WEIXIN_ANSWER_ENABLED;
  494. if (shouldOpenWS) {
  495. // console.log("have single");
  496. const examRecordDataId = this.$route.params.examRecordDataId;
  497. openWS({ examRecordDataId });
  498. }
  499. },
  500. updateQuestion: async function(next) {
  501. // 初始化套题的答案,为回填部分选项做准备
  502. // for (let q of this.examQuestionList) {
  503. // if (q.subQuestionList.length > 0) {
  504. // q.studentAnswer = [];
  505. // for (let sq of q.subQuestionList) {
  506. // q.studentAnswer.push(sq.studentAnswer);
  507. // }
  508. // }
  509. // }
  510. next && next();
  511. if (!this.exam) return;
  512. },
  513. async initFaceLiveness() {
  514. let faceVerifyMinute = null;
  515. let identificationOfLivingBodyScheme = null;
  516. // 启用新活检
  517. {
  518. const examRecordDataId = this.$route.params.examRecordDataId;
  519. const faceBiopsyBaseInfoData = await this.$http.get(
  520. "/api/ecs_oe_student/faceBiopsy/getFaceBiopsyBaseInfo?examRecordDataId=" +
  521. examRecordDataId
  522. );
  523. console.log(faceBiopsyBaseInfoData.data);
  524. faceVerifyMinute = faceBiopsyBaseInfoData.data.faceVerifyMinute;
  525. identificationOfLivingBodyScheme =
  526. faceBiopsyBaseInfoData.data.identificationOfLivingBodyScheme;
  527. }
  528. // 仅在线上使用活体检测
  529. // if (process.env.NODE_ENV === "production" && faceVerifyMinute) {
  530. if (faceVerifyMinute) {
  531. const enoughTimeForFaceId = this.remainTime // 如果remainTime取到了的话
  532. ? this.remainTime / (60 * 1000) - 1 > faceVerifyMinute
  533. : true;
  534. if (!enoughTimeForFaceId) return;
  535. this.faceIdMsgTimeout = setTimeout(() => {
  536. // this.serverLog("debug/S-002001", "活体检测前抓拍");
  537. this.toggleSnapNow();
  538. this.$Message.info({
  539. content: "30秒后开始活体检测",
  540. duration: 15,
  541. closable: true,
  542. });
  543. }, faceVerifyMinute * 60 * 1000 - 30 * 1000); // 活体检测提醒
  544. this.faceIdDivTimeout = setTimeout(() => {
  545. // this.serverLog("debug/S-003001", "准备弹出活体检测框");
  546. if (identificationOfLivingBodyScheme === "S1") {
  547. this.showFaceId = true;
  548. } else if (identificationOfLivingBodyScheme === "S2") {
  549. this.showFaceMotion = true;
  550. }
  551. }, faceVerifyMinute * 60 * 1000); // 定时做活体检测
  552. // }, 1 * 1000); // 定时做活体检测
  553. }
  554. // for test
  555. // setTimeout(() => {
  556. // this.showFaceId = true;
  557. // // this.$Modal.remove();
  558. // // }, this.$route.query.faceVerifyMinute * 60 * 1000); // 定时做活体检测
  559. // }, 5 * 1000); // 定时做活体检测
  560. },
  561. closeFaceId() {
  562. this.showFaceId = false;
  563. },
  564. closeFaceMotion(faceLiveResult) {
  565. this.showFaceMotion = false;
  566. console.log(faceLiveResult);
  567. if (faceLiveResult.endExam) {
  568. this.realSubmitPaper();
  569. } else if (faceLiveResult.needNextVerify) {
  570. this.initFaceLiveness();
  571. }
  572. },
  573. async answerAllQuestions(ignoreDirty) {
  574. const answers = this.examQuestionList
  575. .filter(eq => (ignoreDirty ? true : eq.dirty))
  576. .filter(eq => eq.getQuestionContent)
  577. .map(eq => {
  578. return Object.assign(
  579. {
  580. order: eq.order,
  581. studentAnswer: eq.studentAnswer,
  582. },
  583. eq.audioPlayTimes && { audioPlayTimes: eq.audioPlayTimes },
  584. eq.isSign && { isSign: eq.isSign }
  585. );
  586. });
  587. if (answers.length > 0) {
  588. try {
  589. await this.$http.post(
  590. "/api/ecs_oe_student/examQuestion/submitQuestionAnswer",
  591. answers
  592. );
  593. this.resetExamQuestionDirty();
  594. // 提交成功,返回true,供最后提交时判断。自动提交失败,不暂停。
  595. return true;
  596. } catch (error) {
  597. console.log(error);
  598. this.$Message.error({
  599. content: "提交答案失败",
  600. duration: 15,
  601. closable: true,
  602. });
  603. window._hmt.push([
  604. "_trackEvent",
  605. "正在考试页面",
  606. "提交答案失败",
  607. error.message +
  608. " |||| " +
  609. (((error.response || {}).data || {}).desc || ""),
  610. ]);
  611. this.serverLog(
  612. "debug/S-008001",
  613. `提交答案失败 => 考试剩余时间:${this.remainTime / 1000}`
  614. );
  615. }
  616. }
  617. },
  618. async submitPaper() {
  619. try {
  620. // 交卷前强制提交所有答案
  621. const ret = await this.answerAllQuestions(true);
  622. if (!ret) {
  623. // 提交答案失败,停止交卷逻辑。
  624. return;
  625. }
  626. } catch (error) {
  627. return;
  628. }
  629. if (
  630. this.exam.freezeTime &&
  631. this.remainTime >
  632. (this.exam.duration - this.exam.freezeTime) * 60 * 1000
  633. ) {
  634. this.$Message.info({
  635. content: `考试开始${this.exam.freezeTime}分钟后才允许交卷。`,
  636. duration: 5,
  637. closable: true,
  638. });
  639. return;
  640. }
  641. const answered = this.examQuestionList.filter(
  642. q => q.studentAnswer !== null
  643. ).length;
  644. const unanswered = this.examQuestionList.filter(
  645. q => q.studentAnswer === null
  646. ).length;
  647. const signed = this.examQuestionList.filter(q => q.isSign).length;
  648. const showConfirmTime = Date.now();
  649. this.$Modal.confirm({
  650. title: "确认交卷",
  651. content: `<p>已答题目:${answered}</p><p>未答题目:${unanswered}</p><p>标记题目:${signed}</p>`,
  652. onOk: () => {
  653. this.realSubmitPaper(showConfirmTime);
  654. },
  655. });
  656. },
  657. async realSubmitPaper(showConfirmTime = 0) {
  658. this.__submitPaperStartTime = Date.now();
  659. this.$Spin.show({
  660. render: () => {
  661. return <div style="font-size: 44px">正在交卷,请耐心等待...</div>;
  662. },
  663. });
  664. if (this.faceEnable) {
  665. this.serverLog("debug/S-002001", "交卷前抓拍");
  666. this.toggleSnapNow();
  667. }
  668. // 确保抓拍指令在交卷前执行,同时确保5秒间隔提交答案的指令执行了
  669. let delay = 5 - (Date.now() - showConfirmTime) / 1000;
  670. if (delay < 0) {
  671. // 如果用户已经看确认框超过5秒,或者不是由确认框进来的,不延迟
  672. delay = 0;
  673. }
  674. // 给抓拍照片多一秒处理时间
  675. delay = delay + 1;
  676. // 和下行注释的sleep语句不是一样的。sleep之后还可以执行。加上clearTimeout则可拒绝。
  677. // await new Promise(resolve => setTimeout(() => resolve(), delay * 1000));
  678. setTimeout(() => this.realSubmitPaperStep2(), delay * 1000);
  679. this.submitCount = 1;
  680. },
  681. async realSubmitPaperStep2() {
  682. if (this.snapProcessingCount > 0) {
  683. this.submitCount++;
  684. if (this.submitCount < 200) {
  685. // 一分钟后,强制交卷
  686. setTimeout(() => this.realSubmitPaperStep2(), 300);
  687. return;
  688. }
  689. }
  690. if (this.submitLock) {
  691. return;
  692. } else {
  693. this.submitLock = true;
  694. }
  695. if (this.$route.name !== "OnlineExamingHome") {
  696. // 非考试页,不在交卷
  697. this.$Spin.hide();
  698. return;
  699. }
  700. try {
  701. const examId = this.$route.params.examId;
  702. const examRecordDataId = this.$route.params.examRecordDataId;
  703. const res = await this.$http.get(
  704. "/api/ecs_oe_student/examControl/endExam"
  705. );
  706. if (res.status === 200) {
  707. this.$router.replace({
  708. path: `/online-exam/exam/${examId}/examRecordData/${examRecordDataId}/end`,
  709. });
  710. // 确保交卷成功后,不会再次交卷
  711. this.submitLock = true;
  712. this.$Spin.hide();
  713. this.__submitPaperEndTime = Date.now();
  714. window._hmt.push([
  715. "_trackEvent",
  716. "交卷耗时",
  717. Math.floor(
  718. (this.__submitPaperEndTime - this.__submitPaperStartTime) / 1000
  719. ) + "秒",
  720. ]);
  721. return;
  722. } else {
  723. this.$Message.error({
  724. content: "交卷失败",
  725. duration: 15,
  726. closable: true,
  727. });
  728. }
  729. this.submitLock = false;
  730. } catch (e) {
  731. this.$Message.error({
  732. content: "交卷失败",
  733. duration: 15,
  734. closable: true,
  735. });
  736. console.log(e);
  737. }
  738. this.submitLock = false;
  739. this.$Spin.hide();
  740. },
  741. examQuestion() {
  742. return (
  743. this.examQuestionList &&
  744. this.examQuestionList.find(
  745. eq => eq.order == this.$route.params.order // number == string
  746. )
  747. );
  748. },
  749. reloadPage() {
  750. window._hmt.push([
  751. "_trackEvent",
  752. "正在考试页面",
  753. "页面加载失败",
  754. "reload",
  755. ]);
  756. window.location.reload();
  757. },
  758. },
  759. };
  760. </script>
  761. <style scoped>
  762. .container {
  763. display: grid;
  764. grid-template-areas:
  765. "header header"
  766. "main side";
  767. grid-template-rows: 80px minmax(0, 1fr);
  768. grid-template-columns: 1fr 400px;
  769. height: 100vh;
  770. width: 100vw;
  771. }
  772. .header {
  773. display: grid;
  774. align-items: center;
  775. justify-items: center;
  776. grid-template-columns: 200px 280px 1fr 300px 100px;
  777. grid-area: header;
  778. height: 80px;
  779. background-color: #f5f5f5;
  780. }
  781. .main {
  782. display: grid;
  783. grid-area: main;
  784. grid-template-rows: 1fr 50px;
  785. }
  786. .side {
  787. display: grid;
  788. grid-area: side;
  789. grid-template-rows: 1fr;
  790. background-color: #f5f5f5;
  791. }
  792. .question-nav {
  793. overflow-y: scroll;
  794. }
  795. .camera {
  796. z-index: 100;
  797. height: 300px;
  798. }
  799. @media screen and (max-height: 768px) {
  800. .container {
  801. grid-template-rows: 50px minmax(0, 1fr);
  802. }
  803. .header {
  804. height: 50px;
  805. }
  806. }
  807. @media screen and (max-width: 960px) {
  808. .header {
  809. overflow-x: scroll;
  810. }
  811. }
  812. </style>
  813. <style>
  814. #examing-home-question img {
  815. max-width: 100%;
  816. height: auto !important;
  817. }
  818. .hide-body-scroll {
  819. overflow: hidden !important;
  820. }
  821. </style>