UserLogin.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. <script lang="ts" setup>
  2. import {
  3. getStudentInfoBySessionApi,
  4. getStudentSpecialtyNameListApi,
  5. loginApi,
  6. } from "@/api/login";
  7. import { DOMAIN, VITE_GIT_REPO_VERSION } from "@/constants/constants";
  8. import { useTimers } from "@/setups/useTimers";
  9. import { store } from "@/store/store";
  10. import { createUserDetailLog } from "@/utils/logger";
  11. import { CloseCircleOutline, LockClosed, Person } from "@vicons/ionicons5";
  12. import { FormItemInst, FormRules } from "naive-ui";
  13. import { onMounted, watch } from "vue";
  14. import { useRouter } from "vue-router";
  15. import GeeTest from "./GeeTest.vue";
  16. import GlobalNotice from "./GlobalNotice.vue";
  17. import { useAppVersion } from "./useAppVersion";
  18. import { getElectronConfig } from "./useElectronConfig";
  19. import { checkExamInProgress } from "./useExamInProgress";
  20. import { getGeeTestConfig } from "./useGeeTestConfig";
  21. import { limitLogin } from "./useLimitLogin";
  22. import { useNewVersion } from "./useNewVersion";
  23. logger({
  24. cnl: ["console", "local", "server"],
  25. pgn: "登录页面",
  26. act: "首次渲染",
  27. });
  28. const { addTimeout } = useTimers();
  29. let isGeeTestEnabled = $ref(false);
  30. onMounted(async () => {
  31. const conf = await getElectronConfig();
  32. store.QECSConfig = conf;
  33. isGeeTestEnabled = await getGeeTestConfig(store.QECSConfig.ROOT_ORG_ID);
  34. });
  35. const QECSConfig = store.QECSConfig;
  36. const logoPath = $computed(() => QECSConfig.LOGO_FILE_URL || "");
  37. const backgroundUrl = $computed(
  38. () =>
  39. `url(${
  40. QECSConfig.STUDENT_CLIENT_BG_PICTURE_URL ||
  41. "https://cdn.qmth.com.cn/ui/ecs-client-bg.jpg!/progressive/true"
  42. })`
  43. );
  44. const productName = $computed(
  45. () => QECSConfig.OE_STUDENT_SYS_NAME || "远程教育网络考试"
  46. );
  47. const allowLoginType = $computed(() => QECSConfig.LOGIN_TYPE ?? []);
  48. const { newVersionAvailable, checkNewVersion } = useNewVersion();
  49. const { disableLoginBtnBecauseAppVersionChecker } =
  50. useAppVersion(newVersionAvailable);
  51. let disableLoginBtn = $computed(
  52. () =>
  53. disableLoginBtnBecauseNotTimeout ||
  54. (!import.meta.env.DEV &&
  55. // (this.disableLoginBtnBecauseRemoteApp ||
  56. // this.disableLoginBtnBecauseVCam)) ||
  57. disableLoginBtnBecauseAppVersionChecker.value)
  58. // this.disableLoginBtnBecauseRefreshServiceWorker ||
  59. // this.disableLoginBtnBecauseNotAllowedNative
  60. );
  61. //#region form校验
  62. const domain = DOMAIN;
  63. type FormModel = {
  64. accountType: "STUDENT_CODE" | "IDENTITY_NUMBER";
  65. accountValue: string;
  66. password: string;
  67. domain: string;
  68. rootOrgId: string;
  69. };
  70. const formRef: FormItemInst = $ref();
  71. const formValue: FormModel = $ref({
  72. accountType: "STUDENT_CODE",
  73. accountValue: "",
  74. password: "",
  75. domain,
  76. rootOrgId: "",
  77. });
  78. const fromRules: FormRules = {
  79. accountValue: {
  80. required: true,
  81. trigger: "blur",
  82. message: "账号必填",
  83. },
  84. password: {
  85. required: true,
  86. trigger: "blur",
  87. message: "密码必填",
  88. },
  89. };
  90. let errorInfo = $ref("");
  91. watch([formValue], () => (errorInfo = ""));
  92. //#endregion
  93. //#region 极验
  94. type Captcha = {
  95. appendTo(sel: string): void;
  96. onReady(cb: () => void): void;
  97. onError(cb: (error: any) => void): void;
  98. destroy(): void;
  99. getValidate(): any;
  100. };
  101. let captchaObj: Captcha = $ref();
  102. let resetGeeTime = $ref(0);
  103. resetGeeTime = Date.now();
  104. // 超过60秒,刷新极验;优化刷新次数,当用户真正想输入时
  105. watch(
  106. () => [formValue.accountType, formValue.accountValue, formValue.password],
  107. () => {
  108. if (isGeeTestEnabled && Date.now() - resetGeeTime > 60 * 1000) {
  109. captchaObj?.destroy();
  110. resetGeeTime = Date.now();
  111. }
  112. }
  113. );
  114. //#endregion
  115. const router = useRouter();
  116. let loginBtnLoading = $ref(false);
  117. let disableLoginBtnBecauseNotTimeout = $ref(false);
  118. async function loginForuser() {
  119. if (await checkNewVersion()) return;
  120. if (await formRef.validate().catch(() => true)) return;
  121. if (isGeeTestEnabled) {
  122. if (!captchaObj?.getValidate()) {
  123. $message.error("请完成验证");
  124. return;
  125. }
  126. }
  127. loginBtnLoading = true;
  128. // 登录接口调一次必然间隔10秒以上
  129. disableLoginBtnBecauseNotTimeout = true;
  130. addTimeout(() => (disableLoginBtnBecauseNotTimeout = false), 10 * 1000);
  131. if (await limitLogin()) {
  132. loginBtnLoading = false;
  133. return;
  134. }
  135. logger({
  136. pgn: "登录页面",
  137. cnl: ["local", "server"],
  138. act: "login clicked",
  139. ext: { UA: navigator.userAgent },
  140. });
  141. errorInfo = "";
  142. let geeParams = {};
  143. if (isGeeTestEnabled) {
  144. // const geeForm = document.querySelector(".geetest_form").children;
  145. const geeRes = captchaObj.getValidate();
  146. geeParams = {
  147. user_id: localStorage.getItem("uuidForEcs"),
  148. client_type: "Web",
  149. challenge: geeRes.geetest_challenge, // geeForm[0].value,
  150. validate: geeRes.geetest_validate, // geeForm[1].value,
  151. seccode: geeRes.geetest_seccode, // geeForm[2].value,
  152. };
  153. }
  154. try {
  155. const res = await loginApi(
  156. formValue.accountType,
  157. formValue.accountValue,
  158. formValue.password,
  159. domain,
  160. QECSConfig.ROOT_ORG_ID,
  161. geeParams
  162. );
  163. if (res.data.code === "200") {
  164. errorInfo = "";
  165. // 准备下面的登录token
  166. store.user = res.data.content;
  167. } else {
  168. errorInfo = res.data.desc;
  169. captchaObj?.destroy();
  170. resetGeeTime = Date.now();
  171. logger({
  172. pgu: "AUTO",
  173. act: "点击登录-res-error",
  174. stk: res.data.code + res.data.desc,
  175. cnl: ["console", "server"],
  176. });
  177. return;
  178. }
  179. await afterLogin(res);
  180. } finally {
  181. loginBtnLoading = false;
  182. }
  183. }
  184. /** 登录成功后,取学生信息和跳转 */
  185. async function afterLogin(loginRes: any) {
  186. try {
  187. const [{ data: student }, { data: specialty }] = await Promise.all([
  188. getStudentInfoBySessionApi(),
  189. getStudentSpecialtyNameListApi(),
  190. ]);
  191. const user = {
  192. ...loginRes.data.content,
  193. ...student,
  194. specialty: specialty.join(),
  195. schoolDomain: domain,
  196. };
  197. store.user = user;
  198. createUserDetailLog();
  199. // 有断点或者异常,停止后续处理
  200. if (await checkExamInProgress().catch(() => true)) return;
  201. void router.push({ name: "ChangePassword" });
  202. } catch (error) {
  203. logger({
  204. cnl: ["local", "server"],
  205. act: "登录失败",
  206. dtl: "getStudentInfoBySession/getStudentSpecialtyNameListApi失败",
  207. });
  208. $message.error("获取学生信息失败,请重试!", {
  209. duration: 15,
  210. closable: true,
  211. });
  212. }
  213. }
  214. function closeApp() {
  215. logger({
  216. pgu: "AUTO",
  217. cnl: ["local", "server"],
  218. key: "退出应用",
  219. act: "点击关闭按钮",
  220. });
  221. window.close();
  222. }
  223. </script>
  224. <template>
  225. <div class="tw-flex tw-flex-col tw-h-full">
  226. <header class="header">
  227. <div class="school-logo-container">
  228. <img
  229. v-show="logoPath"
  230. class="school-logo"
  231. :src="logoPath"
  232. alt="school logo"
  233. style="
  234. background: linear-gradient(to bottom, #38f6f5 0%, #8efdf4 100%);
  235. "
  236. />
  237. <!-- 加上它,在logo加载失败的时候有用 -->
  238. <!-- @load="(e: any) => (e.target.style = '')" -->
  239. </div>
  240. <a class="close" @click="closeApp">关闭</a>
  241. </header>
  242. <div class="center" :style="{ backgroundImage: backgroundUrl }">
  243. <div class="content">
  244. <div class="login-types qm-big-text tw-flex tw-overflow-clip">
  245. <a
  246. v-if="allowLoginType.includes('STUDENT_CODE')"
  247. key="STUDENT_CODE"
  248. :class="{ 'active-type': formValue.accountType === 'STUDENT_CODE' }"
  249. @click="formValue.accountType = 'STUDENT_CODE'"
  250. >
  251. {{ QECSConfig.STUDENT_CODE_LOGIN_ALIAS }}
  252. </a>
  253. <a
  254. v-if="allowLoginType.includes('IDENTITY_NUMBER')"
  255. key="IDENTITY_NUMBER"
  256. :class="{
  257. 'active-type': formValue.accountType === 'IDENTITY_NUMBER',
  258. }"
  259. @click="formValue.accountType = 'IDENTITY_NUMBER'"
  260. >
  261. {{ QECSConfig.IDENTITY_NUMBER_LOGIN_ALIAS }}
  262. </a>
  263. <a v-if="allowLoginType.length === 0" key="loading">loading...</a>
  264. </div>
  265. <div class="qm-title-text tw-text-center tw-mt-10">
  266. {{ productName }}
  267. </div>
  268. <div class="tw-mx-10">
  269. <n-form ref="formRef" :model="formValue" :rules="fromRules">
  270. <n-form-item class="form-item-style" path="accountValue">
  271. <n-input v-model:value="formValue.accountValue">
  272. <template #prefix>
  273. <n-icon :component="Person" />
  274. </template>
  275. </n-input>
  276. </n-form-item>
  277. <n-form-item
  278. prop="password"
  279. class="form-item-style"
  280. path="password"
  281. >
  282. <n-input
  283. v-model:value="formValue.password"
  284. type="password"
  285. @onEnter="loginForuser"
  286. >
  287. <template #prefix>
  288. <n-icon :component="LockClosed" />
  289. </template>
  290. </n-input>
  291. </n-form-item>
  292. <n-form-item
  293. v-if="isGeeTestEnabled"
  294. class="form-item-style"
  295. style="height: 40px; margin-top: 0px"
  296. >
  297. <GeeTest
  298. :reset="resetGeeTime"
  299. @onLoad="(v) => (captchaObj = v)"
  300. />
  301. </n-form-item>
  302. <div
  303. v-show="errorInfo"
  304. class="tw-flex tw-items-center tw-text-red-900"
  305. >
  306. <n-icon :component="CloseCircleOutline" size="large" />
  307. {{ errorInfo }}
  308. </div>
  309. <n-form-item class="tw-mb-8">
  310. <n-button
  311. type="success"
  312. size="large"
  313. style="width: 100%"
  314. :disabled="disableLoginBtn"
  315. :loading="loginBtnLoading"
  316. @click="loginForuser"
  317. >
  318. {{ newVersionAvailable ? "点击更新版本" : "登录" }}
  319. </n-button>
  320. </n-form-item>
  321. </n-form>
  322. </div>
  323. </div>
  324. </div>
  325. <footer class="footer">
  326. <div style="position: absolute; right: 20px; bottom: 20px">
  327. 版本: {{ VITE_GIT_REPO_VERSION }}
  328. </div>
  329. </footer>
  330. <GlobalNotice />
  331. </div>
  332. </template>
  333. <style scoped>
  334. .header {
  335. display: flex;
  336. align-items: center;
  337. min-height: 120px;
  338. }
  339. .school-logo-container {
  340. justify-self: flex-start;
  341. margin-left: 100px;
  342. }
  343. .school-logo {
  344. height: 100px;
  345. width: 400px;
  346. object-fit: cover;
  347. }
  348. .close {
  349. position: absolute;
  350. top: 0;
  351. right: 0;
  352. background-color: #eeeeee;
  353. color: #999999;
  354. text-align: center;
  355. width: 80px;
  356. height: 40px;
  357. line-height: 40px;
  358. border-bottom-left-radius: 6px;
  359. }
  360. .close:hover {
  361. color: #444444;
  362. cursor: pointer;
  363. }
  364. .center {
  365. background-position: center;
  366. background-repeat: no-repeat;
  367. background-size: cover;
  368. width: 100vw;
  369. min-height: 600px;
  370. }
  371. .content {
  372. margin-top: 100px;
  373. margin-left: 60%;
  374. width: 340px;
  375. border-radius: 6px;
  376. overflow: hidden;
  377. background-color: white;
  378. }
  379. /* FIXME: 样式复用候选 */
  380. .qm-big-text {
  381. font-size: 16px;
  382. font-weight: normal;
  383. font-stretch: normal;
  384. line-height: 22px;
  385. letter-spacing: 0px;
  386. color: #999999;
  387. }
  388. .qm-title-text {
  389. font-size: 18px;
  390. font-weight: bold;
  391. font-stretch: normal;
  392. line-height: 24px;
  393. letter-spacing: 0px;
  394. color: #444444;
  395. }
  396. .login-types a {
  397. flex: 1;
  398. line-height: 40px;
  399. background-color: #eeeeee;
  400. text-align: center;
  401. }
  402. .login-types a.active-type {
  403. background-color: #ffffff;
  404. }
  405. .form-item-style {
  406. margin-bottom: 30px;
  407. height: 42px;
  408. }
  409. </style>