ExamingHome.vue 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. <script setup lang="ts">
  2. import RemainTime from "./RemainTime.vue";
  3. import OverallProgress from "./OverallProgress.vue";
  4. import QuestionFilters from "./QuestionFilters.vue";
  5. import QuestionView from "./QuestionView.vue";
  6. import ArrowNavView from "./ArrowNavView.vue";
  7. import QuestionNavView from "./QuestionNavView.vue";
  8. import FaceTracking from "./FaceTracking.vue";
  9. import FaceId from "./FaceId.vue";
  10. // import FaceMotion from "./FaceMotion/FaceMotion";
  11. import FaceRecognition from "../FaceRecognition.vue";
  12. import { STRICT_CHECK_HOSTS } from "@/constants/constants";
  13. import { httpApp } from "@/plugins/axiosApp";
  14. import { useTimers } from "@/setups/useTimers";
  15. import { checkMainExe } from "@/utils/nativeMethods";
  16. import { showLogout } from "@/utils/utils";
  17. import { onBeforeUpdate, onMounted, onUnmounted, watch, reactive } from "vue";
  18. import { useRoute } from "vue-router";
  19. import { store } from "@/store/store";
  20. import { useRemoteAppChecker } from "@/features/UserLogin/useRemoteAppChecker";
  21. import { useScreenTop } from "./setups/useScreenTop";
  22. import { useFaceLive } from "./setups/useFaceLive";
  23. import { useFaceCompare } from "./setups/useFaceCompare";
  24. import { initExamData } from "./setups/useInitExamData";
  25. import { useWXSocket } from "./setups/useWXSocket";
  26. import { answerAllQuestions } from "./setups/useAnswerQuestions";
  27. import { useRealSubmitPaper } from "./setups/useSubmitPaper";
  28. import { Store } from "@/types/student-client";
  29. import { closeMediaStream } from "@/utils/camera";
  30. // 清除过时考试数据
  31. store.exam = {} as Store["exam"];
  32. store.exam.compareResultMap = new Map();
  33. const { addTimeout, addInterval } = useTimers();
  34. let loading = $ref(true);
  35. let usedExamTimes = reactive({ usedExamSeconds: 0, startTimestamp: Date.now() });
  36. const route = useRoute();
  37. const examId = +route.params.examId;
  38. const examRecordDataId = +route.params.examRecordDataId;
  39. store.exam.examId = examId;
  40. store.exam.examRecordDataId = examRecordDataId;
  41. //#region 人脸抓拍与活体检测
  42. let { snapId, doSnap, showSnapResult } = useFaceCompare();
  43. let { showFaceId } = useFaceLive(doSnap);
  44. useScreenTop(examRecordDataId);
  45. useWXSocket();
  46. const { userSubmitPaper, realSubmitPaper } = useRealSubmitPaper(
  47. examId,
  48. examRecordDataId,
  49. doSnap
  50. );
  51. async function userClickSubmit() {
  52. store.increaseGlobalMaskCount("userClickSubmit");
  53. void userSubmitPaper(usedExamTimes);
  54. // 一定要在这里等待,不然用户快速双击就会点两次
  55. await new Promise((res) => setTimeout(res, 1000));
  56. store.decreaseGlobalMaskCount("userClickSubmit");
  57. }
  58. onBeforeUpdate(() => {
  59. _hmt.push(["_trackEvent", "答题页面", "题目切换"]);
  60. void answerAllQuestions();
  61. });
  62. watch(
  63. () => [route.params, store.exam.examQuestionList],
  64. () => {
  65. if (!store.exam.examQuestionList) return;
  66. const q = store.exam.examQuestionList.find(
  67. (eq) => eq.order === +route.params.order
  68. );
  69. store.exam.currentQuestion = q!;
  70. }
  71. );
  72. // computed: {
  73. // ...mapState([
  74. // "uploadModalVisible",
  75. // ]),
  76. // },
  77. // beforeDestroy() {
  78. // this.updateExamState({
  79. // pictureAnswer: {},
  80. // });
  81. // // 避免macos上下塘动。避免产生滚动条。
  82. // document.body.classList.toggle("hide-body-scroll", false);
  83. // },
  84. onMounted(async () => {
  85. logger({
  86. cnl: ["server", "local"],
  87. pgn: "答题页面",
  88. act: "进入答题页面-created",
  89. pgu: "AUTO",
  90. });
  91. try {
  92. await initExamData(examId, examRecordDataId);
  93. loading = false;
  94. } catch (error) {
  95. logger({
  96. cnl: ["server"],
  97. pgn: "答题页面",
  98. act: "获取考试和试卷信息失败,退出登录",
  99. });
  100. showLogout("获取考试和试卷信息失败,退出登录");
  101. return;
  102. }
  103. logger({
  104. cnl: ["server"],
  105. pgu: "AUTO",
  106. act: "考试开始",
  107. dtl: "数据初始化完成",
  108. });
  109. });
  110. /** 开始作答 */
  111. function onStartAnswer({
  112. usedExamSeconds: n = 0,
  113. }: {
  114. usedExamSeconds: number;
  115. }) {
  116. usedExamTimes.usedExamSeconds = n
  117. usedExamTimes.startTimestamp = Date.now()
  118. }
  119. type CompareResult = { hasError: boolean; fileName: string };
  120. function onCompareResult({ hasError, fileName }: CompareResult) {
  121. if (hasError) {
  122. // 60秒后重试抓拍
  123. addTimeout(doSnap, 60 * 1000);
  124. } else {
  125. store.exam.compareResultMap.set(fileName, false);
  126. void showSnapResult(fileName, examRecordDataId);
  127. }
  128. }
  129. //#endregion 人脸抓拍与活体检测
  130. onUnmounted(() => {
  131. if (store.exam.faceCheckEnabled) {
  132. // debug级别备选。因为初期上线,摄像头比较容易出错,所以保留此日志
  133. logger({
  134. cnl: ["server", "console"],
  135. pgn: "答题页面",
  136. act: "关闭sharedtream",
  137. });
  138. closeMediaStream();
  139. }
  140. });
  141. //#region 提交答案与交卷
  142. // 10秒检查是否有更改需要提交答案
  143. addInterval(() => answerAllQuestions(), 5 * 1000);
  144. function shouldSubmitPaper() {
  145. logger({ cnl: ["server"], act: "时间到自动交卷" });
  146. void realSubmitPaper();
  147. }
  148. //#endregion 提交答案与交卷
  149. //#region 页面加载失败
  150. let pageLoadTimeout = $ref(false);
  151. addTimeout(() => (pageLoadTimeout = true), 30 * 1000);
  152. function reloadPage() {
  153. logger({
  154. cnl: ["server", "local"],
  155. pgn: "答题页面",
  156. act: "点击重试按钮",
  157. dtl: "答题页面加载失败",
  158. });
  159. window.location.reload();
  160. }
  161. //#endregion 页面加载失败
  162. //#region 防作弊检查
  163. watch(
  164. () => store.exam.isExceededSwitchCount,
  165. () => {
  166. logger({ cnl: ["server"], act: "切屏超出次数自动交卷" });
  167. void realSubmitPaper();
  168. }
  169. );
  170. addTimeout(() => {
  171. if (STRICT_CHECK_HOSTS.includes(window.location.hostname)) {
  172. if (!checkMainExe()) {
  173. void httpApp.post("/api/ecs_oe_student/client/exam/process/discipline");
  174. logger({ cnl: ["server"], act: "答题页面discipline" });
  175. }
  176. }
  177. }, 60 * 1000);
  178. const {
  179. disableLoginBtnBecauseRemoteApp: disableExamingBecauseRemoteApp,
  180. checkRemoteAppTxt: checkRemoteApp,
  181. } = useRemoteAppChecker();
  182. function checkRemoteAppClicked() {
  183. logger({ cnl: ["server"], pgu: "AUTO", act: "点击确认已关闭远程桌面软件" });
  184. void checkRemoteApp();
  185. }
  186. // 3分钟检测是否有远程桌面软件在运行
  187. addInterval(() => checkRemoteApp(), 3 * 60 * 1000);
  188. //#endregion 防作弊检查
  189. const userInfo = $computed(
  190. () => store.user.displayName + " - " + store.user.studentCodeList.join(",")
  191. );
  192. </script>
  193. <template>
  194. <div v-if="!loading" class="container">
  195. <div class="header">
  196. <RemainTime
  197. @onEndtime="shouldSubmitPaper"
  198. @onStartAnswer="onStartAnswer"
  199. ></RemainTime>
  200. <div class="tw-flex tw-flex-wrap tw-justify-between">
  201. <div>{{ store.exam.courseName }}</div>
  202. <OverallProgress></OverallProgress>
  203. </div>
  204. <div
  205. :style="{
  206. whiteSpace: userInfo.length >= 23 ? 'normal' : 'nowrap',
  207. }"
  208. >
  209. {{ userInfo }}
  210. </div>
  211. <QuestionFilters></QuestionFilters>
  212. <n-button type="success" @click="userClickSubmit">交卷</n-button>
  213. </div>
  214. <div id="examing-home-question" class="main">
  215. <QuestionView />
  216. <ArrowNavView />
  217. </div>
  218. <div class="side">
  219. <div class="question-nav">
  220. <QuestionNavView />
  221. </div>
  222. <div v-if="store.exam.faceCheckEnabled" class="camera">
  223. <FaceRecognition
  224. width="400"
  225. height="300"
  226. :showRecognizeButton="false"
  227. :examRecordDataId="examRecordDataId"
  228. :snapId="snapId"
  229. @onAsyncRecognizeResult="onCompareResult"
  230. />
  231. </div>
  232. </div>
  233. <FaceId v-if="showFaceId" @closeFaceid="showFaceId = false" />
  234. <FaceTracking v-if="store.exam.faceCheckEnabled" />
  235. <div
  236. v-if="disableExamingBecauseRemoteApp"
  237. style="
  238. top: 0;
  239. left: 0;
  240. width: 100vw;
  241. height: 100vh;
  242. background-color: rgba(77, 77, 77, 0.95);
  243. z-index: 100;
  244. position: absolute;
  245. "
  246. >
  247. <div
  248. class="tw-flex tw-flex-col tw-justify-center tw-items-center tw-text-center tw-h-full"
  249. >
  250. <h3 class="tw-my-8 tw-text-2xl">请关闭远程桌面软件后再进行考试!</h3>
  251. <n-button type="success" @click="checkRemoteAppClicked">
  252. 确认已关闭远程桌面软件
  253. </n-button>
  254. </div>
  255. </div>
  256. </div>
  257. <div v-else class="tw-text-center tw-my-4 tw-text-lg">
  258. 正在等待数据返回...
  259. <br />
  260. <n-button v-if="pageLoadTimeout" type="success" @click="reloadPage">
  261. 重试
  262. </n-button>
  263. </div>
  264. </template>
  265. <style scoped>
  266. .container {
  267. display: grid;
  268. grid-template-areas:
  269. "header header"
  270. "main side";
  271. grid-template-rows: minmax(60px, 60px) minmax(0, 1fr);
  272. grid-template-columns: 1fr 400px;
  273. height: 100vh;
  274. /* width: 100vw; */
  275. }
  276. .header {
  277. display: grid;
  278. align-items: center;
  279. justify-items: center;
  280. grid-template-columns: minmax(100px, 200px) minmax(200px, 500px) 1fr 300px 100px;
  281. grid-area: header;
  282. background-color: #f5f5f5;
  283. }
  284. .main {
  285. display: grid;
  286. grid-area: main;
  287. grid-template-rows: 1fr 50px;
  288. }
  289. .side {
  290. display: grid;
  291. grid-area: side;
  292. grid-template-rows: 1fr;
  293. background-color: #f5f5f5;
  294. }
  295. .question-nav {
  296. overflow-y: scroll;
  297. }
  298. .camera {
  299. z-index: 100;
  300. height: 300px;
  301. }
  302. @media screen and (max-height: 768px) {
  303. .container {
  304. grid-template-rows: 50px minmax(0, 1fr);
  305. }
  306. .header {
  307. height: 50px;
  308. }
  309. }
  310. @media screen and (max-width: 960px) {
  311. .header {
  312. overflow-x: auto;
  313. }
  314. }
  315. </style>
  316. <style>
  317. #examing-home-question img {
  318. max-width: 100%;
  319. height: auto !important;
  320. }
  321. .hide-body-scroll {
  322. overflow: hidden !important;
  323. }
  324. </style>