|
@@ -0,0 +1,889 @@
|
|
|
|
+<script setup lang="ts">
|
|
|
|
+import RemainTime from "./RemainTime.vue";
|
|
|
|
+import OverallProgress from "./OverallProgress.vue";
|
|
|
|
+import QuestionFilters from "./QuestionFilters.vue";
|
|
|
|
+// import QuestionView from "./QuestionView.vue";
|
|
|
|
+import ArrowNavView from "./ArrowNavView.vue";
|
|
|
|
+import QuestionNavView from "./QuestionNavView.vue";
|
|
|
|
+import FaceTracking from "./FaceTracking.vue";
|
|
|
|
+// import FaceId from "./FaceId.vue";
|
|
|
|
+// import FaceMotion from "./FaceMotion/FaceMotion";
|
|
|
|
+import FaceRecognition from "../FaceRecognition.vue";
|
|
|
|
+// import { openWS, closeWsWithoutReconnect } from "./ws.js";
|
|
|
|
+
|
|
|
|
+import { STRICT_CHECK_HOSTS } from "@/constants/constants";
|
|
|
|
+import { httpApp } from "@/plugins/axiosApp";
|
|
|
|
+import { useTimers } from "@/setups/useTimers";
|
|
|
|
+import { checkMainExe } from "@/utils/nativeMethods";
|
|
|
|
+import { showLogout } from "@/utils/utils";
|
|
|
|
+import { onBeforeUpdate, onMounted } from "vue";
|
|
|
|
+import { useRoute } from "vue-router";
|
|
|
|
+import { store } from "@/store/store";
|
|
|
|
+import { useRemoteAppChecker } from "@/features/UserLogin/useRemoteAppChecker";
|
|
|
|
+import { ExamQuestion, PaperStruct, Store } from "@/types/student-client";
|
|
|
|
+import router from "@/router";
|
|
|
|
+
|
|
|
|
+type PRACTICE_TYPE = "IN_PRACTICE" | "NO_ANSWER";
|
|
|
|
+
|
|
|
|
+let loading = $ref(true);
|
|
|
|
+const route = useRoute();
|
|
|
|
+const examId = route.params.examId;
|
|
|
|
+const examRecordDataId = route.params.examRecordDataId;
|
|
|
|
+
|
|
|
|
+let courseName = $ref("");
|
|
|
|
+
|
|
|
|
+onBeforeUpdate(() => {
|
|
|
|
+ _hmt.push(["_trackEvent", "答题页面", "题目切换"]);
|
|
|
|
+ void answerAllQuestions();
|
|
|
|
+});
|
|
|
|
+
|
|
|
|
+// computed: {
|
|
|
|
+// ...mapState([
|
|
|
|
+// "exam",
|
|
|
|
+// "paperStruct",
|
|
|
|
+// "examQuestionList",
|
|
|
|
+// "snapNow",
|
|
|
|
+// "snapProcessingCount",
|
|
|
|
+// "remainTime",
|
|
|
|
+// "questionAnswerFileUrl",
|
|
|
|
+// "uploadModalVisible",
|
|
|
|
+// "exceedSwitchCount",
|
|
|
|
+// ]),
|
|
|
|
+// previousQuestionOrder: (vm) => {
|
|
|
|
+// if (vm.examQuestion().order > 1) {
|
|
|
|
+// return vm.examQuestion().order - 1;
|
|
|
|
+// } else {
|
|
|
|
+// return null;
|
|
|
|
+// }
|
|
|
|
+// },
|
|
|
|
+// nextQuestionOrder: (vm) => {
|
|
|
|
+// if (vm.examQuestion().order < vm.examQuestionList.length) {
|
|
|
|
+// return vm.examQuestion().order + 1;
|
|
|
|
+// } else {
|
|
|
|
+// return null;
|
|
|
|
+// }
|
|
|
|
+// },
|
|
|
|
+// },
|
|
|
|
+
|
|
|
|
+// watch: {
|
|
|
|
+// $route: function () {
|
|
|
|
+// this.examQuestion();
|
|
|
|
+// },
|
|
|
|
+// questionAnswerFileUrl(value) {
|
|
|
|
+// // console.log(this.examQuestion.studentAnswer);
|
|
|
|
+// // console.log("watch", value);
|
|
|
|
+// const examRecordDataId = examRecordDataId;
|
|
|
|
+// const that = this;
|
|
|
|
+// for (const q of value) {
|
|
|
|
+// if (!q.saved) {
|
|
|
|
+// let acknowledgeStatus = "CONFIRMED";
|
|
|
|
+
|
|
|
|
+// // 目前只针对音频题有丢弃的可能
|
|
|
|
+// if (
|
|
|
|
+// q.transferFileType === "PIC" &&
|
|
|
|
+// (q.order != this.$route.params.order || !this.uploadModalVisible)
|
|
|
|
+// ) {
|
|
|
|
+// acknowledgeStatus = "DISCARDED";
|
|
|
|
+// }
|
|
|
|
+// httpApp
|
|
|
|
+// .post(
|
|
|
|
+// "/api/ecs_oe_student/examControl/saveUploadedFileAcknowledgeStatus",
|
|
|
|
+// {
|
|
|
|
+// examRecordDataId,
|
|
|
|
+// filePath: q.fileUrl,
|
|
|
|
+// order: q.order,
|
|
|
|
+// acknowledgeStatus,
|
|
|
|
+// }
|
|
|
|
+// )
|
|
|
|
+// .then(() => {
|
|
|
|
+// if (q.transferFileType === "AUDIO") {
|
|
|
|
+// that.updateExamQuestion({
|
|
|
|
+// order: q.order,
|
|
|
|
+// studentAnswer: q.fileUrl,
|
|
|
|
+// });
|
|
|
|
+// } else if (
|
|
|
|
+// acknowledgeStatus === "CONFIRMED" &&
|
|
|
|
+// q.transferFileType === "PIC"
|
|
|
|
+// ) {
|
|
|
|
+// that.updatePicture(q);
|
|
|
|
+// }
|
|
|
|
+// q.saved = true;
|
|
|
|
+// if (acknowledgeStatus === "CONFIRMED")
|
|
|
|
+// this.$Message.info({
|
|
|
|
+// content: "小程序作答已更新",
|
|
|
|
+// duration: 5,
|
|
|
|
+// closable: true,
|
|
|
|
+// });
|
|
|
|
+// })
|
|
|
|
+// .catch(() => {
|
|
|
|
+// this.$Message.error({
|
|
|
|
+// content: "更新小程序答案失败!",
|
|
|
|
+// duration: 15,
|
|
|
|
+// closable: true,
|
|
|
|
+// });
|
|
|
|
+// });
|
|
|
|
+// }
|
|
|
|
+// }
|
|
|
|
+// },
|
|
|
|
+// exceedSwitchCount(val) {
|
|
|
|
+// if (val) {
|
|
|
|
+// this.logger({ action: "切屏超出次数自动交卷" });
|
|
|
|
+// this.realSubmitPaper();
|
|
|
|
+// }
|
|
|
|
+// },
|
|
|
|
+// // examQuestionList(val, oldVal) {
|
|
|
|
+// // // console.log(val, oldVal);
|
|
|
|
+// // }
|
|
|
|
+// remainTime(val) {
|
|
|
|
+// if (val === 5 * 60 * 1000) {
|
|
|
|
+// this.reaminModalCreated = true;
|
|
|
|
+// this.$Modal.info({
|
|
|
|
+// render: () => (
|
|
|
|
+// <div>
|
|
|
|
+// <h3>温馨提醒</h3>
|
|
|
|
+// <div style="margin-top: 20px; margin-left: 20px; flex: 1">
|
|
|
|
+// <div style="margin-bottom: 1.5em">
|
|
|
|
+// 还有<span style="font-weight: bold; color: red;"> 五 </span>
|
|
|
|
+// 分钟即将结束本场考试,请合理分配时间!
|
|
|
|
+// </div>
|
|
|
|
+// </div>
|
|
|
|
+// </div>
|
|
|
|
+// ),
|
|
|
|
+// onOk: () => {
|
|
|
|
+// this.reaminModalClosed = true;
|
|
|
|
+// },
|
|
|
|
+// });
|
|
|
|
+// } else if (val === 5 * 60 * 1000 - 10 * 1000) {
|
|
|
|
+// if (this.reaminModalCreated && !this.reaminModalClosed) {
|
|
|
|
+// this.$Modal.remove();
|
|
|
|
+// }
|
|
|
|
+// }
|
|
|
|
+// },
|
|
|
|
+// },
|
|
|
|
+
|
|
|
|
+let pageLoadTimeout = $ref(false);
|
|
|
|
+const { addTimeout, addInterval } = useTimers();
|
|
|
|
+addTimeout(() => (pageLoadTimeout = true), 30 * 1000);
|
|
|
|
+
|
|
|
|
+// 10秒检查是否有更改需要提交答案
|
|
|
|
+addInterval(() => answerAllQuestions(), 5 * 1000);
|
|
|
|
+
|
|
|
|
+addTimeout(() => {
|
|
|
|
+ if (STRICT_CHECK_HOSTS.includes(window.location.hostname)) {
|
|
|
|
+ if (!checkMainExe()) {
|
|
|
|
+ void httpApp.post("/api/ecs_oe_student/client/exam/process/discipline");
|
|
|
|
+ logger({ cnl: ["server"], act: "答题页面discipline" });
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+}, 60 * 1000);
|
|
|
|
+
|
|
|
|
+onMounted(async () => {
|
|
|
|
+ logger({
|
|
|
|
+ cnl: ["server", "local"],
|
|
|
|
+ pgn: "答题页面",
|
|
|
|
+ act: "进入答题页面-created",
|
|
|
|
+ pgu: "AUTO",
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ try {
|
|
|
|
+ await initData();
|
|
|
|
+ loading = false;
|
|
|
|
+ } catch (error) {
|
|
|
|
+ logger({
|
|
|
|
+ cnl: ["server"],
|
|
|
|
+ pgn: "答题页面",
|
|
|
|
+ act: "获取考试和试卷信息失败,退出登录",
|
|
|
|
+ });
|
|
|
|
+ showLogout("获取考试和试卷信息失败,退出登录");
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ logger({
|
|
|
|
+ cnl: ["server"],
|
|
|
|
+ pgu: "AUTO",
|
|
|
|
+ act: "考试开始",
|
|
|
|
+ dtl: "数据初始化完成",
|
|
|
|
+ });
|
|
|
|
+});
|
|
|
|
+
|
|
|
|
+// beforeDestroy() {
|
|
|
|
+// clearInterval(this.initSnapInterval);
|
|
|
|
+// clearInterval(this.snapInterval);
|
|
|
|
+// clearTimeout(this.faceIdMsgTimeout);
|
|
|
|
+// clearTimeout(this.faceIdDivTimeout);
|
|
|
|
+// closeWsWithoutReconnect();
|
|
|
|
+// this.updateExamState({
|
|
|
|
+// exam: null,
|
|
|
|
+// paperStruct: null,
|
|
|
|
+// examQuestionList: null,
|
|
|
|
+// questionAnswerFileUrl: [],
|
|
|
|
+// pictureAnswer: {},
|
|
|
|
+// snapNow: false,
|
|
|
|
+// snapProcessingCount: 0,
|
|
|
|
+// exceedSwitchCount: false,
|
|
|
|
+// });
|
|
|
|
+// // TODO: 是否是个错误点?this.$Modal 不存在?
|
|
|
|
+// this.$Modal.remove();
|
|
|
|
+// // 避免macos上下塘动。避免产生滚动条。
|
|
|
|
+// document.body.classList.toggle("hide-body-scroll", false);
|
|
|
|
+// },
|
|
|
|
+
|
|
|
|
+// methods: {
|
|
|
|
+// ...mapMutations([
|
|
|
|
+// "updateExamState",
|
|
|
|
+// "updateExamQuestion",
|
|
|
|
+// "toggleSnapNow",
|
|
|
|
+// "updateExamResult",
|
|
|
|
+// "resetExamQuestionDirty",
|
|
|
|
+// "updatePicture",
|
|
|
|
+// ]),
|
|
|
|
+async function initData() {
|
|
|
|
+ logger({ cnl: ["server", "local"], pgn: "答题页面", act: "before initData" });
|
|
|
|
+ const [
|
|
|
|
+ { data: weixinAnswerEnabled },
|
|
|
|
+ { data: faceCheckEnabled },
|
|
|
|
+ { data: faceLivenessEnabled },
|
|
|
|
+ { data: examProp },
|
|
|
|
+ { data: exam },
|
|
|
|
+ { data: paperStruct },
|
|
|
|
+ { data: examQuestionListOrig },
|
|
|
|
+ { data: _courseName },
|
|
|
|
+ ] = await Promise.all([
|
|
|
|
+ httpApp.get<boolean>(
|
|
|
|
+ "/api/ecs_exam_work/exam/weixinAnswerEnabled/" + examId,
|
|
|
|
+ {
|
|
|
|
+ "axios-retry": { retries: 4 },
|
|
|
|
+ noErrorMessage: true,
|
|
|
|
+ }
|
|
|
|
+ ),
|
|
|
|
+ httpApp.get<boolean>("/api/ecs_exam_work/exam/faceCheckEnabled/" + examId, {
|
|
|
|
+ "axios-retry": { retries: 4 },
|
|
|
|
+ noErrorMessage: true,
|
|
|
|
+ }),
|
|
|
|
+ httpApp.get<boolean>(
|
|
|
|
+ "/api/ecs_exam_work/exam/identificationOfLivingEnabled/" + examId,
|
|
|
|
+ { "axios-retry": { retries: 4 }, noErrorMessage: true }
|
|
|
|
+ ),
|
|
|
|
+ // 实际上后台都是字符串 {PRACTICE_TYPE: string | null; FREEZE_TIME: string; SNAPSHOT_INTERVAL: string; }
|
|
|
|
+ httpApp.get<{
|
|
|
|
+ PRACTICE_TYPE: string | null;
|
|
|
|
+ FREEZE_TIME: number | null;
|
|
|
|
+ SNAPSHOT_INTERVAL: number;
|
|
|
|
+ }>(
|
|
|
|
+ "/api/ecs_exam_work/exam/getExamPropertyFromCacheByStudentSession/" +
|
|
|
|
+ examId +
|
|
|
|
+ `/SNAPSHOT_INTERVAL,PRACTICE_TYPE,FREEZE_TIME`,
|
|
|
|
+ { "axios-retry": { retries: 4 }, noErrorMessage: true }
|
|
|
|
+ ),
|
|
|
|
+ httpApp.get<Store["exam"]>("/api/ecs_exam_work/exam/" + examId, {
|
|
|
|
+ "axios-retry": { retries: 4 },
|
|
|
|
+ noErrorMessage: true,
|
|
|
|
+ }),
|
|
|
|
+ httpApp.get<PaperStruct>(
|
|
|
|
+ "/api/ecs_oe_student/examRecordPaperStruct/getExamRecordPaperStruct?examRecordDataId=" +
|
|
|
|
+ examRecordDataId,
|
|
|
|
+ { "axios-retry": { retries: 4 }, noErrorMessage: true }
|
|
|
|
+ ),
|
|
|
|
+ httpApp.get<ExamQuestion[]>(
|
|
|
|
+ "/api/ecs_oe_student/examQuestion/findExamQuestionList",
|
|
|
|
+ {
|
|
|
|
+ "axios-retry": { retries: 4 },
|
|
|
|
+ noErrorMessage: true,
|
|
|
|
+ }
|
|
|
|
+ ),
|
|
|
|
+ httpApp.get<string>(
|
|
|
|
+ "/api/ecs_oe_student/examControl/courseName/" + examRecordDataId,
|
|
|
|
+ { "axios-retry": { retries: 4 }, noErrorMessage: true }
|
|
|
|
+ ),
|
|
|
|
+ ]);
|
|
|
|
+ courseName = _courseName;
|
|
|
|
+
|
|
|
|
+ // if (faceLivenessEnabled) {
|
|
|
|
+ // const faceBiopsyBaseInfoData = await httpApp.get(
|
|
|
|
+ // "/api/ecs_oe_student/faceBiopsy/getFaceBiopsyBaseInfo?examRecordDataId=" +
|
|
|
|
+ // examRecordDataId,
|
|
|
|
+ // { "axios-retry": { retries: 4 }, noErrorMessage: true }
|
|
|
|
+ // );
|
|
|
|
+
|
|
|
|
+ // // 释放出去,供定时
|
|
|
|
+ // let faceVerifyMinute = null;
|
|
|
|
+ // let identificationOfLivingBodyScheme = null;
|
|
|
|
+ // faceVerifyMinute = faceBiopsyBaseInfoData.data.faceVerifyMinute;
|
|
|
|
+ // identificationOfLivingBodyScheme =
|
|
|
|
+ // faceBiopsyBaseInfoData.data.identificationOfLivingBodyScheme;
|
|
|
|
+ // }
|
|
|
|
+
|
|
|
|
+ let examQuestionList = examQuestionListOrig;
|
|
|
|
+
|
|
|
|
+ logger({
|
|
|
|
+ cnl: ["server", "local"],
|
|
|
|
+ pgn: "答题页面",
|
|
|
|
+ dtl: `end${typeof Object.fromEntries === "function" ? " " : " "}initData`,
|
|
|
|
+ });
|
|
|
|
+ logger({
|
|
|
|
+ cnl: ["server", "local"],
|
|
|
|
+ act: "答题页面dimension",
|
|
|
|
+ ext: {
|
|
|
|
+ scrollX: window.scrollX,
|
|
|
|
+ scrollY: window.scrollY,
|
|
|
|
+ width: window.screen.width,
|
|
|
|
+ height: window.screen.height,
|
|
|
|
+ screenX: window.screen.availWidth,
|
|
|
|
+ screenY: window.screen.availHeight,
|
|
|
|
+ clientWidth: document.documentElement.clientWidth,
|
|
|
|
+ clientHeight: document.documentElement.clientHeight,
|
|
|
|
+ windowInnerWidth: window.innerWidth,
|
|
|
|
+ windowInnerHeight: window.innerHeight,
|
|
|
|
+ windowOuterWidth: window.outerWidth,
|
|
|
|
+ windowOuterHeight: window.outerHeight,
|
|
|
|
+ // 是否全屏
|
|
|
|
+ equal1:
|
|
|
|
+ "dimesion1" +
|
|
|
|
+ (window.screen.width === window.outerWidth &&
|
|
|
|
+ window.screen.height === window.outerHeight),
|
|
|
|
+ // 是否打开了调试窗口
|
|
|
|
+ equal2:
|
|
|
|
+ "dimesion2" +
|
|
|
|
+ (window.innerWidth === window.outerWidth &&
|
|
|
|
+ window.innerHeight === window.outerHeight),
|
|
|
|
+ },
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ if (exam.examType === "PRACTICE") {
|
|
|
|
+ exam.practiceType = examProp.PRACTICE_TYPE as PRACTICE_TYPE;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ exam.freezeTime = JSON.parse("" + examProp.FREEZE_TIME);
|
|
|
|
+ examProp.SNAPSHOT_INTERVAL = JSON.parse("" + examProp.SNAPSHOT_INTERVAL);
|
|
|
|
+
|
|
|
|
+ exam.WEIXIN_ANSWER_ENABLED = weixinAnswerEnabled;
|
|
|
|
+
|
|
|
|
+ // if (faceCheckEnabled) {
|
|
|
|
+ // let initSnapshotTrialTimes = 0;
|
|
|
|
+ // this.initSnapInterval = setInterval(() => {
|
|
|
|
+ // const video = document.getElementById("video");
|
|
|
|
+ // const videoStartFailed =
|
|
|
|
+ // !video || video.readyState !== 4 || !video.srcObject.active;
|
|
|
|
+ // if (videoStartFailed && initSnapshotTrialTimes < 5) {
|
|
|
|
+ // initSnapshotTrialTimes++;
|
|
|
|
+ // this.logger({
|
|
|
|
+ // action: "答题页面",
|
|
|
|
+ // detail:
|
|
|
|
+ // "进入考试后60秒内抓拍-" + `(第${initSnapshotTrialTimes}次尝试)`,
|
|
|
|
+ // });
|
|
|
|
+ // } else {
|
|
|
|
+ // // 超过6次后,强行抓拍,如果抓拍不成功,则会因抓拍不成功而退出。
|
|
|
|
+ // clearInterval(this.initSnapInterval);
|
|
|
|
+
|
|
|
|
+ // if (videoStartFailed) {
|
|
|
|
+ // this.logger({
|
|
|
|
+ // action: "答题页面",
|
|
|
|
+ // detail: "摄像头没有正常启用-进入考试抓拍",
|
|
|
|
+ // });
|
|
|
|
+ // this.$Message.error({
|
|
|
|
+ // content: "摄像头没有正常启用",
|
|
|
|
+ // duration: 5,
|
|
|
|
+ // closable: true,
|
|
|
|
+ // });
|
|
|
|
+ // window._hmt.push([
|
|
|
|
+ // "_trackEvent",
|
|
|
|
+ // "摄像头框",
|
|
|
|
+ // "摄像头状态",
|
|
|
|
+ // "摄像头没有正常启用-进入考试抓拍",
|
|
|
|
+ // ]);
|
|
|
|
+
|
|
|
|
+ // this.logout("?LogoutReason=" + "摄像头没有正常启用-退出");
|
|
|
|
+ // } else {
|
|
|
|
+ // this.logger({
|
|
|
|
+ // action: "答题页面",
|
|
|
|
+ // detail:
|
|
|
|
+ // "进入考试后60秒内抓拍-" +
|
|
|
|
+ // `(第${initSnapshotTrialTimes + 1}次尝试成功)`,
|
|
|
|
+ // });
|
|
|
|
+ // this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
|
|
|
|
+ // }
|
|
|
|
+ // }
|
|
|
|
+ // }, 10 * 1000);
|
|
|
|
+
|
|
|
|
+ // // let initSnapshotTrialTimes = 0;
|
|
|
|
+ // // const initSnapshot = setTimeout(() => {
|
|
|
|
+ // // if (this.exam || initSnapshotTrialTimes < 6) {
|
|
|
|
+ // // this.toggleSnapNow(); // 开启抓拍才在进入考试时抓拍一张
|
|
|
|
+ // // } else {
|
|
|
|
+ // // setTimeout(() => initSnapshot(), 5 * 1000);
|
|
|
|
+ // // }
|
|
|
|
+ // // }, 5 * 1000);
|
|
|
|
+
|
|
|
|
+ // if (examProp.SNAPSHOT_INTERVAL) {
|
|
|
|
+ // // 考务设置抓拍间隔
|
|
|
|
+ // this.snapInterval = setInterval(() => {
|
|
|
|
+ // this.logger({
|
|
|
|
+ // action: "答题页面",
|
|
|
|
+ // detail: "定时抓拍",
|
|
|
|
+ // SNAPSHOT_INTERVAL: examProp.SNAPSHOT_INTERVAL,
|
|
|
|
+ // });
|
|
|
|
+ // this.toggleSnapNow();
|
|
|
|
+ // }, examProp.SNAPSHOT_INTERVAL * 60 * 1000);
|
|
|
|
+ // }
|
|
|
|
+ // }
|
|
|
|
+
|
|
|
|
+ logger({
|
|
|
|
+ cnl: ["server", "local"],
|
|
|
|
+ pgn: "答题页面",
|
|
|
|
+ ext: {
|
|
|
|
+ examRecordDataId: examRecordDataId,
|
|
|
|
+ faceCheckEnabled: faceCheckEnabled,
|
|
|
|
+ faceLivenessEnabled: faceLivenessEnabled,
|
|
|
|
+ WEIXIN_ANSWER_ENABLED: exam.WEIXIN_ANSWER_ENABLED,
|
|
|
|
+ SNAPSHOT_INTERVAL: examProp.SNAPSHOT_INTERVAL,
|
|
|
|
+ PRACTICE_TYPE: examProp.PRACTICE_TYPE,
|
|
|
|
+ FREEZE_TIME: examProp.FREEZE_TIME,
|
|
|
|
+ },
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ // parentQuestionBody
|
|
|
|
+ // questionUnitWrapperList
|
|
|
|
+ // questionBody => from examQuestionList
|
|
|
|
+ // questionUnitList =>
|
|
|
|
+ // studentAnswer
|
|
|
|
+ // rightAnswer
|
|
|
|
+
|
|
|
|
+ // init subNumber
|
|
|
|
+ let questionId: string | null = null;
|
|
|
|
+ let i = 1;
|
|
|
|
+
|
|
|
|
+ examQuestionList = examQuestionList.map((eq) => {
|
|
|
|
+ if (questionId == eq.questionId) {
|
|
|
|
+ eq.subNumber = i++;
|
|
|
|
+ } else {
|
|
|
|
+ i = 1;
|
|
|
|
+ questionId = eq.questionId;
|
|
|
|
+ eq.subNumber = i++;
|
|
|
|
+ }
|
|
|
|
+ return eq;
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ let groupOrder = 1;
|
|
|
|
+ let mainNumber = 0;
|
|
|
|
+ examQuestionList = examQuestionList.map((eq) => {
|
|
|
|
+ if (mainNumber == eq.mainNumber) {
|
|
|
|
+ eq.inGroupOrder = groupOrder++;
|
|
|
|
+ } else {
|
|
|
|
+ mainNumber = eq.mainNumber;
|
|
|
|
+ groupOrder = 1;
|
|
|
|
+ eq.inGroupOrder = groupOrder++;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const questionWrapperList =
|
|
|
|
+ paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1]
|
|
|
|
+ .questionWrapperList;
|
|
|
|
+ const groupName =
|
|
|
|
+ paperStruct.defaultPaper.questionGroupList[eq.mainNumber - 1].groupName;
|
|
|
|
+ const groupTotal = questionWrapperList.reduce(
|
|
|
|
+ (accumulator, questionWrapper) =>
|
|
|
|
+ accumulator + questionWrapper.questionUnitWrapperList.length,
|
|
|
|
+ 0
|
|
|
|
+ );
|
|
|
|
+
|
|
|
|
+ eq.groupName = groupName;
|
|
|
|
+ eq.groupTotal = groupTotal;
|
|
|
|
+ return eq;
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ store.exam.examQuestionList = examQuestionList.map((eq) => {
|
|
|
|
+ const paperStructQuestion = paperStruct.defaultPaper.questionGroupList[
|
|
|
|
+ eq.mainNumber - 1
|
|
|
|
+ ].questionWrapperList.find((q) => q.questionId === eq.questionId);
|
|
|
|
+ return Object.assign(eq, {
|
|
|
|
+ limitedPlayTimes: paperStructQuestion!.limitedPlayTimes,
|
|
|
|
+ });
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ Object.assign(store.exam, exam);
|
|
|
|
+ store.exam.paperStruct = paperStruct;
|
|
|
|
+ // TODO: 此处类型待优化
|
|
|
|
+ store.exam.allAudioPlayTimes =
|
|
|
|
+ JSON.parse(
|
|
|
|
+ store.exam.examQuestionList[0].audioPlayTimes as unknown as string
|
|
|
|
+ ) || [];
|
|
|
|
+
|
|
|
|
+ // this.updateExamState({
|
|
|
|
+ // exam: exam,
|
|
|
|
+ // paperStruct: paperStruct,
|
|
|
|
+ // examQuestionList: examQuestionList,
|
|
|
|
+ // allAudioPlayTimes: JSON.parse(examQuestionList[0].audioPlayTimes) || [],
|
|
|
|
+ // questionAnswerFileUrl: [],
|
|
|
|
+ // pictureAnswer: {},
|
|
|
|
+ // });
|
|
|
|
+ // console.log(examQuestionList);
|
|
|
|
+ // console.log(examQuestionList.find(v => v.answerType === "SINGLE_AUDIO"));
|
|
|
|
+
|
|
|
|
+ // const shouldOpenWS = exam.WEIXIN_ANSWER_ENABLED;
|
|
|
|
+
|
|
|
|
+ // if (shouldOpenWS) {
|
|
|
|
+ // // console.log("have single");
|
|
|
|
+ // const examRecordDataId = examRecordDataId;
|
|
|
|
+ // openWS({ examRecordDataId });
|
|
|
|
+ // }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// async function updateQuestion(next) {
|
|
|
|
+// // 初始化套题的答案,为回填部分选项做准备
|
|
|
|
+// // for (let q of this.examQuestionList) {
|
|
|
|
+// // if (q.subQuestionList.length > 0) {
|
|
|
|
+// // q.studentAnswer = [];
|
|
|
|
+// // for (let sq of q.subQuestionList) {
|
|
|
|
+// // q.studentAnswer.push(sq.studentAnswer);
|
|
|
|
+// // }
|
|
|
|
+// // }
|
|
|
|
+// // }
|
|
|
|
+
|
|
|
|
+// next && next();
|
|
|
|
+// if (!this.exam) return;
|
|
|
|
+// }
|
|
|
|
+
|
|
|
|
+// 仅在线上使用活体检测
|
|
|
|
+// if (process.env.NODE_ENV === "production" && faceVerifyMinute) {
|
|
|
|
+
|
|
|
|
+// if (faceVerifyMinute) { }
|
|
|
|
+// TODO: 活检定时,通过watch remain 来确定
|
|
|
|
+// console.log("活检定时");
|
|
|
|
+// this.logger({ action: "活检定时", detail: faceVerifyMinute });
|
|
|
|
+// const enoughTimeForFaceId = this.remainTime // 如果remainTime取到了的话
|
|
|
|
+// ? this.remainTime / (60 * 1000) - 1 > faceVerifyMinute
|
|
|
|
+// : true;
|
|
|
|
+// if (!enoughTimeForFaceId) return;
|
|
|
|
+// this.faceIdMsgTimeout = setTimeout(() => {
|
|
|
|
+// this.logger({
|
|
|
|
+// action: "答题页面",
|
|
|
|
+// detail: "活体检测前抓拍",
|
|
|
|
+// });
|
|
|
|
+// this.toggleSnapNow();
|
|
|
|
+// this.$Message.info({
|
|
|
|
+// content: "30秒后开始指定动作检测",
|
|
|
|
+// duration: 15,
|
|
|
|
+// closable: true,
|
|
|
|
+// });
|
|
|
|
+// }, faceVerifyMinute * 60 * 1000 - 30 * 1000); // 活体检测提醒
|
|
|
|
+// this.faceIdDivTimeout = setTimeout(() => {
|
|
|
|
+// if (identificationOfLivingBodyScheme === "S1") {
|
|
|
|
+// this.showFaceId = true;
|
|
|
|
+// } else if (identificationOfLivingBodyScheme === "S2") {
|
|
|
|
+// this.showFaceMotion = true;
|
|
|
|
+// }
|
|
|
|
+// }, faceVerifyMinute * 60 * 1000); // 定时做活体检测
|
|
|
|
+// // }, 1 * 1000); // 定时做活体检测
|
|
|
|
+
|
|
|
|
+// for test
|
|
|
|
+// setTimeout(() => {
|
|
|
|
+// this.showFaceId = true;
|
|
|
|
+// // this.$Modal.remove();
|
|
|
|
+// // }, this.$route.query.faceVerifyMinute * 60 * 1000); // 定时做活体检测
|
|
|
|
+// }, 5 * 1000); // 定时做活体检测
|
|
|
|
+
|
|
|
|
+// let showFaceId = $ref(false);
|
|
|
|
+// function closeFaceId() {
|
|
|
|
+// showFaceId = false;
|
|
|
|
+// }
|
|
|
|
+
|
|
|
|
+function resetExamQuestionDirty() {
|
|
|
|
+ store.exam.examQuestionList = store.exam.examQuestionList.map((eq) => {
|
|
|
|
+ return Object.assign({}, eq, { dirty: false });
|
|
|
|
+ });
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+type Answer = {
|
|
|
|
+ order: number;
|
|
|
|
+ studentAnswer: string;
|
|
|
|
+ audioPlayTimes: { audioName: string; times: number }[];
|
|
|
|
+ isSign: boolean;
|
|
|
|
+};
|
|
|
|
+async function answerAllQuestions(ignoreDirty?: boolean): Promise<boolean> {
|
|
|
|
+ const answers: Answer[] = store.exam.examQuestionList
|
|
|
|
+ .filter((eq) => (ignoreDirty ? true : eq.dirty))
|
|
|
|
+ .filter((eq) => eq.getQuestionContent)
|
|
|
|
+ .map((eq) => {
|
|
|
|
+ return Object.assign(
|
|
|
|
+ {
|
|
|
|
+ order: eq.order,
|
|
|
|
+ studentAnswer: eq.studentAnswer,
|
|
|
|
+ },
|
|
|
|
+ eq.audioPlayTimes && { audioPlayTimes: eq.audioPlayTimes },
|
|
|
|
+ eq.isSign && { isSign: eq.isSign }
|
|
|
|
+ ) as Answer;
|
|
|
|
+ });
|
|
|
|
+ if (answers.length > 0) {
|
|
|
|
+ try {
|
|
|
|
+ await httpApp.post(
|
|
|
|
+ "/api/ecs_oe_student/examQuestion/submitQuestionAnswer",
|
|
|
|
+ answers
|
|
|
|
+ );
|
|
|
|
+ resetExamQuestionDirty();
|
|
|
|
+ } catch (error) {
|
|
|
|
+ logger({
|
|
|
|
+ cnl: ["server", "local"],
|
|
|
|
+ pgu: "AUTO",
|
|
|
|
+ act: "提交答案失败",
|
|
|
|
+ possibleError: error,
|
|
|
|
+ });
|
|
|
|
+ $message.error("提交答案失败");
|
|
|
|
+ return false;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ // 提交成功,返回true,供最后提交时判断。自动提交失败,不暂停。
|
|
|
|
+ return true;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+async function submitPaper() {
|
|
|
|
+ logger({ cnl: ["server", "local", "console"], act: "学生点击交卷" });
|
|
|
|
+ try {
|
|
|
|
+ // 交卷前强制提交所有答案
|
|
|
|
+ const ret = await answerAllQuestions(true);
|
|
|
|
+ if (!ret) {
|
|
|
|
+ // 提交答案失败,停止交卷逻辑。
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+ } catch (error) {
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (
|
|
|
|
+ store.exam.freezeTime &&
|
|
|
|
+ store.exam.remainTime >
|
|
|
|
+ (store.exam.duration - store.exam.freezeTime) * 60 * 1000
|
|
|
|
+ ) {
|
|
|
|
+ $message.info(`考试开始${store.exam.freezeTime}分钟后才允许交卷。`);
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const answered = store.exam.examQuestionList.filter(
|
|
|
|
+ (q) => q.studentAnswer !== null
|
|
|
|
+ ).length;
|
|
|
|
+ const unanswered = store.exam.examQuestionList.filter(
|
|
|
|
+ (q) => q.studentAnswer === null
|
|
|
|
+ ).length;
|
|
|
|
+ const signed = store.exam.examQuestionList.filter((q) => q.isSign).length;
|
|
|
|
+ const showConfirmTime = Date.now();
|
|
|
|
+ $dialog.info({
|
|
|
|
+ title: "确认交卷",
|
|
|
|
+ content: `<p>已答题目:${answered}</p><p>未答题目:${unanswered}</p><p>标记题目:${signed}</p>`,
|
|
|
|
+ positiveText: "确定",
|
|
|
|
+ onPositiveClick: () => {
|
|
|
|
+ void realSubmitPaper(showConfirmTime);
|
|
|
|
+ },
|
|
|
|
+ });
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+function realSubmitPaper(showConfirmTime = 0) {
|
|
|
|
+ store.increaseGlobalMaskCount("realSubmitPaper");
|
|
|
|
+ store.spinMessage = "正在交卷,请耐心等待...";
|
|
|
|
+ logger({ cnl: ["server"], act: "正在交卷,请耐心等待..." });
|
|
|
|
+ if (store.exam.faceCheckEnabled) {
|
|
|
|
+ logger({ cnl: ["server"], act: "交卷前抓拍" });
|
|
|
|
+ // this.toggleSnapNow();
|
|
|
|
+ }
|
|
|
|
+ // 确保抓拍指令在交卷前执行,同时确保5秒间隔提交答案的指令执行了
|
|
|
|
+ let delay = 5 - (Date.now() - showConfirmTime) / 1000;
|
|
|
|
+ if (delay < 0) {
|
|
|
|
+ // 如果用户已经看确认框超过5秒,或者不是由确认框进来的,不延迟
|
|
|
|
+ delay = 0;
|
|
|
|
+ }
|
|
|
|
+ // 给抓拍照片多一秒处理时间
|
|
|
|
+ delay = delay + 1;
|
|
|
|
+ // 和下行注释的sleep语句不是一样的。sleep之后还可以执行。加上clearTimeout则可拒绝。
|
|
|
|
+ // await new Promise(resolve => setTimeout(() => resolve(), delay * 1000));
|
|
|
|
+ addTimeout(() => {
|
|
|
|
+ store.decreaseGlobalMaskCount("realSubmitPaper");
|
|
|
|
+ store.spinMessage = "";
|
|
|
|
+ void router.push({
|
|
|
|
+ name: "SubmitPaper",
|
|
|
|
+ params: { examId, examRecordDataId },
|
|
|
|
+ });
|
|
|
|
+ }, delay * 1000);
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+function shouldSubmitPaper() {
|
|
|
|
+ logger({ cnl: ["server"], act: "时间到自动交卷" });
|
|
|
|
+ void router.push({
|
|
|
|
+ name: "SubmitPaper",
|
|
|
|
+ params: { examId, examRecordDataId },
|
|
|
|
+ });
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// const examQuestion = $computed(() =>
|
|
|
|
+// store.exam.examQuestionList?.find(
|
|
|
|
+// (eq) => eq.order == route.params.order /*number == string*/
|
|
|
|
+// )
|
|
|
|
+// );
|
|
|
|
+
|
|
|
|
+function reloadPage() {
|
|
|
|
+ logger({
|
|
|
|
+ cnl: ["server", "local"],
|
|
|
|
+ pgn: "答题页面",
|
|
|
|
+ act: "点击重试按钮",
|
|
|
|
+ dtl: "答题页面加载失败",
|
|
|
|
+ });
|
|
|
|
+ window.location.reload();
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+const {
|
|
|
|
+ disableLoginBtnBecauseRemoteApp: disableExamingBecauseRemoteApp,
|
|
|
|
+ checkRemoteAppTxt: checkRemoteApp,
|
|
|
|
+} = useRemoteAppChecker();
|
|
|
|
+console.log({
|
|
|
|
+ disableExamingBecauseRemoteApp: disableExamingBecauseRemoteApp.value,
|
|
|
|
+});
|
|
|
|
+
|
|
|
|
+function checkRemoteAppClicked() {
|
|
|
|
+ logger({ cnl: ["server"], pgu: "AUTO", act: "点击确认已关闭远程桌面软件" });
|
|
|
|
+ void checkRemoteApp();
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// 3分钟检测是否有远程桌面软件在运行
|
|
|
|
+addInterval(() => checkRemoteApp(), 3 * 60 * 1000);
|
|
|
|
+</script>
|
|
|
|
+
|
|
|
|
+<template>
|
|
|
|
+ <div v-if="!loading" class="container">
|
|
|
|
+ <div class="header">
|
|
|
|
+ <RemainTime @onEndtime="shouldSubmitPaper"></RemainTime>
|
|
|
|
+ <div style="display: flex; flex-direction: column">
|
|
|
|
+ <div style="margin-bottom: 12px">{{ courseName }}</div>
|
|
|
|
+ <OverallProgress></OverallProgress>
|
|
|
|
+ </div>
|
|
|
|
+ <div>
|
|
|
|
+ {{ store.user.displayName }} -
|
|
|
|
+ {{ store.user.studentCodeList.join(",") }}
|
|
|
|
+ </div>
|
|
|
|
+ <QuestionFilters></QuestionFilters>
|
|
|
|
+ <n-button type="success" @click="submitPaper">交卷</n-button>
|
|
|
|
+ </div>
|
|
|
|
+ <div id="examing-home-question" class="main">
|
|
|
|
+ <!-- <QuestionView :examQuestion="examQuestion()"></QuestionView> -->
|
|
|
|
+ <ArrowNavView></ArrowNavView>
|
|
|
|
+ </div>
|
|
|
|
+ <div :class="['side']">
|
|
|
|
+ <div :class="['question-nav']">
|
|
|
|
+ <QuestionNavView />
|
|
|
|
+ </div>
|
|
|
|
+ <div v-if="store.exam.faceCheckEnabled" class="camera">
|
|
|
|
+ <FaceRecognition
|
|
|
|
+ width="400"
|
|
|
|
+ height="300"
|
|
|
|
+ :showRecognizeButton="false"
|
|
|
|
+ />
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+ <!-- <Modal
|
|
|
|
+ v-model="showFaceId"
|
|
|
|
+ :maskClosable="false"
|
|
|
|
+ :closable="false"
|
|
|
|
+ width="800"
|
|
|
|
+ :styles="{ top: '10px' }"
|
|
|
|
+ >
|
|
|
|
+ <FaceId v-if="showFaceId" @closeFaceid="closeFaceId" />
|
|
|
|
+ <p slot="footer"></p>
|
|
|
|
+ </Modal> -->
|
|
|
|
+ <FaceTracking v-if="store.exam.faceCheckEnabled" />
|
|
|
|
+ <div
|
|
|
|
+ v-if="disableExamingBecauseRemoteApp"
|
|
|
|
+ style="
|
|
|
|
+ top: 0;
|
|
|
|
+ left: 0;
|
|
|
|
+ width: 100vw;
|
|
|
|
+ height: 100vh;
|
|
|
|
+ background-color: rgba(77, 77, 77, 0.95);
|
|
|
|
+ z-index: 100;
|
|
|
|
+ position: absolute;
|
|
|
|
+ "
|
|
|
|
+ >
|
|
|
|
+ <div
|
|
|
|
+ class="tw-flex tw-flex-col tw-justify-center tw-items-center tw-text-center tw-h-full"
|
|
|
|
+ >
|
|
|
|
+ <h3 class="tw-my-8 tw-text-2xl">请关闭远程桌面软件后再进行考试!</h3>
|
|
|
|
+ <n-button type="success" @click="checkRemoteAppClicked">
|
|
|
|
+ 确认已关闭远程桌面软件
|
|
|
|
+ </n-button>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+ <div v-else class="tw-text-center tw-my-4 tw-text-lg">
|
|
|
|
+ 正在等待数据返回...
|
|
|
|
+ <br />
|
|
|
|
+ <n-button v-if="pageLoadTimeout" type="success" @click="reloadPage">
|
|
|
|
+ 重试
|
|
|
|
+ </n-button>
|
|
|
|
+ </div>
|
|
|
|
+</template>
|
|
|
|
+
|
|
|
|
+<style scoped>
|
|
|
|
+.container {
|
|
|
|
+ display: grid;
|
|
|
|
+ grid-template-areas:
|
|
|
|
+ "header header"
|
|
|
|
+ "main side";
|
|
|
|
+ grid-template-rows: 80px minmax(0, 1fr);
|
|
|
|
+ grid-template-columns: 1fr 400px;
|
|
|
|
+
|
|
|
|
+ height: 100vh;
|
|
|
|
+ width: 100vw;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.header {
|
|
|
|
+ display: grid;
|
|
|
|
+ align-items: center;
|
|
|
|
+ justify-items: center;
|
|
|
|
+ grid-template-columns: 200px 280px 1fr 300px 100px;
|
|
|
|
+ grid-area: header;
|
|
|
|
+ height: 80px;
|
|
|
|
+ background-color: #f5f5f5;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.main {
|
|
|
|
+ display: grid;
|
|
|
|
+ grid-area: main;
|
|
|
|
+ grid-template-rows: 1fr 50px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.side {
|
|
|
|
+ display: grid;
|
|
|
|
+ grid-area: side;
|
|
|
|
+ grid-template-rows: 1fr;
|
|
|
|
+ background-color: #f5f5f5;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.question-nav {
|
|
|
|
+ overflow-y: scroll;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.camera {
|
|
|
|
+ z-index: 100;
|
|
|
|
+ height: 300px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+@media screen and (max-height: 768px) {
|
|
|
|
+ .container {
|
|
|
|
+ grid-template-rows: 50px minmax(0, 1fr);
|
|
|
|
+ }
|
|
|
|
+ .header {
|
|
|
|
+ height: 50px;
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+@media screen and (max-width: 960px) {
|
|
|
|
+ .header {
|
|
|
|
+ overflow-x: scroll;
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+</style>
|
|
|
|
+
|
|
|
|
+<style>
|
|
|
|
+#examing-home-question img {
|
|
|
|
+ max-width: 100%;
|
|
|
|
+ height: auto !important;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.hide-body-scroll {
|
|
|
|
+ overflow: hidden !important;
|
|
|
|
+}
|
|
|
|
+</style>
|