ExamingHome.vue 9.6 KB

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