LoginForm.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. <template>
  2. <div class="login-form-wrapper">
  3. <!-- 登录方式切换标签 -->
  4. <div class="login-tabs">
  5. <div class="tab-item" :class="{ active: loginType === 'password' }" @click="loginType = 'password'">密码登录</div>
  6. <div class="tab-item" :class="{ active: loginType === 'code' }" @click="loginType = 'code'">验证码登录</div>
  7. </div>
  8. <!-- 密码登录表单 -->
  9. <el-form
  10. v-if="loginType === 'password'"
  11. ref="passwordFormRef"
  12. :model="passwordForm"
  13. :rules="passwordRules"
  14. size="large"
  15. class="login-form-content"
  16. >
  17. <el-form-item prop="phone">
  18. <el-input v-model="passwordForm.phone" placeholder="请输入手机号" maxlength="11" clearable />
  19. </el-form-item>
  20. <el-form-item prop="password">
  21. <el-input
  22. v-model="passwordForm.password"
  23. type="password"
  24. placeholder="请输入密码"
  25. show-password
  26. autocomplete="new-password"
  27. clearable
  28. />
  29. </el-form-item>
  30. <el-form-item prop="captcha">
  31. <div class="captcha-wrapper">
  32. <el-input
  33. v-model="passwordForm.captcha"
  34. placeholder="请输入图片中的数字"
  35. maxlength="4"
  36. clearable
  37. class="captcha-input"
  38. />
  39. <div class="captcha-image" @click="refreshCaptcha">
  40. <img v-if="captchaImage" :src="captchaImage" alt="验证码" />
  41. <div v-else class="captcha-placeholder">点击获取</div>
  42. </div>
  43. </div>
  44. </el-form-item>
  45. </el-form>
  46. <!-- 验证码登录表单 -->
  47. <el-form v-else ref="codeFormRef" :model="codeForm" :rules="codeRules" size="large" class="login-form-content">
  48. <el-form-item prop="phone">
  49. <el-input v-model="codeForm.phone" placeholder="请输入手机号" maxlength="11" clearable />
  50. </el-form-item>
  51. <el-form-item prop="code">
  52. <div class="code-wrapper">
  53. <el-input v-model="codeForm.code" placeholder="请输入验证码" maxlength="6" clearable class="code-input" />
  54. <el-button :disabled="codeCountdown > 0" @click="sendCode" class="code-button">
  55. {{ codeCountdown > 0 ? `${codeCountdown}秒后重试` : "获取验证码" }}
  56. </el-button>
  57. </div>
  58. </el-form-item>
  59. </el-form>
  60. <!-- 登录按钮 -->
  61. <el-button type="primary" size="large" :loading="loading" @click="handleLogin" class="login-button"> 登录 </el-button>
  62. <!-- 底部链接 -->
  63. <div class="form-footer">
  64. <span class="link-text" @click="handleForgotPassword">忘记密码?</span>
  65. <span class="link-text" @click="handleRegister">还没有账号,去注册</span>
  66. </div>
  67. <!-- 用户协议 -->
  68. <div class="agreement-wrapper">
  69. <el-checkbox v-model="agreed" />
  70. <span class="agreement-text">
  71. 我已阅读并同意
  72. <span class="link-text" @click="handleUserAgreement">《用户协议》</span>
  73. <span class="link-text" @click="handlePrivacyPolicy">《隐私政策》</span>
  74. </span>
  75. </div>
  76. </div>
  77. </template>
  78. <script setup lang="ts">
  79. import { ref, reactive, watch, onMounted, onBeforeUnmount } from "vue";
  80. import { useRouter } from "vue-router";
  81. import { HOME_URL } from "@/config";
  82. import { Login } from "@/api/interface/index";
  83. import { ElMessage, ElNotification } from "element-plus";
  84. import { loginApi } from "@/api/modules/login";
  85. import { useUserStore } from "@/stores/modules/user";
  86. import { useTabsStore } from "@/stores/modules/tabs";
  87. import { useKeepAliveStore } from "@/stores/modules/keepAlive";
  88. import { initDynamicRouter } from "@/routers/modules/dynamicRouter";
  89. import type { ElForm } from "element-plus";
  90. import md5 from "md5";
  91. import { aiLogin } from "@/api/indexAi";
  92. import { localSet } from "@/utils";
  93. import { resetAuthExpiredDedupe } from "@/api/helper/handleAuthExpired";
  94. const router = useRouter();
  95. const userStore = useUserStore();
  96. const tabsStore = useTabsStore();
  97. const keepAliveStore = useKeepAliveStore();
  98. // 登录方式
  99. const loginType = ref<"password" | "code">("password");
  100. // 表单引用
  101. const passwordFormRef = ref<InstanceType<typeof ElForm>>();
  102. const codeFormRef = ref<InstanceType<typeof ElForm>>();
  103. // 加载状态
  104. const loading = ref(false);
  105. // 用户协议同意状态
  106. const agreed = ref(false);
  107. // 密码登录表单
  108. const passwordForm = reactive({
  109. phone: "",
  110. password: "",
  111. captcha: ""
  112. });
  113. // 验证码登录表单
  114. const codeForm = reactive({
  115. phone: "",
  116. code: ""
  117. });
  118. // 图片验证码
  119. const captchaImage = ref("");
  120. // 验证码倒计时
  121. const codeCountdown = ref(0);
  122. let countdownTimer: NodeJS.Timeout | null = null;
  123. // 表单验证规则
  124. const passwordRules = reactive({
  125. phone: [
  126. { required: true, message: "请输入手机号", trigger: "blur" },
  127. { pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号码", trigger: "blur" }
  128. ],
  129. password: [{ required: true, message: "请输入密码", trigger: "blur" }],
  130. captcha: [{ required: true, message: "请输入验证码", trigger: "blur" }]
  131. });
  132. const codeRules = reactive({
  133. phone: [
  134. { required: true, message: "请输入手机号", trigger: "blur" },
  135. { pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号码", trigger: "blur" }
  136. ],
  137. code: [{ required: true, message: "请输入验证码", trigger: "blur" }]
  138. });
  139. // 获取图片验证码
  140. const refreshCaptcha = async () => {
  141. try {
  142. // TODO: 调用获取验证码图片的API
  143. // const { data } = await getCaptchaApi();
  144. // captchaImage.value = data.imageUrl;
  145. // 临时使用随机数字作为验证码图片(实际应该从API获取)
  146. const randomNum = Math.floor(1000 + Math.random() * 9000);
  147. captchaImage.value = `data:image/svg+xml;base64,${btoa(`
  148. <svg width="120" height="40" xmlns="http://www.w3.org/2000/svg">
  149. <rect width="120" height="40" fill="#f5f7fa"/>
  150. <text x="60" y="25" font-size="20" font-weight="bold" text-anchor="middle" fill="#409eff">${randomNum}</text>
  151. </svg>
  152. `)}`;
  153. } catch (error) {
  154. ElMessage.error("获取验证码失败");
  155. }
  156. };
  157. // 发送短信验证码
  158. const sendCode = async () => {
  159. if (!codeForm.phone) {
  160. ElMessage.warning("请先输入手机号");
  161. return;
  162. }
  163. if (!/^1[3-9]\d{9}$/.test(codeForm.phone)) {
  164. ElMessage.warning("请输入正确的手机号码");
  165. return;
  166. }
  167. try {
  168. // TODO: 调用发送验证码的API
  169. // await sendSmsCodeApi({ phone: codeForm.phone });
  170. ElMessage.success("验证码已发送");
  171. // 开始倒计时
  172. codeCountdown.value = 60;
  173. countdownTimer = setInterval(() => {
  174. codeCountdown.value--;
  175. if (codeCountdown.value <= 0) {
  176. if (countdownTimer) {
  177. clearInterval(countdownTimer);
  178. countdownTimer = null;
  179. }
  180. }
  181. }, 1000);
  182. } catch (error) {
  183. ElMessage.error("发送验证码失败");
  184. }
  185. };
  186. // 登录处理
  187. const handleLogin = async () => {
  188. if (!agreed.value) {
  189. ElMessage.warning("请先同意用户协议和隐私政策");
  190. return;
  191. }
  192. let formRef: InstanceType<typeof ElForm> | undefined;
  193. let formData: any = {};
  194. if (loginType.value === "password") {
  195. formRef = passwordFormRef.value;
  196. formData = {
  197. username: passwordForm.phone,
  198. password: passwordForm.password,
  199. captcha: passwordForm.captcha
  200. };
  201. } else {
  202. formRef = codeFormRef.value;
  203. formData = {
  204. username: codeForm.phone,
  205. code: codeForm.code,
  206. loginType: "code"
  207. };
  208. }
  209. if (!formRef) return;
  210. await formRef.validate(async valid => {
  211. if (!valid) return;
  212. loading.value = true;
  213. try {
  214. // 根据登录方式调用不同的登录接口
  215. let loginParams: any;
  216. if (loginType.value === "password") {
  217. loginParams = {
  218. username: formData.username,
  219. password: md5(formData.password)
  220. };
  221. } else {
  222. // TODO: 验证码登录接口
  223. loginParams = {
  224. username: formData.username,
  225. code: formData.code
  226. };
  227. }
  228. const { data } = (await loginApi(loginParams)) as { data: Login.ResLogin };
  229. if (data) {
  230. userStore.setToken(data.token);
  231. resetAuthExpiredDedupe();
  232. console.log("AI登录");
  233. // 保存用户信息到localStorage,供AI登录使用
  234. const userInfo = {
  235. userInfo: {
  236. nickName: formData.username, // 使用登录时的用户名
  237. phone: formData.username, // 假设用户名就是手机号
  238. password: loginType.value === "password" ? formData.password : undefined // 保存密码用于AI登录
  239. },
  240. token: data.token
  241. };
  242. localSet("geeker-user", userInfo);
  243. // 旧登录接口成功后,调用AI登录接口
  244. try {
  245. console.log("AI登录");
  246. await aiLogin();
  247. } catch (error) {
  248. console.error("AI服务登录失败:", error);
  249. // AI登录失败不影响主系统登录流程,静默处理
  250. }
  251. // 添加动态路由
  252. await initDynamicRouter();
  253. // 清空 tabs、keepAlive 数据
  254. tabsStore.setTabs([]);
  255. keepAliveStore.setKeepAliveName([]);
  256. // 跳转到首页
  257. router.push(HOME_URL);
  258. ElNotification({
  259. title: "登录成功",
  260. type: "success",
  261. duration: 3000
  262. });
  263. } else {
  264. ElNotification({
  265. title: "登录失败,请检查账号和密码",
  266. type: "error",
  267. duration: 3000
  268. });
  269. }
  270. } catch (error: any) {
  271. ElNotification({
  272. title: error?.msg || "登录失败,请稍后重试",
  273. type: "error",
  274. duration: 3000
  275. });
  276. } finally {
  277. loading.value = false;
  278. }
  279. });
  280. };
  281. // 忘记密码
  282. const handleForgotPassword = () => {
  283. // TODO: 跳转到忘记密码页面
  284. ElMessage.info("忘记密码功能开发中");
  285. };
  286. // 注册
  287. const handleRegister = () => {
  288. // TODO: 跳转到注册页面
  289. ElMessage.info("注册功能开发中");
  290. };
  291. // 用户协议
  292. const handleUserAgreement = () => {
  293. // TODO: 打开用户协议弹窗或跳转
  294. ElMessage.info("用户协议");
  295. };
  296. // 隐私政策
  297. const handlePrivacyPolicy = () => {
  298. // TODO: 打开隐私政策弹窗或跳转
  299. ElMessage.info("隐私政策");
  300. };
  301. // 切换登录方式时重置表单
  302. const resetForms = () => {
  303. if (passwordFormRef.value) {
  304. passwordFormRef.value.resetFields();
  305. }
  306. if (codeFormRef.value) {
  307. codeFormRef.value.resetFields();
  308. }
  309. captchaImage.value = "";
  310. codeCountdown.value = 0;
  311. if (countdownTimer) {
  312. clearInterval(countdownTimer);
  313. countdownTimer = null;
  314. }
  315. };
  316. // 监听登录方式切换
  317. watch(
  318. () => loginType.value,
  319. () => {
  320. resetForms();
  321. }
  322. );
  323. onMounted(() => {
  324. // 密码登录时自动获取验证码
  325. if (loginType.value === "password") {
  326. refreshCaptcha();
  327. }
  328. // 监听 enter 事件
  329. document.onkeydown = (e: KeyboardEvent) => {
  330. if (e.code === "Enter" || e.code === "enter" || e.code === "NumpadEnter") {
  331. if (loading.value) return;
  332. handleLogin();
  333. }
  334. };
  335. });
  336. onBeforeUnmount(() => {
  337. document.onkeydown = null;
  338. if (countdownTimer) {
  339. clearInterval(countdownTimer);
  340. }
  341. });
  342. </script>
  343. <style scoped lang="scss">
  344. .login-form-wrapper {
  345. .login-tabs {
  346. display: flex;
  347. width: fit-content;
  348. margin: 0 auto 30px;
  349. border-bottom: 1px solid #e4e7ed;
  350. .tab-item {
  351. position: relative;
  352. padding: 12px 24px;
  353. font-size: 16px;
  354. color: #606266;
  355. text-align: center;
  356. white-space: nowrap;
  357. cursor: pointer;
  358. transition: color 0.3s;
  359. &:hover {
  360. color: #606266;
  361. }
  362. &.active {
  363. font-weight: 700;
  364. color: #000000;
  365. &::after {
  366. position: absolute;
  367. right: 0;
  368. bottom: -1px;
  369. left: 0;
  370. height: 2px;
  371. content: "";
  372. background-color: #6c8ff8;
  373. }
  374. }
  375. }
  376. }
  377. .login-form-content {
  378. :deep(.el-form-item) {
  379. margin-bottom: 20px;
  380. }
  381. :deep(.el-input__wrapper) {
  382. padding: 12px 15px;
  383. }
  384. }
  385. .captcha-wrapper {
  386. display: flex;
  387. gap: 10px;
  388. .captcha-input {
  389. flex: 1;
  390. }
  391. .captcha-image {
  392. display: flex;
  393. align-items: center;
  394. justify-content: center;
  395. width: 120px;
  396. height: 40px;
  397. overflow: hidden;
  398. cursor: pointer;
  399. background-color: #f5f7fa;
  400. border: 1px solid #dcdfe6;
  401. border-radius: 4px;
  402. img {
  403. width: 100%;
  404. height: 100%;
  405. object-fit: cover;
  406. }
  407. .captcha-placeholder {
  408. font-size: 12px;
  409. color: #909399;
  410. }
  411. &:hover {
  412. border-color: #409eff;
  413. }
  414. }
  415. }
  416. .code-wrapper {
  417. display: flex;
  418. gap: 10px;
  419. .code-input {
  420. flex: 1;
  421. }
  422. .code-button {
  423. width: 120px;
  424. white-space: nowrap;
  425. }
  426. }
  427. .login-button {
  428. width: 100%;
  429. height: 44px;
  430. margin-top: 10px;
  431. font-size: 16px;
  432. }
  433. :deep(.login-button.el-button--primary) {
  434. background-color: #6c8ff8;
  435. border-color: #6c8ff8;
  436. &:hover {
  437. background-color: #5a7de8;
  438. border-color: #5a7de8;
  439. }
  440. &:active {
  441. background-color: #4a6dd8;
  442. border-color: #4a6dd8;
  443. }
  444. }
  445. .form-footer {
  446. display: flex;
  447. justify-content: space-between;
  448. margin-top: 20px;
  449. font-size: 14px;
  450. .link-text {
  451. color: #409eff;
  452. cursor: pointer;
  453. &:hover {
  454. text-decoration: underline;
  455. }
  456. }
  457. }
  458. .agreement-wrapper {
  459. display: flex;
  460. align-items: center;
  461. margin-top: 20px;
  462. font-size: 14px;
  463. color: #606266;
  464. :deep(.el-checkbox) {
  465. margin-right: 8px;
  466. }
  467. .agreement-text {
  468. .link-text {
  469. color: #409eff;
  470. cursor: pointer;
  471. &:hover {
  472. text-decoration: underline;
  473. }
  474. }
  475. }
  476. }
  477. }
  478. </style>