ExamingHome.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  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(["_trackEvent", "正在考试页面", "提交答案", "失败"]);
  365. this.serverLog(
  366. "debug/S-008001",
  367. `提交答案失败 => 考试剩余时间:${this.remainTime / 1000}`
  368. );
  369. }
  370. }
  371. },
  372. async submitPaper() {
  373. try {
  374. // 交卷前强制提交所有答案
  375. await this.answerAllQuestions(true);
  376. } catch (error) {
  377. return;
  378. }
  379. if (
  380. this.exam.freezeTime &&
  381. this.remainTime >
  382. (this.exam.duration - this.exam.freezeTime) * 60 * 1000
  383. ) {
  384. this.$Message.info({
  385. content: `考试开始${this.exam.freezeTime}分钟后才允许交卷。`,
  386. duration: 5
  387. });
  388. return;
  389. }
  390. const answered = this.examQuestionList.filter(
  391. q => q.studentAnswer !== null
  392. ).length;
  393. const unanswered = this.examQuestionList.filter(
  394. q => q.studentAnswer === null
  395. ).length;
  396. const signed = this.examQuestionList.filter(q => q.isSign).length;
  397. this.$Modal.confirm({
  398. title: "确认交卷",
  399. content: `<p>已答题目:${answered}</p><p>未答题目:${unanswered}</p><p>标记题目:${signed}</p>`,
  400. onOk: this.realSubmitPaper
  401. });
  402. },
  403. async realSubmitPaper() {
  404. this.$Spin.show({
  405. render: () => {
  406. return <div style="font-size: 44px">正在交卷,请耐心等待...</div>;
  407. }
  408. });
  409. if (this.faceEnable) {
  410. this.serverLog("debug/S-002001", "交卷前抓拍");
  411. this.toggleSnapNow();
  412. }
  413. // 确保抓拍指令在交卷前执行
  414. setTimeout(() => this.realSubmitPaperStep2(), 1000);
  415. this.submitCount = 1;
  416. },
  417. async realSubmitPaperStep2() {
  418. if (this.snapProcessingCount > 0) {
  419. this.submitCount++;
  420. if (this.submitCount < 200) {
  421. // 一分钟后,强制交卷
  422. setTimeout(() => this.realSubmitPaperStep2(), 300);
  423. return;
  424. }
  425. }
  426. if (this.submitLock) {
  427. return;
  428. } else {
  429. this.submitLock = true;
  430. }
  431. if (this.$route.name !== "OnlineExamingHome") {
  432. // 非考试页,不在交卷
  433. this.$Spin.hide();
  434. return;
  435. }
  436. try {
  437. const examId = this.$route.params.examId;
  438. const examRecordDataId = this.$route.params.examRecordDataId;
  439. const res = await this.$http.get(
  440. "/api/ecs_oe_student/examControl/endExam"
  441. );
  442. if (res.status === 200) {
  443. this.$router.replace({
  444. path: `/online-exam/exam/${examId}/examRecordData/${examRecordDataId}/end`
  445. });
  446. // 确保交卷成功后,不会再次交卷
  447. this.submitLock = true;
  448. this.$Spin.hide();
  449. return;
  450. } else {
  451. this.$Message.error("交卷失败");
  452. }
  453. this.submitLock = false;
  454. } catch (e) {
  455. this.$Message.error("交卷失败");
  456. console.log(e);
  457. }
  458. this.submitLock = false;
  459. this.$Spin.hide();
  460. },
  461. examQuestion() {
  462. return (
  463. this.examQuestionList &&
  464. this.examQuestionList.find(
  465. eq => eq.order == this.$route.params.order // number == string
  466. )
  467. );
  468. },
  469. reloadPage() {
  470. window._hmt.push([
  471. "_trackEvent",
  472. "正在考试页面",
  473. "页面加载失败",
  474. "reload"
  475. ]);
  476. window.location.reload();
  477. }
  478. },
  479. computed: {
  480. ...mapState([
  481. "exam",
  482. "paperStruct",
  483. "examQuestionList",
  484. "snapNow",
  485. "snapProcessingCount",
  486. "shouldSubmitPaper",
  487. "remainTime"
  488. ]),
  489. previousQuestionOrder: vm => {
  490. if (vm.examQuestion().order > 1) {
  491. return vm.examQuestion().order - 1;
  492. } else {
  493. return null;
  494. }
  495. },
  496. nextQuestionOrder: vm => {
  497. if (vm.examQuestion().order < vm.examQuestionList.length) {
  498. return vm.examQuestion().order + 1;
  499. } else {
  500. return null;
  501. }
  502. }
  503. },
  504. watch: {
  505. $route: function() {
  506. this.examQuestion();
  507. },
  508. shouldSubmitPaper() {
  509. this.realSubmitPaper();
  510. }
  511. // examQuestionList(val, oldVal) {
  512. // // console.log(val, oldVal);
  513. // }
  514. },
  515. components: {
  516. RemainTime,
  517. OverallProgress,
  518. QuestionFilters,
  519. QuestionView,
  520. ArrowNavView,
  521. QuestionNavView,
  522. FaceRecognition,
  523. FaceId,
  524. FaceTracking
  525. }
  526. };
  527. </script>
  528. <style scoped>
  529. .container {
  530. display: grid;
  531. grid-template-areas:
  532. "header header"
  533. "main side";
  534. grid-template-rows: 80px minmax(0, 1fr);
  535. grid-template-columns: 1fr 400px;
  536. height: 100vh;
  537. width: 100vw;
  538. }
  539. .header {
  540. display: grid;
  541. align-items: center;
  542. justify-items: center;
  543. grid-template-columns: 200px 1fr 300px 100px;
  544. grid-area: header;
  545. height: 80px;
  546. background-color: #f5f5f5;
  547. }
  548. .main {
  549. display: grid;
  550. grid-area: main;
  551. grid-template-rows: 1fr 50px;
  552. }
  553. .side {
  554. display: grid;
  555. grid-area: side;
  556. grid-template-rows: 1fr 250px;
  557. background-color: #f5f5f5;
  558. }
  559. .side-row-size {
  560. grid-template-rows: 1fr;
  561. }
  562. .question-nav {
  563. overflow-y: scroll;
  564. max-height: calc(100vh - 300px);
  565. }
  566. .question-nav-long {
  567. max-height: calc(100vh - 100px);
  568. }
  569. .camera {
  570. align-self: flex-end;
  571. justify-self: flex-end;
  572. }
  573. @media screen and (max-height: 768px) {
  574. .container {
  575. grid-template-rows: 50px minmax(0, 1fr);
  576. }
  577. .header {
  578. height: 50px;
  579. }
  580. .side {
  581. grid-template-rows: minmax(0, 1fr) 200px;
  582. }
  583. }
  584. </style>
  585. <style>
  586. #examing-home-question img {
  587. max-width: 100%;
  588. height: auto !important;
  589. }
  590. </style>