소스 검색

add login code

周玉环 2 주 전
부모
커밋
be2f567383
6개의 변경된 파일452개의 추가작업 그리고 41개의 파일을 삭제
  1. 172 0
      docs/TOKEN_SHARING.md
  2. 10 7
      src/http/index.ts
  3. 40 0
      src/pages/login/index.module.css
  4. 101 27
      src/pages/login/index.tsx
  5. 33 7
      src/services/modules/system.ts
  6. 96 0
      src/utils/auth.ts

+ 172 - 0
docs/TOKEN_SHARING.md

@@ -0,0 +1,172 @@
+# Token 共享机制
+
+本项目与 JeecgBoot 前端共享登录态,可以实现无缝跳转。
+
+## 存储机制
+
+### LocalStorage Keys
+
+与 JeecgBoot 前端保持一致的存储 key:
+
+| Key | 说明 | 示例 |
+|-----|------|------|
+| `ACCESS_TOKEN` | 访问令牌 | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...` |
+| `USER_INFO` | 用户信息(JSON 字符串) | `{"username":"admin","realname":"管理员"}` |
+| `TENANT_ID` | 租户 ID | `1` |
+
+## 使用方法
+
+### 1. 登录时保存 Token
+
+```typescript
+import { setToken, setUserInfo } from '@/utils/auth';
+
+// 登录成功后
+const result = await authApi.login({ username, password, captcha });
+
+// 保存 token
+setToken(result.token);
+
+// 保存用户信息
+setUserInfo(result.userInfo);
+```
+
+### 2. 请求时携带 Token
+
+HTTP 拦截器会自动从 localStorage 读取 token 并添加到请求头:
+
+```typescript
+// 自动添加以下 headers
+{
+  "X-Access-Token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+  "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+```
+
+### 3. 登出时清除 Token
+
+```typescript
+import { clearAuth } from '@/utils/auth';
+
+// 清除所有认证信息
+clearAuth();
+
+// 跳转到登录页
+navigate('/login');
+```
+
+### 4. 检查登录状态
+
+```typescript
+import { isAuthenticated } from '@/utils/auth';
+
+if (!isAuthenticated()) {
+  // 未登录,跳转到登录页
+  navigate('/login');
+}
+```
+
+## 跨应用跳转
+
+由于使用相同的 localStorage key,可以实现以下场景:
+
+### 从 React 应用跳转到 JeecgBoot 前端
+
+```typescript
+// 直接跳转,token 会自动共享
+window.location.href = 'http://jeecg-boot-frontend.com/dashboard';
+```
+
+### 从 JeecgBoot 前端跳转到 React 应用
+
+```javascript
+// 直接跳转,token 会自动共享
+window.location.href = 'http://react-app.com/spaces/content-model';
+```
+
+## Token 刷新
+
+当 token 过期(401 错误)时,系统会自动:
+
+1. 清除所有认证信息(`clearAuth()`)
+2. 显示"登录已过期"提示
+3. 1.5 秒后跳转到登录页
+
+## 安全注意事项
+
+1. **Token 存储**:使用 localStorage 存储,注意 XSS 攻击防护
+2. **Token 传输**:使用 HTTPS 协议传输
+3. **Token 过期**:后端应设置合理的过期时间
+4. **敏感操作**:重要操作应要求二次验证
+
+## API 工具函数
+
+### auth.ts
+
+```typescript
+// 获取 token
+getToken(): string
+
+// 设置 token
+setToken(token: string): void
+
+// 移除 token
+removeToken(): void
+
+// 获取用户信息
+getUserInfo(): any
+
+// 设置用户信息
+setUserInfo(userInfo: any): void
+
+// 移除用户信息
+removeUserInfo(): void
+
+// 获取租户 ID
+getTenantId(): string
+
+// 设置租户 ID
+setTenantId(tenantId: string): void
+
+// 清除所有认证信息
+clearAuth(): void
+
+// 检查是否已登录
+isAuthenticated(): boolean
+```
+
+## 与 JeecgBoot 的兼容性
+
+| 特性 | JeecgBoot | React 应用 | 兼容性 |
+|------|-----------|-----------|--------|
+| Token Key | `ACCESS_TOKEN` | `ACCESS_TOKEN` | ✅ 完全兼容 |
+| Token Header | `X-Access-Token` | `X-Access-Token` | ✅ 完全兼容 |
+| 用户信息 Key | `USER_INFO` | `USER_INFO` | ✅ 完全兼容 |
+| 租户 ID Key | `TENANT_ID` | `TENANT_ID` | ✅ 完全兼容 |
+| 401 处理 | 清除 token 并跳转 | 清除 token 并跳转 | ✅ 完全兼容 |
+
+## 示例场景
+
+### 场景 1:用户在 React 应用登录
+
+1. 用户在 React 应用登录
+2. Token 保存到 `localStorage.ACCESS_TOKEN`
+3. 用户点击链接跳转到 JeecgBoot 前端
+4. JeecgBoot 前端读取 `localStorage.ACCESS_TOKEN`
+5. 用户无需重新登录,直接访问
+
+### 场景 2:用户在 JeecgBoot 前端登录
+
+1. 用户在 JeecgBoot 前端登录
+2. Token 保存到 `localStorage.ACCESS_TOKEN`
+3. 用户点击链接跳转到 React 应用
+4. React 应用读取 `localStorage.ACCESS_TOKEN`
+5. 用户无需重新登录,直接访问
+
+### 场景 3:Token 过期
+
+1. 用户在任一应用中操作
+2. Token 过期,后端返回 401
+3. 应用清除所有认证信息
+4. 显示"登录已过期"提示
+5. 跳转到登录页

+ 10 - 7
src/http/index.ts

@@ -13,6 +13,7 @@ import message from "../utils/message";
 import { joinTimestamp, formatRequestDate, setObjToUrlParams } from "./helper";
 import { generateSign, getTimestamp } from "../utils/sign";
 import { getCurrentTenantId } from "../utils/tenant";
+import { getToken, clearAuth } from "../utils/auth";
 
 /**
  * 数据处理
@@ -60,8 +61,9 @@ const transform: AxiosTransform = {
     switch (code) {
       case 401:
         errorMsg = msg || "登录已过期";
+        // 清除所有认证信息
+        clearAuth();
         // 跳转登录页
-        localStorage.removeItem("token");
         setTimeout(() => {
           window.location.href = "/spaces/login";
         }, 1500);
@@ -169,12 +171,13 @@ const transform: AxiosTransform = {
     const tenantId = getCurrentTenantId();
     config.headers["X-Tenant-ID"] = String(tenantId);
 
-    // 如果需要 token,这里可以添加
-    const token =
-      "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IuWGheWuueS4reW_g-euoeeQhuWRmCIsImV4cCI6MTc2NTA0MjczOX0.xymZCEWejOAfdncfJRdgL27ejI7_7nE2TzT9XX7soXk";
+    // 从 localStorage 获取 token(与 JeecgBoot 前端共享)
+    const token = getToken();
     if (token && options.requestOptions?.withToken !== false) {
-      config.headers.Authorization = token;
+      // JeecgBoot 使用 X-Access-Token 作为主要的 token header
       config.headers["X-Access-Token"] = token;
+      // 同时也设置 Authorization,兼容标准的 Bearer token
+      config.headers.Authorization = `Bearer ${token}`;
     }
 
     return config;
@@ -233,8 +236,8 @@ const transform: AxiosTransform = {
       
       // 401 未授权,跳转登录页
       if (response.status === 401) {
-        // 清除 token
-        localStorage.removeItem("token");
+        // 清除所有认证信息
+        clearAuth();
         // 跳转登录页
         window.location.href = "/spaces/login";
       }

+ 40 - 0
src/pages/login/index.module.css

@@ -38,6 +38,10 @@
   box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
 }
 
+.form_control {
+  margin-bottom: 10px !important;
+}
+
 .socialButton {
   flex: 1;
 }
@@ -54,3 +58,39 @@
 .footer button:hover {
   text-decoration: underline;
 }
+
+/* 验证码相关样式 */
+.captchaInputWrapper {
+  flex: 1;
+}
+
+.captchaImage {
+  cursor: pointer;
+  height: 35px;
+  width: 120px;
+  border: 1px solid #d3dce6;
+  border-radius: 4px;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: #f5f7fa;
+  transition: all 0.2s;
+}
+
+.captchaImage:hover {
+  /* border-color: #0066ff; */
+  /* background-color: #f0f5ff; */
+}
+
+.captchaImage img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+
+.captchaPlaceholder {
+  font-size: 12px;
+  color: #999;
+}

+ 101 - 27
src/pages/login/index.tsx

@@ -1,23 +1,49 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
 import {
   Box,
   Flex,
   TextInput,
   Button,
   FormControl,
-  Checkbox,
+  //   Checkbox,
   Note,
 } from "@contentful/f36-components";
 import { useNavigate } from "react-router-dom";
+import { authApi } from "../../services/modules/system";
+import { setToken, setUserInfo } from "../../utils/auth";
+import { useAuth } from "../../contexts/AuthContext";
 import styles from "./index.module.css";
 
 export default function LoginPage() {
-  const [account, setAccount] = useState("");
-  const [password, setPassword] = useState("");
-  const [rememberMe, setRememberMe] = useState(false);
+  const [account, setAccount] = useState("内容中心管理员");
+  const [password, setPassword] = useState("zyh931015$");
+  const [captcha, setCaptcha] = useState("");
+  const [captchaKey, setCaptchaKey] = useState("");
+  const [captchaImage, setCaptchaImage] = useState("");
+  //   const [rememberMe, setRememberMe] = useState(false);
   const [loading, setLoading] = useState(false);
   const [errorMessage, setErrorMessage] = useState("");
   const navigate = useNavigate();
+  const { refreshAuth } = useAuth();
+
+  // 获取验证码
+  const fetchCaptcha = async () => {
+    const checkKey =
+      new Date().getTime() + Math.random().toString(36).slice(-4); // 1629428467008;
+    setCaptchaKey(checkKey);
+    try {
+      const result = await authApi.getCaptcha(checkKey);
+      setCaptchaImage(result);
+      setCaptcha(""); // 清空验证码输入
+    } catch (error) {
+      console.error("获取验证码失败:", error);
+    }
+  };
+
+  // 页面加载时获取验证码
+  useEffect(() => {
+    fetchCaptcha();
+  }, []);
 
   const handleLogin = async (e: React.FormEvent) => {
     e.preventDefault();
@@ -26,27 +52,44 @@ export default function LoginPage() {
     setErrorMessage("");
 
     // 验证表单 - 只检查必填
-    if (!account.trim() || !password.trim()) {
-      setErrorMessage("账号或密码不能为空");
+    if (!account.trim() || !password.trim() || !captcha.trim()) {
+      setErrorMessage("账号、密码或验证码不能为空");
       return;
     }
 
     setLoading(true);
 
     try {
-      // TODO: 实现登录逻辑
-      console.log("Login:", { account, password, rememberMe });
+      // 调用登录接口
+      const result = await authApi.login({
+        username: account,
+        password,
+        captcha,
+        checkKey: captchaKey,
+      });
 
-      // 模拟登录失败
-      // setErrorMessage("账号或密码错误");
+      console.log("登录成功:", result);
 
-      // 模拟登录成功后跳转
-      setTimeout(() => {
-        navigate("/content-model");
-      }, 1000);
-    } catch (error) {
+      // 保存 token(使用与 JeecgBoot 前端相同的 key)
+      if (result.token) {
+        setToken(result.token);
+      }
+
+      // 保存用户信息
+      if (result.userInfo) {
+        setUserInfo(result.userInfo);
+      }
+
+      // 刷新权限数据(加载菜单)
+      await refreshAuth();
+
+      // 跳转到首页
+      navigate("/content-model");
+    } catch (error: any) {
       console.error("登录失败:", error);
-      setErrorMessage("登录失败,请稍后重试");
+      setErrorMessage(error.message || "登录失败,请稍后重试");
+      // 刷新验证码
+      fetchCaptcha();
     } finally {
       setLoading(false);
     }
@@ -68,16 +111,12 @@ export default function LoginPage() {
         {/* 登录表单 */}
         <Box className={styles.formContainer}>
           <form onSubmit={handleLogin}>
-            <Flex flexDirection="column" gap="spacingL">
+            <Flex flexDirection="column" gap="spacingS">
               {/* 错误提示 */}
-              {errorMessage && (
-                <Note variant="warning">
-                  {errorMessage}
-                </Note>
-              )}
+              {errorMessage && <Note variant="warning">{errorMessage}</Note>}
 
               {/* account */}
-              <FormControl>
+              <FormControl className={styles.form_control}>
                 <FormControl.Label>账号</FormControl.Label>
                 <TextInput
                   //   type="account"
@@ -94,7 +133,7 @@ export default function LoginPage() {
               </FormControl>
 
               {/* Password */}
-              <FormControl>
+              <FormControl className={styles.form_control}>
                 <FormControl.Label>密码</FormControl.Label>
                 <TextInput
                   type="password"
@@ -110,13 +149,48 @@ export default function LoginPage() {
                 />
               </FormControl>
 
+              {/* Captcha */}
+              <FormControl className={styles.form_control}>
+                <FormControl.Label>验证码</FormControl.Label>
+                <Flex gap="spacingS" alignItems="center">
+                  <Box className={styles.captchaInputWrapper}>
+                    <TextInput
+                      value={captcha}
+                      onChange={(e) => {
+                        setCaptcha(e.target.value);
+                        // 清除错误提示
+                        if (errorMessage) {
+                          setErrorMessage("");
+                        }
+                      }}
+                      placeholder="请输入验证码"
+                    />
+                  </Box>
+                  <Box
+                    onClick={fetchCaptcha}
+                    className={styles.captchaImage}
+                  >
+                    {captchaImage ? (
+                      <img
+                        src={captchaImage}
+                        alt="验证码"
+                      />
+                    ) : (
+                      <span className={styles.captchaPlaceholder}>
+                        点击刷新
+                      </span>
+                    )}
+                  </Box>
+                </Flex>
+              </FormControl>
+
               {/* Remember me */}
-              <Checkbox
+              {/* <Checkbox
                 isChecked={rememberMe}
                 onChange={() => setRememberMe(!rememberMe)}
               >
                 Remember me
-              </Checkbox>
+              </Checkbox> */}
 
               {/* Login Button */}
               <Button

+ 33 - 7
src/services/modules/system.ts

@@ -11,8 +11,19 @@ const http = defHttp;
  * 登录登出相关
  */
 export const authApi = {
+  // 获取验证码
+  getCaptcha: (currdatetime: string) =>
+    http.get<string>({
+      url: "/sys/randomImage" + "/" + currdatetime,
+    }),
+
   // 登录
-  login: (data: { username: string; password: string; captcha?: string }) =>
+  login: (data: {
+    username: string;
+    password: string;
+    captcha?: string;
+    checkKey?: string;
+  }) =>
     http.post<{ token: string; userInfo: any }>({ url: "/sys/login", data }),
 
   // 登出
@@ -22,8 +33,11 @@ export const authApi = {
   getUserInfo: () => http.get({ url: "/sys/user/getUserInfo" }),
 
   // 修改密码
-  changePassword: (data: { oldPassword: string; newPassword: string; confirmPassword: string }) =>
-    http.put({ url: "/sys/user/changePassword", data }),
+  changePassword: (data: {
+    oldPassword: string;
+    newPassword: string;
+    confirmPassword: string;
+  }) => http.put({ url: "/sys/user/changePassword", data }),
 };
 
 /**
@@ -31,14 +45,24 @@ export const authApi = {
  */
 export const permissionApi = {
   // 获取用户菜单权限
-  getUserPermissions: () => http.get<JeecgUserPermissions>({ url: "/sys/permission/getUserPermissionByToken" }),
+  getUserPermissions: () =>
+    http.get<JeecgUserPermissions>({
+      url: "/sys/permission/getUserPermissionByToken",
+    }),
 
   // 查询角色权限
   queryRolePermission: (roleId: string) =>
-    http.get({ url: "/sys/permission/queryRolePermission", params: { roleId } }),
+    http.get({
+      url: "/sys/permission/queryRolePermission",
+      params: { roleId },
+    }),
 
   // 保存角色权限
-  saveRolePermission: (roleId: string, permissionIds: string[], lastPermissionIds?: string[]) =>
+  saveRolePermission: (
+    roleId: string,
+    permissionIds: string[],
+    lastPermissionIds?: string[]
+  ) =>
     http.post({
       url: "/sys/permission/saveRolePermission",
       data: {
@@ -55,7 +79,9 @@ export const permissionApi = {
 export const dictApi = {
   // 获取字典数据
   getDictItems: (dictCode: string) =>
-    http.get<Array<{ text: string; value: string }>>({ url: `/sys/dict/getDictItems/${dictCode}` }),
+    http.get<Array<{ text: string; value: string }>>({
+      url: `/sys/dict/getDictItems/${dictCode}`,
+    }),
 
   // 批量获取字典数据
   queryAllDictItems: () => http.get({ url: "/sys/dict/queryAllDictItems" }),

+ 96 - 0
src/utils/auth.ts

@@ -0,0 +1,96 @@
+/**
+ * 认证相关工具
+ * 仿照 JeecgBoot 前端的 token 存储逻辑
+ */
+
+// Token 存储的 key,与 JeecgBoot 前端保持一致
+const TOKEN_KEY = "ACCESS_TOKEN";
+const USER_INFO_KEY = "USER_INFO";
+const TENANT_ID_KEY = "TENANT_ID";
+
+/**
+ * 获取 token
+ */
+export function getToken(): string {
+  return localStorage.getItem(TOKEN_KEY) || "";
+}
+
+/**
+ * 设置 token
+ */
+export function setToken(token: string): void {
+  localStorage.setItem(TOKEN_KEY, token);
+}
+
+/**
+ * 移除 token
+ */
+export function removeToken(): void {
+  localStorage.removeItem(TOKEN_KEY);
+}
+
+/**
+ * 获取用户信息
+ */
+export function getUserInfo(): any {
+  const userInfo = localStorage.getItem(USER_INFO_KEY);
+  if (userInfo) {
+    try {
+      return JSON.parse(userInfo);
+    } catch (e) {
+      return null;
+    }
+  }
+  return null;
+}
+
+/**
+ * 设置用户信息
+ */
+export function setUserInfo(userInfo: any): void {
+  localStorage.setItem(USER_INFO_KEY, JSON.stringify(userInfo));
+}
+
+/**
+ * 移除用户信息
+ */
+export function removeUserInfo(): void {
+  localStorage.removeItem(USER_INFO_KEY);
+}
+
+/**
+ * 获取租户 ID
+ */
+export function getTenantId(): string {
+  return localStorage.getItem(TENANT_ID_KEY) || "";
+}
+
+/**
+ * 设置租户 ID
+ */
+export function setTenantId(tenantId: string): void {
+  localStorage.setItem(TENANT_ID_KEY, tenantId);
+}
+
+/**
+ * 移除租户 ID
+ */
+export function removeTenantId(): void {
+  localStorage.removeItem(TENANT_ID_KEY);
+}
+
+/**
+ * 清除所有认证信息
+ */
+export function clearAuth(): void {
+  removeToken();
+  removeUserInfo();
+  removeTenantId();
+}
+
+/**
+ * 检查是否已登录
+ */
+export function isAuthenticated(): boolean {
+  return !!getToken();
+}