ExamingHome.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. <template>
  2. <div v-if="exam && examQuestion()" class="container" :key="exam.id">
  3. <div class="header">
  4. <RemainTime></RemainTime>
  5. <OverallProgress :exam-question-list="examQuestionList"></OverallProgress>
  6. <QuestionFilters :exam-question-list="examQuestionList"></QuestionFilters>
  7. <i-button class="qm-primary-button" @click="submitPaper">交卷</i-button>
  8. </div>
  9. <div class="main" id="examing-home-question">
  10. <QuestionView :exam-question="examQuestion()"></QuestionView>
  11. <ArrowNavView
  12. :previousQuestionOrder="previousQuestionOrder"
  13. :nextQuestionOrder="nextQuestionOrder"
  14. ></ArrowNavView>
  15. </div>
  16. <div :class="['side', 'side-row-size']">
  17. <div :class="['question-nav', !faceEnable && 'question-nav-long']">
  18. <QuestionNavView :paperStruct="paperStruct" />
  19. </div>
  20. <div v-if="faceEnable" class="camera">
  21. <FaceRecognition
  22. v-if="faceEnable"
  23. width="100%"
  24. height="100%"
  25. :showRecognizeButton="false"
  26. />
  27. </div>
  28. </div>
  29. <Modal
  30. v-model="showFaceId"
  31. :mask-closable="false"
  32. :closable="false"
  33. width="800"
  34. :styles="{ top: '10px' }"
  35. >
  36. <FaceId v-if="showFaceId" @closeFaceId="closeFaceId" />
  37. <p slot="footer"></p>
  38. </Modal>
  39. <FaceTracking v-if="faceEnable && PRODUCTION" />
  40. </div>
  41. <div v-else>
  42. 正在等待数据返回...
  43. <i-button class="qm-primary-button" v-if="timeouted" @click="reloadPage">
  44. 重试
  45. </i-button>
  46. </div>
  47. </template>
  48. <script>
  49. import RemainTime from "./RemainTime.vue";
  50. import OverallProgress from "./OverallProgress.vue";
  51. import QuestionFilters from "./QuestionFilters.vue";
  52. import QuestionView from "./QuestionView.vue";
  53. import ArrowNavView from "./ArrowNavView.vue";
  54. import QuestionNavView from "./QuestionNavView.vue";
  55. import FaceTracking from "./FaceTracking.vue";
  56. import FaceId from "./FaceId.vue";
  57. import FaceRecognition from "../../../components/FaceRecognition/FaceRecognition";
  58. import { createNamespacedHelpers } from "vuex";
  59. const { mapState, mapMutations } = createNamespacedHelpers("examingHomeModule");
  60. export default {
  61. name: "ExamingHome",
  62. data() {
  63. return {
  64. showFaceId: false,
  65. faceEnable: false,
  66. timeouted: false,
  67. PRODUCTION: process.env.NODE_ENV === "production"
  68. };
  69. },
  70. async created() {
  71. this.timeoutTimeout = setTimeout(() => (this.timeouted = true), 30 * 1000);
  72. // 仅在线上使用活体检测
  73. if (
  74. process.env.NODE_ENV === "production" &&
  75. /^\d+$/.test(this.$route.query.faceVerifyMinute)
  76. ) {
  77. const enoughTimeForFaceId = this.remainTime // 如果remainTime取到了的话
  78. ? this.remainTime / (60 * 1000) - 1 > this.$route.query.faceVerifyMinute
  79. : true;
  80. if (!enoughTimeForFaceId) return;
  81. this.faceIdMsgTimeout = setTimeout(() => {
  82. this.serverLog("debug/S-002001", "活体检测前抓拍");
  83. this.toggleSnapNow();
  84. this.$Message.info("30秒后开始活体检测");
  85. }, this.$route.query.faceVerifyMinute * 60 * 1000 - 30 * 1000); // 活体检测提醒
  86. this.faceIdDivTimeout = setTimeout(() => {
  87. this.serverLog("debug/S-003001", "准备弹出活体检测框");
  88. this.showFaceId = true;
  89. }, this.$route.query.faceVerifyMinute * 60 * 1000); // 定时做活体检测
  90. // }, 1 * 1000); // 定时做活体检测
  91. }
  92. // for test
  93. // setTimeout(() => {
  94. // this.showFaceId = true;
  95. // // this.$Modal.remove();
  96. // // }, this.$route.query.faceVerifyMinute * 60 * 1000); // 定时做活体检测
  97. // }, 5 * 1000); // 定时做活体检测
  98. let faceEnable;
  99. try {
  100. faceEnable = await this.$http.get(
  101. "/api/ecs_exam_work/exam/examOrgProperty/" +
  102. this.$route.params.examId +
  103. `/IS_FACE_ENABLE`
  104. );
  105. } catch (error) {
  106. this.$Message.error("获取人脸检测设置失败");
  107. this.logout("?LogoutReason=获取人脸检测设置失败");
  108. return;
  109. }
  110. if (faceEnable.data) {
  111. this.faceEnable = true;
  112. // setTimeout(() => {
  113. // this.serverLog("debug/S-002001", "进入考试后60秒内抓拍");
  114. // this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
  115. // }, 60 * 1000); // 60内秒钟后抓拍
  116. let initSnapshotTrialTimes = 0;
  117. this.initSnapInterval = setInterval(() => {
  118. if (this.exam || initSnapshotTrialTimes > 12) {
  119. clearInterval(this.initSnapInterval);
  120. this.serverLog(
  121. "debug/S-002001",
  122. "进入考试后60秒内抓拍" + `(${(initSnapshotTrialTimes + 1) * 5}秒)`
  123. );
  124. this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
  125. } else {
  126. initSnapshotTrialTimes++;
  127. }
  128. }, 5 * 1000);
  129. // let initSnapshotTrialTimes = 0;
  130. // const initSnapshot = setTimeout(() => {
  131. // if (this.exam || initSnapshotTrialTimes < 6) {
  132. // this.serverLog("debug/S-002001", "进入考试后60秒内抓拍");
  133. // this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
  134. // } else {
  135. // setTimeout(() => initSnapshot(), 5 * 1000);
  136. // }
  137. // }, 5 * 1000); // 60内秒钟后抓拍
  138. try {
  139. const snapshotInterval = await this.$http.get(
  140. "/api/ecs_exam_work/exam/examOrgProperty/" +
  141. this.$route.params.examId +
  142. `/SNAPSHOT_INTERVAL`
  143. );
  144. if (snapshotInterval.data) {
  145. // 考务设置抓拍间隔
  146. this.snapInterval = setInterval(() => {
  147. this.serverLog(
  148. "debug/S-002001",
  149. "根据抓拍间隔抓拍:抓拍间隔=" + snapshotInterval.data + "分钟"
  150. );
  151. this.toggleSnapNow();
  152. }, snapshotInterval.data * 60 * 1000);
  153. }
  154. } catch (error) {
  155. this.$Message.error("获取人脸抓拍间隔设置失败");
  156. this.logout("?LogoutReason=获取人脸抓拍间隔设置失败");
  157. return;
  158. }
  159. }
  160. try {
  161. await this.initData();
  162. } catch (error) {
  163. this.$Message.error("获取试卷信息失败,退出登录");
  164. this.logout("?LogoutReason=获取试卷信息失败");
  165. return;
  166. }
  167. this.submitInterval = setInterval(
  168. () => this.answerAllQuestions(),
  169. 5 * 1000 // 10秒检查是否有更改需要提交答案
  170. );
  171. },
  172. mounted() {
  173. // iview bug: https://github.com/iview/iview/issues/4061
  174. // document.body.style = "";
  175. window._hmt.push(["_trackEvent", "正在考试页面", "进入页面"]);
  176. if (typeof nodeRequire != "undefined") {
  177. try {
  178. var fs = window.nodeRequire("fs");
  179. if (fs.existsSync("multiCamera.exe")) {
  180. window.nodeRequire("node-cmd").get("multiCamera.exe", () => {
  181. let CameraInfo = fs.readFileSync("CameraInfo.txt", "utf-8");
  182. if (CameraInfo && CameraInfo.trim()) {
  183. CameraInfo = CameraInfo.trim();
  184. CameraInfo = CameraInfo.replace(/\r\n/g, "");
  185. CameraInfo = CameraInfo.replace(/\n/g, "");
  186. console.log(CameraInfo);
  187. this.serverLog("debug/S-001001", CameraInfo);
  188. }
  189. });
  190. }
  191. } catch (error) {
  192. console.log(error);
  193. }
  194. }
  195. },
  196. beforeRouteUpdate(from, to, next) {
  197. window._hmt.push(["_trackEvent", "正在考试页面", "题目切换"]);
  198. if (process.env.NODE_ENV === "development") {
  199. console.log("beforeRouteUpdate from: " + this.$route.fullPath);
  200. }
  201. this.answerAllQuestions();
  202. next();
  203. },
  204. beforeDestroy() {
  205. clearTimeout(this.timeoutTimeout);
  206. clearInterval(this.submitInterval);
  207. clearInterval(this.initSnapInterval);
  208. clearInterval(this.snapInterval);
  209. clearTimeout(this.faceIdMsgTimeout);
  210. clearTimeout(this.faceIdDivTimeout);
  211. this.updateExamState({
  212. exam: null,
  213. paperStruct: null,
  214. examQuestionList: null
  215. });
  216. this.$Modal.remove();
  217. },
  218. // beforeRouteUpdate(to, from, next) {
  219. // this.updateQuestion(next);
  220. // },
  221. methods: {
  222. ...mapMutations([
  223. "updateExamState",
  224. "toggleSnapNow",
  225. "updateExamResult",
  226. "resetExamQuestionDirty"
  227. ]),
  228. async initData() {
  229. const [
  230. examData,
  231. paperStructData,
  232. examQuestionListData
  233. ] = await Promise.all([
  234. this.$http.get("/api/ecs_exam_work/exam/" + this.$route.params.examId),
  235. this.$http.get(
  236. "/api/ecs_oe_student/examRecordPaperStruct/getExamRecordPaperStruct?examRecordDataId=" +
  237. this.$route.params.examRecordDataId
  238. ),
  239. this.$http.get("/api/ecs_oe_student/examQuestion/findExamQuestionList")
  240. ]);
  241. const [exam, paperStruct] = [examData.data, paperStructData.data];
  242. let examQuestionList = examQuestionListData.data;
  243. if (
  244. exam === undefined ||
  245. paperStruct === undefined ||
  246. examQuestionListData === undefined
  247. ) {
  248. this.$Message.error("获取试卷信息失败");
  249. this.logout("?LogoutReason=获取试卷信息失败");
  250. return;
  251. }
  252. if (exam.examType === "PRACTICE") {
  253. const practiceType = (await this.$http.get(
  254. "/api/ecs_exam_work/exam/examOrgProperty/" +
  255. this.$route.params.examId +
  256. `/PRACTICE_TYPE`
  257. )).data;
  258. this.practiceType = practiceType; // IN_PRACTICE NO_ANSWER
  259. exam.practiceType = practiceType;
  260. }
  261. try {
  262. let freezeTimeData = await this.$http.get(
  263. "/api/ecs_exam_work/exam/examOrgProperty/" +
  264. this.$route.params.examId +
  265. `/FREEZE_TIME`
  266. );
  267. exam.freezeTime = freezeTimeData.data;
  268. } catch (error) {
  269. console.log("获取考试冻结时间失败--忽略");
  270. }
  271. // init subNumber
  272. let questionId = null;
  273. let i = 1;
  274. examQuestionList = examQuestionList.map(eq => {
  275. if (questionId == eq.questionId) {
  276. eq.subNumber = i++;
  277. } else {
  278. i = 1;
  279. questionId = eq.questionId;
  280. eq.subNumber = i++;
  281. }
  282. return eq;
  283. });
  284. let groupOrder = 1;
  285. let mainNumber = 0;
  286. examQuestionList = examQuestionList.map(eq => {
  287. if (mainNumber == eq.mainNumber) {
  288. eq.groupOrder = groupOrder++;
  289. } else {
  290. mainNumber = eq.mainNumber;
  291. groupOrder = 1;
  292. eq.groupOrder = groupOrder++;
  293. }
  294. const questionWrapperList =
  295. paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1]
  296. .questionWrapperList;
  297. const groupName =
  298. paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1]
  299. .groupName;
  300. const groupTotal = questionWrapperList.reduce(
  301. (accumulator, questionWrapper) =>
  302. accumulator + questionWrapper.questionUnitWrapperList.length,
  303. 0
  304. );
  305. eq.groupName = groupName;
  306. eq.groupTotal = groupTotal;
  307. return eq;
  308. });
  309. examQuestionList = examQuestionList.map(eq => {
  310. const paperStructQuestion = paperStruct.defaultPaper.questionGroupList[
  311. eq.mainNumber - 1
  312. ].questionWrapperList.find(q => q.questionId === eq.questionId);
  313. return Object.assign(eq, {
  314. limitedPlayTimes: paperStructQuestion.limitedPlayTimes
  315. });
  316. });
  317. this.updateExamState({
  318. exam: exam,
  319. paperStruct: paperStruct,
  320. examQuestionList: examQuestionList,
  321. allAudioPlayTimes: JSON.parse(examQuestionList[0].audioPlayTimes) || []
  322. });
  323. },
  324. updateQuestion: async function(next) {
  325. // 初始化套题的答案,为回填部分选项做准备
  326. // for (let q of this.examQuestionList) {
  327. // if (q.subQuestionList.length > 0) {
  328. // q.studentAnswer = [];
  329. // for (let sq of q.subQuestionList) {
  330. // q.studentAnswer.push(sq.studentAnswer);
  331. // }
  332. // }
  333. // }
  334. next && next();
  335. if (!this.exam) return;
  336. },
  337. closeFaceId() {
  338. this.showFaceId = false;
  339. },
  340. async answerAllQuestions(ignoreDirty) {
  341. const answers = this.examQuestionList
  342. .filter(eq => !ignoreDirty && eq.dirty)
  343. .filter(eq => eq.getQuestionContent)
  344. .map(eq => {
  345. return Object.assign(
  346. {
  347. order: eq.order,
  348. studentAnswer: eq.studentAnswer
  349. },
  350. eq.audioPlayTimes && { audioPlayTimes: eq.audioPlayTimes },
  351. eq.isSign && { isSign: eq.isSign }
  352. );
  353. });
  354. if (answers.length > 0) {
  355. try {
  356. await this.$http.post(
  357. "/api/ecs_oe_student/examQuestion/submitQuestionAnswer",
  358. answers
  359. );
  360. this.resetExamQuestionDirty();
  361. } catch (error) {
  362. console.log(error);
  363. this.$Message.error("提交答案失败");
  364. window._hmt.push([
  365. "_trackEvent",
  366. "正在考试页面",
  367. "提交答案失败",
  368. error.message +
  369. " |||| " +
  370. (((error.response || {}).data || {}).desc || "")
  371. ]);
  372. this.serverLog(
  373. "debug/S-008001",
  374. `提交答案失败 => 考试剩余时间:${this.remainTime / 1000}`
  375. );
  376. }
  377. }
  378. },
  379. async submitPaper() {
  380. try {
  381. // 交卷前强制提交所有答案
  382. await this.answerAllQuestions(true);
  383. } catch (error) {
  384. return;
  385. }
  386. if (
  387. this.exam.freezeTime &&
  388. this.remainTime >
  389. (this.exam.duration - this.exam.freezeTime) * 60 * 1000
  390. ) {
  391. this.$Message.info({
  392. content: `考试开始${this.exam.freezeTime}分钟后才允许交卷。`,
  393. duration: 5
  394. });
  395. return;
  396. }
  397. const answered = this.examQuestionList.filter(
  398. q => q.studentAnswer !== null
  399. ).length;
  400. const unanswered = this.examQuestionList.filter(
  401. q => q.studentAnswer === null
  402. ).length;
  403. const signed = this.examQuestionList.filter(q => q.isSign).length;
  404. this.$Modal.confirm({
  405. title: "确认交卷",
  406. content: `<p>已答题目:${answered}</p><p>未答题目:${unanswered}</p><p>标记题目:${signed}</p>`,
  407. onOk: this.realSubmitPaper
  408. });
  409. },
  410. async realSubmitPaper() {
  411. this.$Spin.show({
  412. render: () => {
  413. return <div style="font-size: 44px">正在交卷,请耐心等待...</div>;
  414. }
  415. });
  416. if (this.faceEnable) {
  417. this.serverLog("debug/S-002001", "交卷前抓拍");
  418. this.toggleSnapNow();
  419. }
  420. // 确保抓拍指令在交卷前执行
  421. setTimeout(() => this.realSubmitPaperStep2(), 1000);
  422. this.submitCount = 1;
  423. },
  424. async realSubmitPaperStep2() {
  425. if (this.snapProcessingCount > 0) {
  426. this.submitCount++;
  427. if (this.submitCount < 200) {
  428. // 一分钟后,强制交卷
  429. setTimeout(() => this.realSubmitPaperStep2(), 300);
  430. return;
  431. }
  432. }
  433. if (this.submitLock) {
  434. return;
  435. } else {
  436. this.submitLock = true;
  437. }
  438. if (this.$route.name !== "OnlineExamingHome") {
  439. // 非考试页,不在交卷
  440. this.$Spin.hide();
  441. return;
  442. }
  443. try {
  444. const examId = this.$route.params.examId;
  445. const examRecordDataId = this.$route.params.examRecordDataId;
  446. const res = await this.$http.get(
  447. "/api/ecs_oe_student/examControl/endExam"
  448. );
  449. if (res.status === 200) {
  450. this.$router.replace({
  451. path: `/online-exam/exam/${examId}/examRecordData/${examRecordDataId}/end`
  452. });
  453. // 确保交卷成功后,不会再次交卷
  454. this.submitLock = true;
  455. this.$Spin.hide();
  456. return;
  457. } else {
  458. this.$Message.error("交卷失败");
  459. }
  460. this.submitLock = false;
  461. } catch (e) {
  462. this.$Message.error("交卷失败");
  463. console.log(e);
  464. }
  465. this.submitLock = false;
  466. this.$Spin.hide();
  467. },
  468. examQuestion() {
  469. return (
  470. this.examQuestionList &&
  471. this.examQuestionList.find(
  472. eq => eq.order == this.$route.params.order // number == string
  473. )
  474. );
  475. },
  476. reloadPage() {
  477. window._hmt.push([
  478. "_trackEvent",
  479. "正在考试页面",
  480. "页面加载失败",
  481. "reload"
  482. ]);
  483. window.location.reload();
  484. }
  485. },
  486. computed: {
  487. ...mapState([
  488. "exam",
  489. "paperStruct",
  490. "examQuestionList",
  491. "snapNow",
  492. "snapProcessingCount",
  493. "shouldSubmitPaper",
  494. "remainTime"
  495. ]),
  496. previousQuestionOrder: vm => {
  497. if (vm.examQuestion().order > 1) {
  498. return vm.examQuestion().order - 1;
  499. } else {
  500. return null;
  501. }
  502. },
  503. nextQuestionOrder: vm => {
  504. if (vm.examQuestion().order < vm.examQuestionList.length) {
  505. return vm.examQuestion().order + 1;
  506. } else {
  507. return null;
  508. }
  509. }
  510. },
  511. watch: {
  512. $route: function() {
  513. this.examQuestion();
  514. },
  515. shouldSubmitPaper() {
  516. this.realSubmitPaper();
  517. }
  518. // examQuestionList(val, oldVal) {
  519. // // console.log(val, oldVal);
  520. // }
  521. },
  522. components: {
  523. RemainTime,
  524. OverallProgress,
  525. QuestionFilters,
  526. QuestionView,
  527. ArrowNavView,
  528. QuestionNavView,
  529. FaceRecognition,
  530. FaceId,
  531. FaceTracking
  532. }
  533. };
  534. </script>
  535. <style scoped>
  536. .container {
  537. display: grid;
  538. grid-template-areas:
  539. "header header"
  540. "main side";
  541. grid-template-rows: 80px minmax(0, 1fr);
  542. grid-template-columns: 1fr 400px;
  543. height: 100vh;
  544. width: 100vw;
  545. }
  546. .header {
  547. display: grid;
  548. align-items: center;
  549. justify-items: center;
  550. grid-template-columns: 200px 1fr 300px 100px;
  551. grid-area: header;
  552. height: 80px;
  553. background-color: #f5f5f5;
  554. }
  555. .main {
  556. display: grid;
  557. grid-area: main;
  558. grid-template-rows: 1fr 50px;
  559. }
  560. .side {
  561. display: grid;
  562. grid-area: side;
  563. grid-template-rows: 1fr 250px;
  564. background-color: #f5f5f5;
  565. }
  566. .side-row-size {
  567. grid-template-rows: 1fr;
  568. }
  569. .question-nav {
  570. overflow-y: scroll;
  571. max-height: calc(100vh - 300px);
  572. }
  573. .question-nav-long {
  574. max-height: calc(100vh - 100px);
  575. }
  576. .camera {
  577. align-self: flex-end;
  578. justify-self: flex-end;
  579. }
  580. @media screen and (max-height: 768px) {
  581. .container {
  582. grid-template-rows: 50px minmax(0, 1fr);
  583. }
  584. .header {
  585. height: 50px;
  586. }
  587. .side {
  588. grid-template-rows: minmax(0, 1fr) 200px;
  589. }
  590. }
  591. </style>
  592. <style>
  593. #examing-home-question img {
  594. max-width: 100%;
  595. height: auto !important;
  596. }
  597. </style>