UserLogin.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. <script lang="ts" setup>
  2. import {
  3. getStudentInfoBySessionApi,
  4. getStudentSpecialtyNameListApi,
  5. loginApi,
  6. } from "@/api/login";
  7. import {
  8. DOMAIN,
  9. FACE_API_MODEL_PATH,
  10. VITE_GIT_REPO_VERSION,
  11. } from "@/constants/constants";
  12. import { useTimers } from "@/setups/useTimers";
  13. import { resetStore, store } from "@/store/store";
  14. import { createEncryptLog, createUserDetailLog } from "@/utils/logger";
  15. import { MD5 } from "@/utils/md5";
  16. import { getScreenShot, isElectron } from "@/utils/nativeMethods";
  17. import ua from "@/utils/ua";
  18. import { decryptLogin, preloadResource } from "@/utils/utils";
  19. import { CloseCircleOutline, LockClosed, Person } from "@vicons/ionicons5";
  20. import { FormItemInst, FormRules, useDialog } from "naive-ui";
  21. import { onMounted, onUnmounted, watch } from "vue";
  22. import { useRouter } from "vue-router";
  23. import GeeTest from "./GeeTest.vue";
  24. import GlobalNotice from "./GlobalNotice.vue";
  25. import { useAppVersion } from "./useAppVersion";
  26. import { getElectronConfig } from "./useElectronConfig";
  27. import { checkExamInProgress } from "./useExamInProgress";
  28. import { useExamShellStats } from "./useExamShellStats";
  29. import { getGeeTestConfig } from "./useGeeTestConfig";
  30. import { limitLogin } from "./useLimitLogin";
  31. import { useNewVersion } from "./useNewVersion";
  32. import { useRemoteAppChecker } from "./useRemoteAppChecker";
  33. import { useVCamChecker } from "./useVCamChecker";
  34. const dialog = useDialog();
  35. const { addTimeout, addInterval } = useTimers();
  36. resetStore();
  37. //#region 登录日志处理
  38. // @ts-expect-error
  39. // eslint-disable-next-line no-undef
  40. const portableFile = window.proceess?.env?.PORTABLE_EXECUTABLE_FILE;
  41. logger({
  42. cnl: ["console", "local", "server"],
  43. pgn: "登录页面",
  44. act: "首次渲染",
  45. });
  46. if (isElectron()) {
  47. logger({
  48. cnl: ["server"],
  49. pgn: "登录页面",
  50. act: "versonstats",
  51. ext: {
  52. packageVersion: "ua-" + ua.getBrowser().version,
  53. file: portableFile,
  54. uaGood: "uagood-" + (portableFile ? 1 : 0),
  55. },
  56. });
  57. }
  58. // @ts-expect-error rtt不应该存在chrome 61以下,此处是陷阱代码
  59. if (navigator.connection && navigator.connection.rtt) {
  60. logger({
  61. cnl: ["local", "server"],
  62. pgn: "登录页面",
  63. // 故意用特殊的空格字符
  64. act: "page created",
  65. ext: { UA: navigator.userAgent },
  66. });
  67. }
  68. // 上传本机加密日志
  69. addInterval(createEncryptLog, 5 * 1000);
  70. useExamShellStats();
  71. //#endregion
  72. //#region cache faceapi json
  73. {
  74. const faceapiUrls = [
  75. FACE_API_MODEL_PATH + "tiny_face_detector_model-weights_manifest.json",
  76. FACE_API_MODEL_PATH + "face_landmark_68_model-weights_manifest.json",
  77. FACE_API_MODEL_PATH + "face_expression_model-weights_manifest.json",
  78. ];
  79. for (const u of faceapiUrls) {
  80. preloadResource(u);
  81. }
  82. }
  83. //#endregion
  84. let isGeeTestEnabled = $ref(false);
  85. onMounted(async () => {
  86. try {
  87. const conf = await getElectronConfig();
  88. store.QECSConfig = conf;
  89. } catch (error) {
  90. console.error(error);
  91. }
  92. if (store.QECSConfig && !store.QECSConfig.LOGIN_SUPPORT.includes("NATIVE")) {
  93. dialog.warning({
  94. title: "提醒",
  95. content: "当前版本已禁用,请根据学校通知下载新版考生端程序进行考试!",
  96. positiveText: "确定",
  97. maskClosable: false,
  98. closeOnEsc: false,
  99. closable: false,
  100. onPositiveClick: () => {
  101. closeApp("notSupport");
  102. },
  103. });
  104. }
  105. isGeeTestEnabled = await getGeeTestConfig(store.QECSConfig.ROOT_ORG_ID);
  106. });
  107. const QECSConfig = $computed(() => store.QECSConfig);
  108. const logoPath = $computed(() => QECSConfig.LOGO_FILE_URL || "");
  109. const backgroundUrl = $computed(
  110. () =>
  111. `url(${
  112. QECSConfig.STUDENT_CLIENT_BG_PICTURE_URL ||
  113. "https://cdn.qmth.com.cn/ui/ecs-client-bg.jpg!/progressive/true"
  114. })`
  115. );
  116. const productName = $computed(
  117. () => QECSConfig.OE_STUDENT_SYS_NAME || "远程教育网络考试"
  118. );
  119. const allowLoginType = $computed(() => QECSConfig?.LOGIN_TYPE ?? []);
  120. const { newVersionAvailable, checkNewVersion } = useNewVersion();
  121. const { disableLoginBtnBecauseAppVersionChecker } =
  122. useAppVersion(newVersionAvailable);
  123. const { disableLoginBtnBecauseVCam } = useVCamChecker();
  124. const { disableLoginBtnBecauseRemoteApp } = useRemoteAppChecker();
  125. let disableLoginBtn = $computed(
  126. () =>
  127. !import.meta.env.DEV &&
  128. (disableLoginBtnBecauseNotTimeout ||
  129. disableLoginBtnBecauseRemoteApp.value ||
  130. disableLoginBtnBecauseVCam.value ||
  131. disableLoginBtnBecauseAppVersionChecker.value)
  132. // this.disableLoginBtnBecauseRefreshServiceWorker ||
  133. // this.disableLoginBtnBecauseNotAllowedNative
  134. );
  135. addTimeout(() => {
  136. if (disableLoginBtn)
  137. logger({
  138. cnl: ["server", "local"],
  139. // FIXME: change to debug when go prod
  140. lvl: "log",
  141. dtl: "disabler",
  142. ext: {
  143. notTimeout: disableLoginBtnBecauseNotTimeout,
  144. remoteApp: disableLoginBtnBecauseRemoteApp.value,
  145. vcam: disableLoginBtnBecauseVCam.value,
  146. appVersionChecker: disableLoginBtnBecauseAppVersionChecker.value,
  147. },
  148. });
  149. }, 15 * 1000);
  150. //#region form校验
  151. const domain = DOMAIN;
  152. if (!domain?.includes(".ecs.qmth.com.cn")) {
  153. $message.warning("学校域名不正确", { duration: 5 * 60 * 1000 });
  154. }
  155. type FormModel = {
  156. accountType: "STUDENT_CODE" | "STUDENT_IDENTITY_NUMBER";
  157. accountValue: string;
  158. password: string;
  159. domain: string;
  160. rootOrgId: string;
  161. };
  162. const formRef: FormItemInst = $ref();
  163. const formValue: FormModel = $ref({
  164. accountType: "STUDENT_CODE",
  165. accountValue: "",
  166. password: "",
  167. domain,
  168. rootOrgId: "",
  169. });
  170. const fromRules: FormRules = {
  171. accountValue: {
  172. required: true,
  173. trigger: "blur",
  174. message: "账号必填",
  175. },
  176. password: {
  177. required: true,
  178. trigger: "blur",
  179. message: "密码必填",
  180. },
  181. };
  182. let errorInfo = $ref("");
  183. watch(
  184. () => allowLoginType,
  185. (v) => {
  186. let defaultAccountType = formValue.accountType || "STUDENT_CODE";
  187. const allowStudentCode = v.includes("STUDENT_CODE");
  188. const allowIdentityNumber = v.includes("IDENTITY_NUMBER");
  189. if (!allowStudentCode && allowIdentityNumber) {
  190. defaultAccountType = "STUDENT_IDENTITY_NUMBER";
  191. }
  192. formValue.accountType = defaultAccountType;
  193. }
  194. );
  195. watch([formValue], () => (errorInfo = ""));
  196. watch(
  197. () => formValue.accountType,
  198. () => {
  199. formValue.accountValue = localStorage.getItem(formValue.accountType) || "";
  200. },
  201. { immediate: true }
  202. );
  203. //#endregion
  204. //#region 极验
  205. type Captcha = {
  206. appendTo(sel: string): void;
  207. onReady(cb: () => void): void;
  208. onError(cb: (error: any) => void): void;
  209. destroy(): void;
  210. getValidate(): any;
  211. };
  212. let captchaObj: Captcha = $ref();
  213. let resetGeeTime = $ref(0);
  214. resetGeeTime = Date.now();
  215. // 超过60秒,刷新极验;优化刷新次数,当用户真正想输入时
  216. watch(
  217. () => [formValue.accountType, formValue.accountValue, formValue.password],
  218. () => {
  219. if (isGeeTestEnabled && Date.now() - resetGeeTime > 60 * 1000) {
  220. captchaObj?.destroy();
  221. resetGeeTime = Date.now();
  222. }
  223. }
  224. );
  225. //#endregion
  226. //#region 登录处理
  227. const router = useRouter();
  228. let loginBtnLoading = $ref(false);
  229. let disableLoginBtnBecauseNotTimeout = $ref(false);
  230. async function loginForuser() {
  231. if (await checkNewVersion()) return;
  232. if (disableLoginBtn) return;
  233. if (await formRef.validate().catch(() => true)) return;
  234. if (isGeeTestEnabled) {
  235. if (!captchaObj?.getValidate()) {
  236. $message.error("请完成验证");
  237. return;
  238. }
  239. }
  240. loginBtnLoading = true;
  241. // 登录接口调一次必然间隔10秒以上
  242. disableLoginBtnBecauseNotTimeout = true;
  243. addTimeout(() => (disableLoginBtnBecauseNotTimeout = false), 10 * 1000);
  244. if (await limitLogin()) {
  245. loginBtnLoading = false;
  246. return;
  247. }
  248. logger({
  249. pgn: "登录页面",
  250. cnl: ["local", "server"],
  251. act: "login clicked",
  252. ext: { UA: navigator.userAgent },
  253. });
  254. errorInfo = "";
  255. let geeParams = {};
  256. if (isGeeTestEnabled) {
  257. // const geeForm = document.querySelector(".geetest_form").children;
  258. const geeRes = captchaObj.getValidate();
  259. geeParams = {
  260. user_id: localStorage.getItem("uuidForEcs"),
  261. client_type: "Web",
  262. challenge: geeRes.geetest_challenge, // geeForm[0].value,
  263. validate: geeRes.geetest_validate, // geeForm[1].value,
  264. seccode: geeRes.geetest_seccode, // geeForm[2].value,
  265. };
  266. }
  267. try {
  268. const timestamp = Date.now();
  269. const res = await loginApi(
  270. formValue.accountType,
  271. formValue.accountValue,
  272. formValue.password,
  273. domain,
  274. QECSConfig.ROOT_ORG_ID,
  275. geeParams,
  276. timestamp
  277. );
  278. const key = MD5(
  279. "accountValue=" +
  280. formValue.accountValue +
  281. "&" +
  282. "password=" +
  283. formValue.password +
  284. "&" +
  285. "timestamp=" +
  286. timestamp
  287. );
  288. logger({
  289. cnl: ["server"],
  290. pgu: "AUTO",
  291. act: "login api success",
  292. ext: {
  293. accountType: formValue.accountType,
  294. accountValue: formValue.accountValue,
  295. domain,
  296. ...geeParams,
  297. },
  298. });
  299. if (res.data.code === "200") {
  300. errorInfo = "";
  301. // FIXME: 插入多余代码?
  302. const str = decryptLogin(res.data.content as string, key);
  303. res.data.content = JSON.parse(str);
  304. console.log(res.data.content);
  305. // setSalt(res.data.content.salt as string);
  306. // delete res.data.content.salt;
  307. // 准备下面的登录token
  308. store.user = res.data.content;
  309. } else {
  310. errorInfo = res.data.desc;
  311. captchaObj?.destroy();
  312. resetGeeTime = Date.now();
  313. logger({
  314. pgu: "AUTO",
  315. act: "点击登录-res-error",
  316. stk: res.data.code + res.data.desc,
  317. cnl: ["console", "server"],
  318. });
  319. return;
  320. }
  321. await afterLogin(res);
  322. } finally {
  323. loginBtnLoading = false;
  324. }
  325. }
  326. /** 登录成功后,取学生信息和跳转 */
  327. async function afterLogin(loginRes: any) {
  328. // 存储登录成功的用户名
  329. localStorage.setItem(formValue.accountType, formValue.accountValue);
  330. try {
  331. const [{ data: student }, { data: specialty }] = await Promise.all([
  332. getStudentInfoBySessionApi(),
  333. getStudentSpecialtyNameListApi(),
  334. ]);
  335. const user = {
  336. ...loginRes.data.content,
  337. ...student,
  338. specialty: specialty.join(),
  339. schoolDomain: domain,
  340. };
  341. store.user = user;
  342. createUserDetailLog();
  343. getScreenShot({ cause: "ss-login" }).catch((e) => {
  344. logger({
  345. pgu: "AUTO",
  346. cnl: ["server"],
  347. dtl: "桌面抓拍失败-electron问题",
  348. ejn: JSON.stringify(e),
  349. });
  350. });
  351. let userIds: number[] = JSON.parse(localStorage.getItem("userIds") || "[]");
  352. userIds = [...new Set(userIds).add(store.user.id)];
  353. localStorage.setItem("userIds", JSON.stringify(userIds));
  354. // 有断点或者异常,停止后续处理
  355. if (await checkExamInProgress().catch(() => true)) return;
  356. void router.push({ name: "WelcomePage" });
  357. $message.destroyAll();
  358. } catch (error) {
  359. logger({
  360. cnl: ["local", "server"],
  361. act: "登录失败",
  362. dtl: "getStudentInfoBySession/getStudentSpecialtyNameListApi失败",
  363. });
  364. $message.error("获取学生信息失败,请重试!", {
  365. duration: 15 * 60 * 1000,
  366. closable: true,
  367. });
  368. }
  369. }
  370. //#endregion
  371. function closeApp(type: "exit" | "notSupport" = "exit") {
  372. const isNotSupport = type === "notSupport";
  373. logger({
  374. pgu: "AUTO",
  375. cnl: ["local", "server"],
  376. key: isNotSupport ? "学生端登录禁用" : "退出应用",
  377. act: isNotSupport ? "学生端登录禁用,点击确定按钮" : "点击关闭按钮",
  378. });
  379. window.close();
  380. }
  381. // 登录页会收到很多消息,离开登录页时,都应该销毁掉
  382. onUnmounted(() => $message.destroyAll());
  383. </script>
  384. <template>
  385. <div class="tw-absolute tw-left-0 tw-right-0">
  386. <header class="header">
  387. <div class="school-logo-container">
  388. <img
  389. v-show="logoPath"
  390. class="school-logo"
  391. :src="logoPath"
  392. alt="school logo"
  393. style="
  394. background: linear-gradient(to bottom, #38f6f5 0%, #8efdf4 100%);
  395. "
  396. @load="(e) => ((e.target as HTMLImageElement).style.backgroundImage = 'none')"
  397. />
  398. <!-- 加上它,在logo加载失败的时候有用 -->
  399. </div>
  400. <a class="close" @click="closeApp()">关闭</a>
  401. </header>
  402. </div>
  403. <div class="tw-flex tw-items-center tw-min-h-screen">
  404. <div class="center-bg" :style="{ backgroundImage: backgroundUrl }">
  405. <div class="login-content">
  406. <div class="login-types qm-big-text tw-flex tw-overflow-clip">
  407. <a
  408. v-if="allowLoginType.includes('STUDENT_CODE')"
  409. key="STUDENT_CODE"
  410. :class="{
  411. 'active-type': formValue.accountType === 'STUDENT_CODE',
  412. }"
  413. @click="formValue.accountType = 'STUDENT_CODE'"
  414. >
  415. {{ QECSConfig.STUDENT_CODE_LOGIN_ALIAS }}
  416. </a>
  417. <a
  418. v-if="allowLoginType.includes('IDENTITY_NUMBER')"
  419. key="IDENTITY_NUMBER"
  420. :class="{
  421. 'active-type':
  422. formValue.accountType === 'STUDENT_IDENTITY_NUMBER',
  423. }"
  424. @click="formValue.accountType = 'STUDENT_IDENTITY_NUMBER'"
  425. >
  426. {{ QECSConfig.IDENTITY_NUMBER_LOGIN_ALIAS }}
  427. </a>
  428. <a v-if="allowLoginType.length === 0" key="loading">loading...</a>
  429. </div>
  430. <div class="qm-title-text tw-text-center tw-mt-10">
  431. {{ productName }}
  432. </div>
  433. <div class="tw-mx-10">
  434. <n-form ref="formRef" :model="formValue" :rules="fromRules">
  435. <n-form-item class="form-item-style" path="accountValue">
  436. <n-input v-model:value="formValue.accountValue">
  437. <template #prefix>
  438. <n-icon :component="Person" />
  439. </template>
  440. </n-input>
  441. </n-form-item>
  442. <n-form-item
  443. prop="password"
  444. class="form-item-style"
  445. path="password"
  446. >
  447. <n-input
  448. v-model:value="formValue.password"
  449. type="password"
  450. @keypress.enter="loginForuser"
  451. >
  452. <template #prefix>
  453. <n-icon :component="LockClosed" />
  454. </template>
  455. </n-input>
  456. </n-form-item>
  457. <n-form-item
  458. v-if="isGeeTestEnabled"
  459. class="form-item-style"
  460. style="height: 40px; margin-top: 0px"
  461. >
  462. <GeeTest
  463. :reset="resetGeeTime"
  464. @onLoad="(v) => (captchaObj = v)"
  465. />
  466. </n-form-item>
  467. <div
  468. v-show="errorInfo"
  469. class="tw-flex tw-items-center tw-text-red-900"
  470. >
  471. <n-icon :component="CloseCircleOutline" size="large" />
  472. {{ errorInfo }}
  473. </div>
  474. <n-form-item class="tw-mb-8">
  475. <n-button
  476. type="success"
  477. size="large"
  478. style="width: 100%"
  479. :disabled="disableLoginBtn"
  480. :loading="loginBtnLoading"
  481. @click="loginForuser"
  482. >
  483. {{ newVersionAvailable ? "点击更新版本" : "登录" }}
  484. </n-button>
  485. </n-form-item>
  486. </n-form>
  487. </div>
  488. </div>
  489. </div>
  490. </div>
  491. <footer class="tw-absolute tw-right-5 tw-bottom-5">
  492. 版本: {{ VITE_GIT_REPO_VERSION }}
  493. </footer>
  494. <GlobalNotice />
  495. </template>
  496. <style scoped>
  497. .header {
  498. display: flex;
  499. align-items: center;
  500. min-height: 120px;
  501. }
  502. .school-logo-container {
  503. justify-self: flex-start;
  504. margin-left: 100px;
  505. }
  506. .school-logo {
  507. height: 100px;
  508. width: 400px;
  509. object-fit: cover;
  510. }
  511. .close {
  512. position: absolute;
  513. top: 0;
  514. right: 0;
  515. background-color: #eeeeee;
  516. color: #999999;
  517. text-align: center;
  518. width: 80px;
  519. height: 40px;
  520. line-height: 40px;
  521. border-bottom-left-radius: 6px;
  522. }
  523. .close:hover {
  524. color: #444444;
  525. cursor: pointer;
  526. }
  527. .center-bg {
  528. background-position: center;
  529. background-repeat: no-repeat;
  530. background-size: cover;
  531. width: 100%;
  532. min-height: 400px;
  533. height: calc(100vh - 240px);
  534. max-height: 700px;
  535. display: flex;
  536. align-items: center;
  537. }
  538. .login-content {
  539. /* margin-top: 100px; */
  540. margin-left: 60%;
  541. width: 340px;
  542. border-radius: 6px;
  543. overflow: hidden;
  544. background-color: white;
  545. }
  546. /* FIXME: 样式复用候选 */
  547. .qm-big-text {
  548. font-size: 16px;
  549. font-weight: normal;
  550. font-stretch: normal;
  551. line-height: 22px;
  552. letter-spacing: 0px;
  553. color: #999999;
  554. }
  555. .qm-title-text {
  556. font-size: 18px;
  557. font-weight: bold;
  558. font-stretch: normal;
  559. line-height: 24px;
  560. letter-spacing: 0px;
  561. color: #444444;
  562. }
  563. .login-types a {
  564. flex: 1;
  565. line-height: 40px;
  566. background-color: #eeeeee;
  567. text-align: center;
  568. cursor: pointer;
  569. }
  570. .login-types a.active-type {
  571. background-color: #ffffff;
  572. }
  573. .form-item-style {
  574. margin-bottom: 30px;
  575. height: 42px;
  576. }
  577. </style>