ExamingHome.vue 36 KB

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