StartExamModal.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. <script setup lang="ts">
  2. import OnlineExamFaceCheckModal from "./OnlineExamFaceCheckModal.vue";
  3. import CommittmentDialog from "./CommittmentDialog.vue";
  4. import { OnlineExam } from "@/types/student-client";
  5. import CheckComputer from "./CheckComputer.vue";
  6. import { onMounted, onUnmounted, watch } from "vue";
  7. import { httpApp } from "@/plugins/axiosApp";
  8. import router from "@/router";
  9. import { checkExamInProgress } from "../UserLogin/useExamInProgress";
  10. import { store } from "@/store/store";
  11. import { closeMediaStream } from "@/utils/camera";
  12. import { getKey } from "@/utils/utils";
  13. import { AxiosError } from "axios";
  14. import { doDec, doEnc } from "@/utils/encDec";
  15. const { course } = defineProps<{ course: OnlineExam }>();
  16. const emit = defineEmits<{ (e: "on-unselect-course"): void }>();
  17. /** 采用状态机,根据每一步的状态来执行初始化,以及跳转
  18. * 驱动步骤向前分两类:
  19. * 1. 自动跳转在watch里面
  20. * 2. 用户事件跳转,通过组件注册事件
  21. */
  22. const STEPS = [
  23. "COMMITTMENT",
  24. "CHECK_ENV_1",
  25. "CHECK_ENV_2",
  26. "CHECK_FACE",
  27. "CHECK_FACE_COMMITTMENT",
  28. ] as const;
  29. let curentStep = $ref(-1);
  30. // 为了页面在跳过此步骤是不出现此步骤的页面,所以加上这个变量来控制modal.show
  31. let showCheckEnv1 = $ref(false);
  32. watch(
  33. () => curentStep,
  34. async (step, oldStep) => {
  35. logger({
  36. cnl: ["server", "console"],
  37. act: "StartExamModal",
  38. ext: {
  39. currentStep: STEPS[step],
  40. oldStep: STEPS[oldStep],
  41. },
  42. });
  43. if (STEPS[step] === "COMMITTMENT") {
  44. if (!course.showUndertaking) curentStep++;
  45. }
  46. if (STEPS[step] === "CHECK_ENV_1") {
  47. if (!(await isGetCheckEnvOk())) {
  48. emit("on-unselect-course");
  49. return;
  50. }
  51. if (!serverWantCheckEnv) curentStep += 2;
  52. showCheckEnv1 = true;
  53. }
  54. if (STEPS[step] === "CHECK_FACE") {
  55. if (course.faceEnable) {
  56. if (!store.user.photoPath) {
  57. logger({ cnl: ["server"], act: "无底照" });
  58. $message.info(
  59. "本场考试需要进行人脸检测,但是您没有上传底照,请联系老师!"
  60. );
  61. emit("on-unselect-course");
  62. return true;
  63. }
  64. if (!(await isGetFaceLivenessOk())) {
  65. emit("on-unselect-course");
  66. return true;
  67. }
  68. } else {
  69. curentStep++;
  70. await enterExam();
  71. }
  72. }
  73. }
  74. );
  75. function applyCommittmentResult(isCommitted: boolean) {
  76. if (!isCommitted) {
  77. emit("on-unselect-course");
  78. } else {
  79. curentStep++;
  80. }
  81. }
  82. let serverWantCheckEnv = $ref(false);
  83. function skipCheckEnv() {
  84. curentStep += 2;
  85. }
  86. function doCheckEnv() {
  87. curentStep++;
  88. }
  89. async function isIPOk(): Promise<boolean> {
  90. logger({ cnl: ["server"], act: "正在检测IP合法性..." });
  91. try {
  92. const ipLimit = (
  93. await httpApp.get("/api/ecs_exam_work/exam/ipLimit/" + course.examId)
  94. ).data;
  95. if (ipLimit.limited) {
  96. logger({ cnl: ["server"], act: "IP受限,请到中心指定地点进行考试!" });
  97. $message.error("IP受限,请到中心指定地点进行考试!");
  98. return false;
  99. }
  100. } catch (error) {
  101. $message.error("查询IP限制出错!");
  102. logger({ cnl: ["server"], act: "查询IP限制出错!" });
  103. return false;
  104. }
  105. return true;
  106. }
  107. async function isGetCheckEnvOk(): Promise<boolean> {
  108. try {
  109. let checkEnv = await httpApp.get(
  110. "/api/ecs_exam_work/exam/getExamPropertyFromCacheByStudentSession/" +
  111. course.examId +
  112. `/CHECK_ENVIRONMENT`
  113. );
  114. if (checkEnv.data.CHECK_ENVIRONMENT === "true") {
  115. serverWantCheckEnv = true;
  116. }
  117. } catch (error) {
  118. logger({
  119. cnl: ["server"],
  120. act: "获取CHECK_ENVIRONMENT失败",
  121. });
  122. $message.warning("网络错误,请重试!");
  123. // 获取不到流程必要的参数,就中断流程
  124. return false;
  125. }
  126. return true;
  127. }
  128. async function isGetFaceLivenessOk(): Promise<boolean> {
  129. let faceLiveness = null;
  130. try {
  131. faceLiveness = await httpApp.get(
  132. "/api/ecs_exam_work/exam/identificationOfLivingEnabled/" + course.examId
  133. );
  134. } catch (error) {
  135. $message.error("查询考试的人脸检测设置属性出错!");
  136. logger({ cnl: ["server"], act: "查询考试的人脸检测设置属性出错!" });
  137. return false;
  138. }
  139. if (faceLiveness.data) {
  140. logger({ cnl: ["server"], act: "正在检测底照是否满足活体检测标准..." });
  141. let checkBasePhoto;
  142. try {
  143. checkBasePhoto = (
  144. await httpApp.get(
  145. "/api/ecs_oe_student/examFaceLivenessVerify/checkFaceLiveness" +
  146. `?${store.user.token}` // 考生采用相同的机器考试,使用不同的请求的缓存
  147. )
  148. ).data;
  149. if (!checkBasePhoto.success) {
  150. $message.error("您上传的底照不符合活体检测的要求,请联系老师!");
  151. logger({
  152. cnl: ["server"],
  153. act: "您上传的底照不符合活体检测的要求,请联系老师!",
  154. });
  155. return false;
  156. }
  157. } catch (error) {
  158. $message.error("网络错误,请稍后重试!");
  159. logger({
  160. cnl: ["server"],
  161. act: "查询检测底照是否满足活体检测标准的接口出错!",
  162. possibleError: error,
  163. });
  164. return false;
  165. }
  166. }
  167. return true;
  168. }
  169. onMounted(async () => {
  170. if (!(await isIPOk())) {
  171. emit("on-unselect-course");
  172. return true;
  173. }
  174. if (await checkExamInProgress().catch(() => true)) {
  175. // 检测到有断点,或者断点有异常,退出当前流程
  176. emit("on-unselect-course");
  177. return;
  178. }
  179. curentStep = 0;
  180. });
  181. function onFacePassed(isRealStudent: boolean) {
  182. if (isRealStudent) {
  183. void enterExam();
  184. } else {
  185. if (!course.faceCheck) curentStep++;
  186. }
  187. }
  188. function onFaceClosedByUser() {
  189. emit("on-unselect-course");
  190. }
  191. function disagreeCommittment() {
  192. emit("on-unselect-course");
  193. }
  194. let passedAllChecks = false;
  195. async function enterExam() {
  196. let hstate = { examStudentId: course.examStudentId };
  197. /** 为了代码加密混淆效果更好,隐藏在线考试开始考试的启动逻辑,将在线考试启动接口调用放在该组件内部 */
  198. if (course.examType === "ONLINE") {
  199. const timestamp = Date.now();
  200. const rawStr = JSON.stringify({
  201. examStudentId: course.examStudentId,
  202. timestamp,
  203. });
  204. const key = getKey(timestamp);
  205. let encryptParams = "";
  206. encryptParams = doEnc(rawStr, key);
  207. try {
  208. let res = await httpApp.post<string>(
  209. "/api/ecs_oe_student/examControl/online/startExam",
  210. encryptParams,
  211. {
  212. "axios-retry": { retries: 4 },
  213. noErrorMessage: true,
  214. headers: {
  215. timestamp,
  216. "Content-Type": "text/plain",
  217. accept: "text/plain",
  218. },
  219. responseType: "text",
  220. transitional: { forcedJSONParsing: false },
  221. }
  222. );
  223. res.data = doDec(res.data, key);
  224. const newRes: {
  225. courseName: string;
  226. examRecordDataId: number;
  227. } = JSON.parse(res.data);
  228. Object.assign(hstate, {
  229. examRecordDataId: newRes.examRecordDataId,
  230. courseName: encodeURIComponent(newRes.courseName),
  231. });
  232. } catch (error) {
  233. // console.log(error, error.response, error.isAxiosError);
  234. if ("isAxiosError" in <any>error) {
  235. const ne = <AxiosError>error;
  236. const jsonRes = JSON.parse(<string>ne.response?.data ?? "null");
  237. const desc: string = jsonRes.desc;
  238. if (desc) {
  239. $message.error(desc);
  240. logger({ cnl: ["server"], act: "开考失败", dtl: desc });
  241. } else {
  242. logger({
  243. cnl: ["server"],
  244. act: "开考失败",
  245. dtl: "开考失败,并且无错误消息",
  246. possibleError: error,
  247. });
  248. }
  249. } else {
  250. logger({
  251. cnl: ["server"],
  252. act: "开考失败",
  253. dtl: "非接口失败",
  254. possibleError: error,
  255. });
  256. }
  257. return;
  258. }
  259. }
  260. passedAllChecks = true;
  261. await router.push({
  262. name: "OnlineExamOverview",
  263. params: { examId: course.examId },
  264. query: hstate,
  265. });
  266. }
  267. onUnmounted(() => {
  268. /** TODO: 摄像头关闭时机,需要完善 */
  269. if (!passedAllChecks && course.faceEnable) {
  270. // debug级别备选。因为初期上线,摄像头比较容易出错,所以保留此日志
  271. logger({
  272. cnl: ["server", "console"],
  273. act: "关闭sharedtream",
  274. dtl: "!passedAllChecks",
  275. });
  276. closeMediaStream();
  277. } else {
  278. logger({
  279. cnl: ["server", "console"],
  280. act: "保留sharedtream",
  281. dtl: "passedAllChecks",
  282. });
  283. }
  284. });
  285. </script>
  286. <template>
  287. <CommittmentDialog
  288. v-if="STEPS[curentStep] === 'COMMITTMENT'"
  289. ref="committmentDialogRef"
  290. :content="course.undertaking"
  291. @onCommitResult="applyCommittmentResult"
  292. />
  293. <n-modal
  294. v-if="STEPS[curentStep] === 'CHECK_ENV_1'"
  295. :show="showCheckEnv1"
  296. :closable="false"
  297. preset="card"
  298. title="进行环境检测"
  299. style="width: 600px"
  300. >
  301. <div>
  302. 环境检测可以检测电脑的硬件配置、网络速度和常用操作。环境检测不通过的话,可能影响考试的正常进行。
  303. </div>
  304. <div class="tw-flex tw-justify-center tw-mt-2">
  305. <n-button
  306. type="success"
  307. style="margin-right: 5px; min-width: 120px"
  308. @click="skipCheckEnv"
  309. >
  310. 跳过检测
  311. </n-button>
  312. <n-button
  313. type="success"
  314. style="margin-right: 5px; min-width: 120px"
  315. @click="doCheckEnv"
  316. >
  317. 进行检测
  318. </n-button>
  319. </div>
  320. </n-modal>
  321. <CheckComputer
  322. v-if="STEPS[curentStep] === 'CHECK_ENV_2'"
  323. @onClose="curentStep++"
  324. />
  325. <OnlineExamFaceCheckModal
  326. v-if="STEPS[curentStep] === 'CHECK_FACE'"
  327. @passed="onFacePassed"
  328. @close="onFaceClosedByUser"
  329. />
  330. <n-modal
  331. v-if="STEPS[curentStep] === 'CHECK_FACE_COMMITTMENT'"
  332. :show="true"
  333. :closable="false"
  334. preset="card"
  335. title="郑重承诺"
  336. style="width: 600px"
  337. >
  338. <div>
  339. 我承诺由本人参加考试,并且同意接受考试监控系统信息审核,一经发现作弊,立即取消本门课程考试成绩。
  340. </div>
  341. <div class="tw-flex tw-justify-center tw-mt-2">
  342. <n-button
  343. type="success"
  344. style="margin-right: 5px; min-width: 120px"
  345. @click="disagreeCommittment"
  346. >
  347. 取消
  348. </n-button>
  349. <n-button
  350. type="success"
  351. style="margin-right: 5px; min-width: 120px"
  352. @click="enterExam"
  353. >
  354. 确定
  355. </n-button>
  356. </div>
  357. </n-modal>
  358. </template>