123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362 |
- <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 { 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, onUnmounted, watch, reactive } from "vue";
- import { useRoute } from "vue-router";
- import { store } from "@/store/store";
- import { useRemoteAppChecker } from "@/features/UserLogin/useRemoteAppChecker";
- import { useScreenTop } from "./setups/useScreenTop";
- import { useFaceLive } from "./setups/useFaceLive";
- import { useFaceCompare } from "./setups/useFaceCompare";
- import { initExamData } from "./setups/useInitExamData";
- import { useWXSocket } from "./setups/useWXSocket";
- import { answerAllQuestions } from "./setups/useAnswerQuestions";
- import { useRealSubmitPaper } from "./setups/useSubmitPaper";
- import { Store } from "@/types/student-client";
- import { closeMediaStream } from "@/utils/camera";
- // 清除过时考试数据
- store.exam = {} as Store["exam"];
- store.exam.compareResultMap = new Map();
- const { addTimeout, addInterval } = useTimers();
- let loading = $ref(true);
- let usedExamTimes = reactive({ usedExamSeconds: 0, startTimestamp: Date.now() });
- const route = useRoute();
- const examId = +route.params.examId;
- const examRecordDataId = +route.params.examRecordDataId;
- store.exam.examId = examId;
- store.exam.examRecordDataId = examRecordDataId;
- //#region 人脸抓拍与活体检测
- let { snapId, doSnap, showSnapResult } = useFaceCompare();
- let { showFaceId } = useFaceLive(doSnap);
- useScreenTop(examRecordDataId);
- useWXSocket();
- const { userSubmitPaper, realSubmitPaper } = useRealSubmitPaper(
- examId,
- examRecordDataId,
- doSnap
- );
- async function userClickSubmit() {
- store.increaseGlobalMaskCount("userClickSubmit");
- void userSubmitPaper(usedExamTimes);
- // 一定要在这里等待,不然用户快速双击就会点两次
- await new Promise((res) => setTimeout(res, 1000));
- store.decreaseGlobalMaskCount("userClickSubmit");
- }
- onBeforeUpdate(() => {
- _hmt.push(["_trackEvent", "答题页面", "题目切换"]);
- void answerAllQuestions();
- });
- watch(
- () => [route.params, store.exam.examQuestionList],
- () => {
- if (!store.exam.examQuestionList) return;
- const q = store.exam.examQuestionList.find(
- (eq) => eq.order === +route.params.order
- );
- store.exam.currentQuestion = q!;
- }
- );
- // computed: {
- // ...mapState([
- // "uploadModalVisible",
- // ]),
- // },
- // beforeDestroy() {
- // this.updateExamState({
- // pictureAnswer: {},
- // });
- // // 避免macos上下塘动。避免产生滚动条。
- // document.body.classList.toggle("hide-body-scroll", false);
- // },
- onMounted(async () => {
- logger({
- cnl: ["server", "local"],
- pgn: "答题页面",
- act: "进入答题页面-created",
- pgu: "AUTO",
- });
- try {
- await initExamData(examId, examRecordDataId);
- loading = false;
- } catch (error) {
- logger({
- cnl: ["server"],
- pgn: "答题页面",
- act: "获取考试和试卷信息失败,退出登录",
- });
- showLogout("获取考试和试卷信息失败,退出登录");
- return;
- }
- logger({
- cnl: ["server"],
- pgu: "AUTO",
- act: "考试开始",
- dtl: "数据初始化完成",
- });
- });
- /** 开始作答 */
- function onStartAnswer({
- usedExamSeconds: n = 0,
- }: {
- usedExamSeconds: number;
- }) {
- usedExamTimes.usedExamSeconds = n
- usedExamTimes.startTimestamp = Date.now()
- }
- type CompareResult = { hasError: boolean; fileName: string };
- function onCompareResult({ hasError, fileName }: CompareResult) {
- if (hasError) {
- // 60秒后重试抓拍
- addTimeout(doSnap, 60 * 1000);
- } else {
- store.exam.compareResultMap.set(fileName, false);
- void showSnapResult(fileName, examRecordDataId);
- }
- }
- //#endregion 人脸抓拍与活体检测
- onUnmounted(() => {
- if (store.exam.faceCheckEnabled) {
- // debug级别备选。因为初期上线,摄像头比较容易出错,所以保留此日志
- logger({
- cnl: ["server", "console"],
- pgn: "答题页面",
- act: "关闭sharedtream",
- });
- closeMediaStream();
- }
- });
- //#region 提交答案与交卷
- // 10秒检查是否有更改需要提交答案
- addInterval(() => answerAllQuestions(), 5 * 1000);
- function shouldSubmitPaper() {
- logger({ cnl: ["server"], act: "时间到自动交卷" });
- void realSubmitPaper();
- }
- //#endregion 提交答案与交卷
- //#region 页面加载失败
- let pageLoadTimeout = $ref(false);
- addTimeout(() => (pageLoadTimeout = true), 30 * 1000);
- function reloadPage() {
- logger({
- cnl: ["server", "local"],
- pgn: "答题页面",
- act: "点击重试按钮",
- dtl: "答题页面加载失败",
- });
- window.location.reload();
- }
- //#endregion 页面加载失败
- //#region 防作弊检查
- watch(
- () => store.exam.isExceededSwitchCount,
- () => {
- logger({ cnl: ["server"], act: "切屏超出次数自动交卷" });
- void realSubmitPaper();
- }
- );
- 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);
- const {
- disableLoginBtnBecauseRemoteApp: disableExamingBecauseRemoteApp,
- checkRemoteAppTxt: checkRemoteApp,
- } = useRemoteAppChecker();
- function checkRemoteAppClicked() {
- logger({ cnl: ["server"], pgu: "AUTO", act: "点击确认已关闭远程桌面软件" });
- void checkRemoteApp();
- }
- // 3分钟检测是否有远程桌面软件在运行
- addInterval(() => checkRemoteApp(), 3 * 60 * 1000);
- //#endregion 防作弊检查
- const userInfo = $computed(
- () => store.user.displayName + " - " + store.user.studentCodeList.join(",")
- );
- </script>
- <template>
- <div v-if="!loading" class="container">
- <div class="header">
- <RemainTime
- @onEndtime="shouldSubmitPaper"
- @onStartAnswer="onStartAnswer"
- ></RemainTime>
- <div class="tw-flex tw-flex-wrap tw-justify-between">
- <div>{{ store.exam.courseName }}</div>
- <OverallProgress></OverallProgress>
- </div>
- <div
- :style="{
- whiteSpace: userInfo.length >= 23 ? 'normal' : 'nowrap',
- }"
- >
- {{ userInfo }}
- </div>
- <QuestionFilters></QuestionFilters>
- <n-button type="success" @click="userClickSubmit">交卷</n-button>
- </div>
- <div id="examing-home-question" class="main">
- <QuestionView />
- <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"
- :examRecordDataId="examRecordDataId"
- :snapId="snapId"
- @onAsyncRecognizeResult="onCompareResult"
- />
- </div>
- </div>
- <FaceId v-if="showFaceId" @closeFaceid="showFaceId = false" />
- <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: minmax(60px, 60px) minmax(0, 1fr);
- grid-template-columns: 1fr 400px;
- height: 100vh;
- /* width: 100vw; */
- }
- .header {
- display: grid;
- align-items: center;
- justify-items: center;
- grid-template-columns: minmax(100px, 200px) minmax(200px, 500px) 1fr 300px 100px;
- grid-area: header;
- 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: auto;
- }
- }
- </style>
- <style>
- #examing-home-question img {
- max-width: 100%;
- height: auto !important;
- }
- .hide-body-scroll {
- overflow: hidden !important;
- }
- </style>
|