diff --git a/src/CellularManagement.WebUI/README.md b/src/CellularManagement.WebUI/README.md index 60a5680..913431b 100644 --- a/src/CellularManagement.WebUI/README.md +++ b/src/CellularManagement.WebUI/README.md @@ -15,6 +15,49 @@ - 🔍 搜索功能 - 👤 用户管理 +## 角色管理功能 + +### 功能概述 +- 角色列表展示 +- 角色创建、编辑、删除 +- 角色权限管理 +- 角色用户关联 + +### 技术实现 +- 使用 React + TypeScript 开发 +- 采用 Ant Design 组件库构建 UI +- 使用 React Hooks 进行状态管理 +- 遵循 Clean Architecture 架构设计 + +### 目录结构 +``` +src/ +├── pages/ +│ └── roles/ +│ └── RolesView.tsx # 角色管理页面 +├── components/ +│ └── roles/ # 角色相关组件 +├── services/ +│ └── roleService.ts # 角色服务 +├── hooks/ +│ └── useRoles.ts # 角色相关 Hook +└── types/ + └── role.ts # 角色类型定义 +``` + +### 使用说明 +1. 角色列表页面:展示所有角色信息 +2. 新建角色:点击"新建角色"按钮 +3. 编辑角色:点击角色列表中的"编辑"按钮 +4. 删除角色:点击角色列表中的"删除"按钮 + +### API 接口 +- GET /api/roles - 获取角色列表 +- GET /api/roles/{id} - 获取单个角色 +- POST /api/roles - 创建角色 +- PUT /api/roles/{id} - 更新角色 +- DELETE /api/roles/{id} - 删除角色 + ## 开始使用 1. 克隆项目 diff --git a/src/CellularManagement.WebUI/public/avatar.jpg b/src/CellularManagement.WebUI/public/avatar.jpg new file mode 100644 index 0000000..79a1ea2 Binary files /dev/null and b/src/CellularManagement.WebUI/public/avatar.jpg differ diff --git a/src/CellularManagement.WebUI/src/components/auth/LoginForm.tsx b/src/CellularManagement.WebUI/src/components/auth/LoginForm.tsx index 892f0f4..70cbef8 100644 --- a/src/CellularManagement.WebUI/src/components/auth/LoginForm.tsx +++ b/src/CellularManagement.WebUI/src/components/auth/LoginForm.tsx @@ -3,17 +3,18 @@ import { DEFAULT_CREDENTIALS } from '@/constants/auth'; import { useAuth } from '@/contexts/AuthContext'; interface LoginFormProps { - onSubmit: (username: string, password: string) => Promise; + onSubmit: (username: string, password: string, rememberMe: boolean) => Promise; } export function LoginForm({ onSubmit }: LoginFormProps) { const [username, setUsername] = useState(DEFAULT_CREDENTIALS.username); const [password, setPassword] = useState(DEFAULT_CREDENTIALS.password); + const [rememberMe, setRememberMe] = useState(false); const { isLoading, error } = useAuth(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - await onSubmit(username, password); + await onSubmit(username, password, rememberMe); }; return ( @@ -49,7 +50,20 @@ export function LoginForm({ onSubmit }: LoginFormProps) { disabled={isLoading} /> - {error && ( +
+ setRememberMe(e.target.checked)} + className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" + disabled={isLoading} + /> + +
+ {typeof error === 'string' && error && (
{error}
diff --git a/src/CellularManagement.WebUI/src/components/auth/ProtectedRoute.tsx b/src/CellularManagement.WebUI/src/components/auth/ProtectedRoute.tsx index e286704..2a344f9 100644 --- a/src/CellularManagement.WebUI/src/components/auth/ProtectedRoute.tsx +++ b/src/CellularManagement.WebUI/src/components/auth/ProtectedRoute.tsx @@ -25,7 +25,7 @@ export function ProtectedRoute({ children, requiredPermission }: ProtectedRouteP } // 确保 userPermissions 存在且是数组 - const permissions = userPermissions || []; + const permissions = (userPermissions || []) as Permission[]; if (requiredPermission && !hasPermission(permissions, requiredPermission)) { // 如果用户没有所需权限,重定向到403页面 return ; diff --git a/src/CellularManagement.WebUI/src/components/layout/Header.tsx b/src/CellularManagement.WebUI/src/components/layout/Header.tsx index 80147b9..31a0645 100644 --- a/src/CellularManagement.WebUI/src/components/layout/Header.tsx +++ b/src/CellularManagement.WebUI/src/components/layout/Header.tsx @@ -3,13 +3,16 @@ import { UserAvatarMenu } from '@/components/ui/UserAvatarMenu'; import { NotificationDrawer } from '@/components/layout/NotificationDrawer'; import { useDrawer } from '@/hooks/useDrawer'; import { useSidebarToggle } from '@/hooks/useSidebarToggle'; -import { cn } from '@/lib/utils'; import { SidebarToggleButton } from '@/components/ui/SidebarToggleButton'; +import { useAuth } from '@/contexts/AuthContext'; +import { getUserInfo } from '@/utils/getUserInfo'; export function Header() { const { isOpen: isNotificationOpen, toggle: toggleNotification } = useDrawer(); const { isCollapsed, toggleSidebar } = useSidebarToggle(); - + const { user, isLoading } = useAuth(); + const { userName, email } = getUserInfo(user); + console.log('[Header] 用户信息', userName, email); return (
- +
diff --git a/src/CellularManagement.WebUI/src/components/ui/UserAvatarMenu.tsx b/src/CellularManagement.WebUI/src/components/ui/UserAvatarMenu.tsx index e766b57..f8784dc 100644 --- a/src/CellularManagement.WebUI/src/components/ui/UserAvatarMenu.tsx +++ b/src/CellularManagement.WebUI/src/components/ui/UserAvatarMenu.tsx @@ -41,10 +41,7 @@ export function UserAvatarMenu({ className="flex items-center gap-1 h-8 px-2 rounded-full hover:bg-accent transition-colors focus:outline-none" onMouseEnter={() => setOpen(true)} > - - - 用户 - + {username} diff --git a/src/CellularManagement.WebUI/src/constants/auth.ts b/src/CellularManagement.WebUI/src/constants/auth.ts index 329cb7c..ab4ac22 100644 --- a/src/CellularManagement.WebUI/src/constants/auth.ts +++ b/src/CellularManagement.WebUI/src/constants/auth.ts @@ -1,5 +1,48 @@ +export const AUTH_CONSTANTS = { + // 存储键名 + STORAGE_KEYS: { + ACCESS_TOKEN: 'accessToken', + REFRESH_TOKEN: 'refreshToken', + TOKEN_EXPIRY: 'tokenExpiry', + REMEMBER_ME: 'rememberMe', + LOGIN_ATTEMPTS: 'loginAttempts', + LAST_LOGIN_ATTEMPT: 'lastLoginAttempt' + }, + + // 认证配置 + AUTH_CONFIG: { + MAX_LOGIN_ATTEMPTS: 5, + LOGIN_ATTEMPTS_RESET_TIME: 30 * 60 * 1000, // 30分钟 + TOKEN_EXPIRY_TIME: 24 * 60 * 60 * 1000, // 24小时 + TOKEN_REFRESH_THRESHOLD: 5 * 60 * 1000, // 5分钟 + }, + + // 默认权限 + DEFAULT_PERMISSIONS: { + VIEW_DASHBOARD: true, + MANAGE_USERS: false, + MANAGE_ROLES: false, + MANAGE_PERMISSIONS: false + }, + + MESSAGES: { + LOGIN_SUCCESS: '登录成功', + LOGIN_FAILED: '登录失败,请检查用户名和密码', + LOGOUT_SUCCESS: '已成功退出登录', + LOGOUT_FAILED: '退出登录失败', + TOKEN_REFRESHED: '令牌已刷新', + TOKEN_REFRESH_FAILED: '令牌刷新失败,请重新登录', + USER_FETCHED: '已获取用户信息', + USER_FETCH_FAILED: '获取用户信息失败', + INVALID_CREDENTIALS: '用户名或密码错误', + ACCOUNT_LOCKED: '账户已被锁定,请稍后再试', + TOKEN_EXPIRED: '登录已过期,请重新登录', + NETWORK_ERROR: '网络错误,请检查网络连接', + UNKNOWN_ERROR: '发生未知错误,请稍后重试' + } +} as const; + export const DEFAULT_CREDENTIALS = { username: 'zhangsan', - email: 'zhangsan@example.com', password: 'P@ssw0rd!' }; \ No newline at end of file diff --git a/src/CellularManagement.WebUI/src/contexts/AuthContext.tsx b/src/CellularManagement.WebUI/src/contexts/AuthContext.tsx index 8f3ae03..3a13059 100644 --- a/src/CellularManagement.WebUI/src/contexts/AuthContext.tsx +++ b/src/CellularManagement.WebUI/src/contexts/AuthContext.tsx @@ -1,8 +1,17 @@ -import { createContext, useContext, useReducer, ReactNode, useEffect, useMemo } from 'react'; +import { createContext, useContext, useReducer, ReactNode, useMemo, useEffect } from 'react'; import { AuthState, AuthContextType, LoginRequest, User } from '@/types/auth'; -import { authService } from '@/services/authService'; import { useSetRecoilState } from 'recoil'; import { userState } from '@/states/appState'; +import { authService } from '@/services/authService'; +import { useAuthSync } from '@/hooks/useAuthSync'; +import { useAuthInit } from '@/hooks/useAuthInit'; +import { storageService } from '@/services/storageService'; + +const TOKEN_EXPIRY_KEY = 'tokenExpiry'; +const REMEMBER_ME_KEY = 'rememberMe'; +const LOGIN_ATTEMPTS_KEY = 'loginAttempts'; +const MAX_LOGIN_ATTEMPTS = 5; +const LOGIN_ATTEMPTS_RESET_TIME = 30 * 60 * 1000; // 30分钟 const initialState: AuthState = { user: null, @@ -10,15 +19,29 @@ const initialState: AuthState = { isLoading: false, error: null, userPermissions: [], + rememberMe: false, }; type AuthAction = | { type: 'LOGIN_START' } - | { type: 'LOGIN_SUCCESS'; payload: { user: User; accessToken: string; refreshToken: string } } - | { type: 'LOGIN_FAILURE'; payload: string } + | { type: 'LOGIN_SUCCESS'; payload: { user: User; accessToken: string; refreshToken: string; rememberMe: boolean } } + | { type: 'LOGIN_FAILURE'; payload: { error: string } } | { type: 'LOGOUT' } | { type: 'CLEAR_ERROR' } - | { type: 'SET_USER'; payload: User }; + | { type: 'SET_USER'; payload: { user: User; accessToken: string; refreshToken: string } } + | { type: 'SET_REMEMBER_ME'; payload: boolean }; + +// 获取默认权限 +const getDefaultPermissions = (userPermissions: Record = {}) => [ + ...new Set([ + ...Object.keys(userPermissions || {}), + 'dashboard.view', + 'users.view', + "roles.view", + "permissions.view", + 'settings.view' + ]) +]; const authReducer = (state: AuthState, action: AuthAction): AuthState => { switch (action.type) { @@ -30,13 +53,9 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { isLoading: false, isAuthenticated: true, user: action.payload.user, - userPermissions: [ - ...(Array.isArray(action.payload.user.permissions) ? action.payload.user.permissions : []), - 'dashboard.view', - 'users.view', - 'settings.view' - ], + userPermissions: getDefaultPermissions(action.payload.user.permissions), error: null, + rememberMe: action.payload.rememberMe, }; case 'LOGIN_FAILURE': return { @@ -45,7 +64,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { isAuthenticated: false, user: null, userPermissions: [], - error: action.payload, + error: action.payload.error, }; case 'LOGOUT': return initialState; @@ -54,15 +73,15 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { case 'SET_USER': return { ...state, - user: action.payload, - userPermissions: [ - ...(Array.isArray(action.payload.permissions) ? action.payload.permissions : []), - 'dashboard.view', - 'users.view', - 'settings.view' - ], + user: action.payload.user, + userPermissions: getDefaultPermissions(action.payload.user.permissions), isAuthenticated: true, }; + case 'SET_REMEMBER_ME': + return { + ...state, + rememberMe: action.payload, + }; default: return state; } @@ -70,97 +89,68 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { const AuthContext = createContext(undefined); +const getInitialState = (): AuthState => { + const accessToken = storageService.getAccessToken(); + const refreshToken = storageService.getRefreshToken(); + const expiry = storageService.getTokenExpiry(); + const rememberMe = storageService.getRememberMe(); + + // 如果有有效的 token,设置初始状态为已认证 + if (accessToken && !authService.isTokenExpired()) { + return { + user: null, // 用户信息会在初始化时获取 + isAuthenticated: true, + isLoading: true, // 设置为 true,等待初始化完成 + error: null, + userPermissions: [], + rememberMe + }; + } + + return { + user: null, + isAuthenticated: false, + isLoading: false, + error: null, + userPermissions: [], + rememberMe: false + }; +}; + export function AuthProvider({ children }: { children: ReactNode }) { - const [state, dispatch] = useReducer(authReducer, initialState); + const [state, dispatch] = useReducer(authReducer, getInitialState()); const setGlobalUser = useSetRecoilState(userState); - // 同步用户状态到 Recoil - useEffect(() => { - setGlobalUser(state.user); - }, [state.user, setGlobalUser]); - - // 初始化时检查用户状态 + // 添加状态变化日志 useEffect(() => { - const initAuth = async () => { - const token = localStorage.getItem('accessToken'); - if (token) { - try { - const result = await authService.getCurrentUser(); - if (result.isSuccess && result.data?.user) { - dispatch({ type: 'SET_USER', payload: result.data.user }); - } else { - dispatch({ type: 'LOGOUT' }); - } - } catch { - dispatch({ type: 'LOGOUT' }); - } - } - }; - initAuth(); - }, []); - - const login = async (request: LoginRequest) => { - dispatch({ type: 'LOGIN_START' }); - try { - const result = await authService.login(request); - if (result.isSuccess && result.data) { - const { accessToken, refreshToken, user } = result.data; - localStorage.setItem('accessToken', accessToken); - localStorage.setItem('refreshToken', refreshToken); - dispatch({ type: 'LOGIN_SUCCESS', payload: { user, accessToken, refreshToken } }); - } else { - dispatch({ - type: 'LOGIN_FAILURE', - payload: result.errorMessages?.[0] || '登录失败', - }); - } - } catch (error) { - dispatch({ - type: 'LOGIN_FAILURE', - payload: '登录请求失败,请稍后重试', - }); - } - }; + console.log('[AuthProvider] 状态更新', { + isAuthenticated: state.isAuthenticated, + isLoading: state.isLoading, + hasUser: !!state.user, + error: state.error + }); + }, [state]); - const logout = async () => { - try { - await authService.logout(); - } finally { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - dispatch({ type: 'LOGOUT' }); - } - }; + // 使用自定义 hooks + useAuthSync(state.user, setGlobalUser); + useAuthInit(dispatch); - const refreshToken = async () => { - const refreshToken = localStorage.getItem('refreshToken'); - if (!refreshToken) { - dispatch({ type: 'LOGOUT' }); - return; - } - - try { - const result = await authService.refreshToken(refreshToken); - if (result.isSuccess && result.data) { - const { accessToken, refreshToken: newRefreshToken, user } = result.data; - localStorage.setItem('accessToken', accessToken); - localStorage.setItem('refreshToken', newRefreshToken); - dispatch({ type: 'SET_USER', payload: user }); - } else { - dispatch({ type: 'LOGOUT' }); - } - } catch { - dispatch({ type: 'LOGOUT' }); - } - }; + const authActions = useMemo(() => ({ + login: async (request: LoginRequest) => { + await authService.handleLogin(request, dispatch); + }, + logout: async () => { + await authService.handleLogout(dispatch); + }, + refreshToken: async () => { + await authService.handleRefreshToken(dispatch); + }, + }), [dispatch]); - // 使用 useMemo 优化 Context 值 const contextValue = useMemo(() => ({ ...state, - login, - logout, - refreshToken, - }), [state]); + ...authActions, + }), [state, authActions]); return ( @@ -171,6 +161,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { export function useAuth() { const context = useContext(AuthContext); + console.log('[useAuth] 获取认证上下文', { + hasContext: !!context, + isAuthenticated: context?.isAuthenticated, + isLoading: context?.isLoading, + hasUser: !!context?.user, + error: context?.error + }); if (context === undefined) { throw new Error('useAuth must be used within an AuthProvider'); } diff --git a/src/CellularManagement.WebUI/src/hooks/useAuthInit.ts b/src/CellularManagement.WebUI/src/hooks/useAuthInit.ts new file mode 100644 index 0000000..52735ed --- /dev/null +++ b/src/CellularManagement.WebUI/src/hooks/useAuthInit.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { Dispatch } from 'react'; +import { AuthAction } from '@/types/auth'; +import { authService } from '@/services/authService'; + +export function useAuthInit(dispatch: Dispatch) { + useEffect(() => { + console.log('[useAuthInit] 开始初始化认证状态'); + const initAuth = async () => { + try { + console.log('[useAuthInit] 调用 initializeAuth'); + await authService.initializeAuth(dispatch); + console.log('[useAuthInit] initializeAuth 完成'); + } catch (error) { + console.error('[useAuthInit] 初始化失败', error); + } + }; + initAuth(); + }, [dispatch]); +} \ No newline at end of file diff --git a/src/CellularManagement.WebUI/src/hooks/useAuthSync.ts b/src/CellularManagement.WebUI/src/hooks/useAuthSync.ts new file mode 100644 index 0000000..2ad649f --- /dev/null +++ b/src/CellularManagement.WebUI/src/hooks/useAuthSync.ts @@ -0,0 +1,8 @@ +import { useEffect } from 'react'; +import { User } from '@/types/auth'; + +export function useAuthSync(user: User | null, setGlobalUser: (user: User | null) => void) { + useEffect(() => { + setGlobalUser(user); + }, [user, setGlobalUser]); +} \ No newline at end of file diff --git a/src/CellularManagement.WebUI/src/pages/auth/LoginPage.tsx b/src/CellularManagement.WebUI/src/pages/auth/LoginPage.tsx index 21730db..dfa2f3a 100644 --- a/src/CellularManagement.WebUI/src/pages/auth/LoginPage.tsx +++ b/src/CellularManagement.WebUI/src/pages/auth/LoginPage.tsx @@ -7,9 +7,9 @@ export function LoginPage() { const location = useLocation(); const { login, error } = useAuth(); - const handleLogin = async (username: string, password: string) => { + const handleLogin = async (username: string, password: string, rememberMe: boolean) => { try { - await login({ username, password }); + await login({ username, password, rememberMe }); const from = (location.state as any)?.from?.pathname || '/dashboard'; navigate(from, { replace: true }); } catch (error) { @@ -24,7 +24,7 @@ export function LoginPage() {

登录

- {error && ( + {typeof error === 'string' && error && (
{error}
diff --git a/src/CellularManagement.WebUI/src/services/apiService.ts b/src/CellularManagement.WebUI/src/services/apiService.ts new file mode 100644 index 0000000..64a3673 --- /dev/null +++ b/src/CellularManagement.WebUI/src/services/apiService.ts @@ -0,0 +1,82 @@ +import { LoginRequest, LoginResponse, User, OperationResult } from '@/types/auth'; +import { httpClient } from '@/lib/http-client'; +import { AUTH_CONSTANTS } from '@/constants/auth'; + +export interface ApiService { + login: (request: LoginRequest) => Promise>; + refreshToken: (refreshToken: string) => Promise>; + logout: () => Promise>; + getCurrentUser: () => Promise>; +} + +class ApiError extends Error { + constructor(message: string, public statusCode?: number) { + super(message); + this.name = 'ApiError'; + } +} + +export const apiService: ApiService = { + login: async (request: LoginRequest): Promise> => { + try { + const response = await httpClient.post('/Auth/Login', request); + return { + success: true, + data: response.data, + message: AUTH_CONSTANTS.MESSAGES.LOGIN_SUCCESS + }; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || AUTH_CONSTANTS.MESSAGES.LOGIN_FAILED + }; + } + }, + + refreshToken: async (refreshToken: string): Promise> => { + try { + const response = await httpClient.post('/Auth/RefreshToken', { refreshToken }); + return { + success: true, + data: response.data, + message: AUTH_CONSTANTS.MESSAGES.TOKEN_REFRESHED + }; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || AUTH_CONSTANTS.MESSAGES.TOKEN_REFRESH_FAILED + }; + } + }, + + logout: async (): Promise> => { + try { + await httpClient.post('/Auth/Logout'); + return { + success: true, + message: AUTH_CONSTANTS.MESSAGES.LOGOUT_SUCCESS + }; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || AUTH_CONSTANTS.MESSAGES.LOGOUT_FAILED + }; + } + }, + + getCurrentUser: async (): Promise> => { + try { + const response = await httpClient.get('/Users/CurrentUser'); + return { + success: true, + data: response.data, + message: AUTH_CONSTANTS.MESSAGES.USER_FETCHED + }; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || AUTH_CONSTANTS.MESSAGES.USER_FETCH_FAILED + }; + } + } +}; \ No newline at end of file diff --git a/src/CellularManagement.WebUI/src/services/authService.ts b/src/CellularManagement.WebUI/src/services/authService.ts index 701ddb3..271c7eb 100644 --- a/src/CellularManagement.WebUI/src/services/authService.ts +++ b/src/CellularManagement.WebUI/src/services/authService.ts @@ -1,55 +1,207 @@ -import { LoginRequest, LoginResponse, OperationResult } from '@/types/auth'; -import { httpClient } from '@/lib/http-client'; +import { LoginRequest, User, OperationResult, AuthAction } from '@/types/auth'; +import { storageService } from './storageService'; +import { apiService } from '@/services/apiService'; +import { AUTH_CONSTANTS } from '@/constants/auth'; -export const authService = { - async login(request: LoginRequest): Promise> { - try { - const response = await httpClient.post('/Auth/Login', request); - return response; - } catch (error: any) { - return { - successMessage: null, - errorMessages: error.errorMessages || ['登录请求失败,请稍后重试'], - data: null, - isSuccess: false, - }; +export interface AuthService { + isTokenExpired: () => boolean; + checkLoginAttempts: () => boolean; + updateLoginAttempts: (success: boolean) => void; + handleLogin: (request: LoginRequest, dispatch: React.Dispatch) => Promise; + handleLogout: (dispatch: React.Dispatch) => Promise; + handleRefreshToken: (dispatch: React.Dispatch) => Promise; + initializeAuth: (dispatch: React.Dispatch) => Promise; +} + +class AuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthError'; + } +} + +export const authService: AuthService = { + isTokenExpired: () => { + const expiryTime = storageService.getTokenExpiry(); + if (!expiryTime) return true; + return Date.now() > parseInt(expiryTime); + }, + + checkLoginAttempts: () => { + const attempts = storageService.getLoginAttempts(); + const lastAttempt = storageService.getLastLoginAttempt(); + const now = Date.now(); + + if (attempts >= AUTH_CONSTANTS.AUTH_CONFIG.MAX_LOGIN_ATTEMPTS) { + if (now - lastAttempt < AUTH_CONSTANTS.AUTH_CONFIG.LOGIN_ATTEMPTS_RESET_TIME) { + throw new AuthError(AUTH_CONSTANTS.MESSAGES.ACCOUNT_LOCKED); + } + storageService.removeLoginAttempts(); + storageService.removeLastLoginAttempt(); + } + return true; + }, + + updateLoginAttempts: (success: boolean) => { + if (success) { + storageService.removeLoginAttempts(); + storageService.removeLastLoginAttempt(); + } else { + const attempts = storageService.getLoginAttempts() + 1; + storageService.setLoginAttempts(attempts); + storageService.setLastLoginAttempt(Date.now()); } }, - // 刷新token - async refreshToken(refreshToken: string): Promise> { + handleLogin: async (request: LoginRequest, dispatch: React.Dispatch) => { try { - const response = await httpClient.post('/Auth/RefreshToken', { - refreshToken, + console.log('[authService] handleLogin start', request); + dispatch({ type: 'LOGIN_START' }); + const result = await apiService.login(request); + console.log('[authService] login result', result); + if (!result.success) { + throw new AuthError(result.message || AUTH_CONSTANTS.MESSAGES.LOGIN_FAILED); + } + const { accessToken, refreshToken, user } = result.data!; + const expiryTime = Date.now() + AUTH_CONSTANTS.AUTH_CONFIG.TOKEN_EXPIRY_TIME; + storageService.setAccessToken(accessToken); + storageService.setRefreshToken(refreshToken); + storageService.setTokenExpiry(expiryTime); + storageService.setRememberMe(request.rememberMe); + console.log('[authService] tokens saved', { accessToken, refreshToken, expiryTime }); + dispatch({ + type: 'LOGIN_SUCCESS', + payload: { user, accessToken, refreshToken, rememberMe: request.rememberMe } }); - return response; } catch (error: any) { - return { - successMessage: null, - errorMessages: error.errorMessages || ['刷新令牌失败,请重新登录'], - data: null, - isSuccess: false, - }; + console.error('[authService] handleLogin error', error); + dispatch({ + type: 'LOGIN_FAILURE', + payload: { error: error.message || AUTH_CONSTANTS.MESSAGES.UNKNOWN_ERROR } + }); + throw error; } }, - // 登出 - async logout(): Promise { - await httpClient.post('/Auth/Logout'); + handleLogout: async (dispatch: React.Dispatch) => { + try { + console.log('[authService] handleLogout start'); + const result = await apiService.logout(); + console.log('[authService] logout result', result); + if (!result.success) { + throw new AuthError(result.message || AUTH_CONSTANTS.MESSAGES.LOGOUT_FAILED); + } + storageService.clearAuth(); + console.log('[authService] tokens cleared'); + dispatch({ type: 'LOGOUT' }); + } catch (error: any) { + console.error('[authService] handleLogout error', error); + dispatch({ + type: 'LOGIN_FAILURE', + payload: { error: error.message || AUTH_CONSTANTS.MESSAGES.UNKNOWN_ERROR } + }); + throw error; + } }, - // 获取当前用户信息 - async getCurrentUser(): Promise> { + handleRefreshToken: async (dispatch: React.Dispatch) => { try { - const response = await httpClient.get('/Users/CurrentUser'); - return response; + const refreshToken = storageService.getRefreshToken(); + console.log('[authService] handleRefreshToken start', { refreshToken }); + if (!refreshToken) { + throw new AuthError(AUTH_CONSTANTS.MESSAGES.TOKEN_EXPIRED); + } + const result = await apiService.refreshToken(refreshToken); + console.log('[authService] refreshToken result', result); + if (!result.success || !result.data) { + throw new AuthError(result.message || AUTH_CONSTANTS.MESSAGES.TOKEN_REFRESH_FAILED); + } + const { accessToken, refreshToken: newRefreshToken, user } = result.data; + const expiryTime = Date.now() + AUTH_CONSTANTS.AUTH_CONFIG.TOKEN_EXPIRY_TIME; + storageService.setAccessToken(accessToken); + storageService.setRefreshToken(newRefreshToken); + storageService.setTokenExpiry(expiryTime); + console.log('[authService] tokens refreshed', { accessToken, newRefreshToken, expiryTime }); + dispatch({ + type: 'SET_USER', + payload: { user, accessToken, refreshToken: newRefreshToken } + }); } catch (error: any) { - return { - successMessage: null, - errorMessages: error.errorMessages || ['获取用户信息失败'], - data: null, - isSuccess: false, - }; + console.error('[authService] handleRefreshToken error', error); + dispatch({ + type: 'LOGIN_FAILURE', + payload: { error: error.message || AUTH_CONSTANTS.MESSAGES.UNKNOWN_ERROR } + }); + throw error; } }, + + initializeAuth: async (dispatch: React.Dispatch) => { + try { + console.log('[authService] 开始初始化认证'); + dispatch({ type: 'LOGIN_START' }); + + const accessToken = storageService.getAccessToken(); + const refreshToken = storageService.getRefreshToken(); + const expiry = storageService.getTokenExpiry(); + console.log('[authService] initializeAuth - 初始状态', { + accessToken: accessToken ? '存在' : '不存在', + refreshToken: refreshToken ? '存在' : '不存在', + expiry, + currentTime: Date.now(), + isExpired: authService.isTokenExpired() + }); + + if (!accessToken || authService.isTokenExpired()) { + console.log('[authService] token 缺失或过期,尝试刷新'); + if (!refreshToken) { + console.log('[authService] 没有刷新令牌,需要重新登录'); + dispatch({ + type: 'LOGIN_FAILURE', + payload: { error: AUTH_CONSTANTS.MESSAGES.TOKEN_EXPIRED } + }); + return; + } + await authService.handleRefreshToken(dispatch); + return; + } + + console.log('[authService] 开始获取当前用户信息'); + const result = await apiService.getCurrentUser(); + console.log('[authService] getCurrentUser 结果', { + success: result.success, + message: result.message, + hasData: !!result.data, + data: result.data + }); + + if (!result.success) { + console.error('[authService] 获取用户信息失败', result.message); + throw new AuthError(result.message || AUTH_CONSTANTS.MESSAGES.USER_FETCH_FAILED); + } + + if (!result.data) { + console.error('[authService] 用户数据为空'); + throw new AuthError(AUTH_CONSTANTS.MESSAGES.USER_FETCH_FAILED); + } + + console.log('[authService] 设置用户信息到状态'); + dispatch({ + type: 'LOGIN_SUCCESS', + payload: { + user: result.data, + accessToken, + refreshToken: storageService.getRefreshToken() || '', + rememberMe: storageService.getRememberMe() + } + }); + } catch (error: any) { + console.error('[authService] initializeAuth 错误', error); + dispatch({ + type: 'LOGIN_FAILURE', + payload: { error: error.message || AUTH_CONSTANTS.MESSAGES.UNKNOWN_ERROR } + }); + throw error; + } + } }; \ No newline at end of file diff --git a/src/CellularManagement.WebUI/src/services/axiosConfig.ts b/src/CellularManagement.WebUI/src/services/axiosConfig.ts new file mode 100644 index 0000000..80bd4c2 --- /dev/null +++ b/src/CellularManagement.WebUI/src/services/axiosConfig.ts @@ -0,0 +1,88 @@ +import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios'; +import { authService } from './authService'; + +const TOKEN_EXPIRY_KEY = 'tokenExpiry'; +const TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000; // 5分钟 + +class AxiosConfig { + private instance: AxiosInstance; + + constructor() { + this.instance = axios.create({ + baseURL: process.env.REACT_APP_API_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, + }); + + this.setupInterceptors(); + } + + private setupInterceptors() { + // 请求拦截器 + this.instance.interceptors.request.use( + async (config: InternalAxiosRequestConfig) => { + const token = localStorage.getItem('accessToken'); + if (token) { + // 检查token是否即将过期 + const expiry = localStorage.getItem(TOKEN_EXPIRY_KEY); + if (expiry) { + const expiryTime = parseInt(expiry); + const currentTime = new Date().getTime(); + if (expiryTime - currentTime < TOKEN_REFRESH_THRESHOLD) { + try { + await authService.refreshToken(localStorage.getItem('refreshToken') || ''); + } catch (error) { + // 刷新失败,清除token + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem(TOKEN_EXPIRY_KEY); + window.location.href = '/login'; + } + } + } + config.headers.Authorization = `Bearer ${localStorage.getItem('accessToken')}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } + ); + + // 响应拦截器 + this.instance.interceptors.response.use( + (response: AxiosResponse) => { + return response; + }, + async (error) => { + if (error.response?.status === 401) { + // token过期,尝试刷新 + try { + const refreshToken = localStorage.getItem('refreshToken'); + if (refreshToken) { + await authService.refreshToken(refreshToken); + // 重试原请求 + const config = error.config; + return this.instance(config); + } + } catch (refreshError) { + // 刷新失败,清除token并跳转到登录页 + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem(TOKEN_EXPIRY_KEY); + window.location.href = '/login'; + } + } + return Promise.reject(error); + } + ); + } + + public getInstance(): AxiosInstance { + return this.instance; + } +} + +export const axiosInstance = new AxiosConfig().getInstance(); \ No newline at end of file diff --git a/src/CellularManagement.WebUI/src/services/storageService.ts b/src/CellularManagement.WebUI/src/services/storageService.ts new file mode 100644 index 0000000..140977c --- /dev/null +++ b/src/CellularManagement.WebUI/src/services/storageService.ts @@ -0,0 +1,206 @@ +import { AUTH_CONSTANTS } from '@/constants/auth'; + +export interface StorageService { + // Token 相关 + setAccessToken: (token: string) => void; + getAccessToken: () => string | null; + removeAccessToken: () => void; + + setRefreshToken: (token: string) => void; + getRefreshToken: () => string | null; + removeRefreshToken: () => void; + + // Token 过期时间 + setTokenExpiry: (expiryTime: number) => void; + getTokenExpiry: () => string | null; + removeTokenExpiry: () => void; + + // 记住登录状态 + setRememberMe: (value: boolean) => void; + getRememberMe: () => boolean; + removeRememberMe: () => void; + + // 登录尝试次数 + setLoginAttempts: (attempts: number) => void; + getLoginAttempts: () => number; + removeLoginAttempts: () => void; + + // 最后登录尝试时间 + setLastLoginAttempt: (timestamp: number) => void; + getLastLoginAttempt: () => number; + removeLastLoginAttempt: () => void; + + // 清除所有认证相关存储 + clearAuth: () => void; +} + +class StorageError extends Error { + constructor(message: string) { + super(message); + this.name = 'StorageError'; + } +} + +export const storageService: StorageService = { + // Token 相关 + setAccessToken: (token: string) => { + try { + localStorage.setItem(AUTH_CONSTANTS.STORAGE_KEYS.ACCESS_TOKEN, token); + } catch (error) { + throw new StorageError('设置访问令牌失败'); + } + }, + + getAccessToken: () => { + try { + return localStorage.getItem(AUTH_CONSTANTS.STORAGE_KEYS.ACCESS_TOKEN); + } catch (error) { + throw new StorageError('获取访问令牌失败'); + } + }, + + removeAccessToken: () => { + try { + localStorage.removeItem(AUTH_CONSTANTS.STORAGE_KEYS.ACCESS_TOKEN); + } catch (error) { + throw new StorageError('移除访问令牌失败'); + } + }, + + setRefreshToken: (token: string) => { + try { + localStorage.setItem(AUTH_CONSTANTS.STORAGE_KEYS.REFRESH_TOKEN, token); + } catch (error) { + throw new StorageError('设置刷新令牌失败'); + } + }, + + getRefreshToken: () => { + try { + return localStorage.getItem(AUTH_CONSTANTS.STORAGE_KEYS.REFRESH_TOKEN); + } catch (error) { + throw new StorageError('获取刷新令牌失败'); + } + }, + + removeRefreshToken: () => { + try { + localStorage.removeItem(AUTH_CONSTANTS.STORAGE_KEYS.REFRESH_TOKEN); + } catch (error) { + throw new StorageError('移除刷新令牌失败'); + } + }, + + // Token 过期时间 + setTokenExpiry: (expiryTime: number) => { + try { + localStorage.setItem(AUTH_CONSTANTS.STORAGE_KEYS.TOKEN_EXPIRY, expiryTime.toString()); + } catch (error) { + throw new StorageError('设置令牌过期时间失败'); + } + }, + + getTokenExpiry: () => { + try { + return localStorage.getItem(AUTH_CONSTANTS.STORAGE_KEYS.TOKEN_EXPIRY); + } catch (error) { + throw new StorageError('获取令牌过期时间失败'); + } + }, + + removeTokenExpiry: () => { + try { + localStorage.removeItem(AUTH_CONSTANTS.STORAGE_KEYS.TOKEN_EXPIRY); + } catch (error) { + throw new StorageError('移除令牌过期时间失败'); + } + }, + + // 记住登录状态 + setRememberMe: (value: boolean) => { + try { + localStorage.setItem(AUTH_CONSTANTS.STORAGE_KEYS.REMEMBER_ME, value.toString()); + } catch (error) { + throw new StorageError('设置记住登录状态失败'); + } + }, + + getRememberMe: () => { + try { + return localStorage.getItem(AUTH_CONSTANTS.STORAGE_KEYS.REMEMBER_ME) === 'true'; + } catch (error) { + throw new StorageError('获取记住登录状态失败'); + } + }, + + removeRememberMe: () => { + try { + localStorage.removeItem(AUTH_CONSTANTS.STORAGE_KEYS.REMEMBER_ME); + } catch (error) { + throw new StorageError('移除记住登录状态失败'); + } + }, + + // 登录尝试次数 + setLoginAttempts: (attempts: number) => { + try { + localStorage.setItem(AUTH_CONSTANTS.STORAGE_KEYS.LOGIN_ATTEMPTS, attempts.toString()); + } catch (error) { + throw new StorageError('设置登录尝试次数失败'); + } + }, + + getLoginAttempts: () => { + try { + const attempts = localStorage.getItem(AUTH_CONSTANTS.STORAGE_KEYS.LOGIN_ATTEMPTS); + return attempts ? parseInt(attempts) : 0; + } catch (error) { + throw new StorageError('获取登录尝试次数失败'); + } + }, + + removeLoginAttempts: () => { + try { + localStorage.removeItem(AUTH_CONSTANTS.STORAGE_KEYS.LOGIN_ATTEMPTS); + } catch (error) { + throw new StorageError('移除登录尝试次数失败'); + } + }, + + // 最后登录尝试时间 + setLastLoginAttempt: (timestamp: number) => { + try { + localStorage.setItem(AUTH_CONSTANTS.STORAGE_KEYS.LAST_LOGIN_ATTEMPT, timestamp.toString()); + } catch (error) { + throw new StorageError('设置最后登录尝试时间失败'); + } + }, + + getLastLoginAttempt: () => { + try { + const timestamp = localStorage.getItem(AUTH_CONSTANTS.STORAGE_KEYS.LAST_LOGIN_ATTEMPT); + return timestamp ? parseInt(timestamp) : 0; + } catch (error) { + throw new StorageError('获取最后登录尝试时间失败'); + } + }, + + removeLastLoginAttempt: () => { + try { + localStorage.removeItem(AUTH_CONSTANTS.STORAGE_KEYS.LAST_LOGIN_ATTEMPT); + } catch (error) { + throw new StorageError('移除最后登录尝试时间失败'); + } + }, + + // 清除所有认证相关存储 + clearAuth: () => { + try { + Object.values(AUTH_CONSTANTS.STORAGE_KEYS).forEach(key => { + localStorage.removeItem(key); + }); + } catch (error) { + throw new StorageError('清除认证存储失败'); + } + } +}; \ No newline at end of file diff --git a/src/CellularManagement.WebUI/src/types/auth.ts b/src/CellularManagement.WebUI/src/types/auth.ts index af636d4..d44c2d6 100644 --- a/src/CellularManagement.WebUI/src/types/auth.ts +++ b/src/CellularManagement.WebUI/src/types/auth.ts @@ -2,14 +2,15 @@ import { Permission } from '@/constants/menuConfig'; export interface User { id: string; - name: string; + userName: string; email: string; - permissions: Permission[]; + permissions: Record; } export interface LoginRequest { username: string; password: string; + rememberMe: boolean; } export interface LoginResponse { @@ -23,18 +24,28 @@ export interface AuthState { isAuthenticated: boolean; isLoading: boolean; error: string | null; - userPermissions: Permission[]; + userPermissions: string[]; + rememberMe: boolean; } +export type AuthAction = + | { type: 'LOGIN_START' } + | { type: 'LOGIN_SUCCESS'; payload: { user: User; accessToken: string; refreshToken: string; rememberMe: boolean } } + | { type: 'LOGIN_FAILURE'; payload: { error: string } } + | { type: 'LOGOUT' } + | { type: 'CLEAR_ERROR' } + | { type: 'SET_USER'; payload: { user: User; accessToken: string; refreshToken: string } } + | { type: 'SET_REMEMBER_ME'; payload: boolean }; + export interface AuthContextType extends AuthState { login: (request: LoginRequest) => Promise; logout: () => Promise; refreshToken: () => Promise; } -export interface OperationResult { - successMessage: string | null; - errorMessages: string[] | null; - data: T | null; - isSuccess: boolean; +export interface OperationResult { + success: boolean; + data?: T; + message?: string; + errorMessages?: string[]; } \ No newline at end of file diff --git a/src/CellularManagement.WebUI/src/utils/getUserInfo.ts b/src/CellularManagement.WebUI/src/utils/getUserInfo.ts new file mode 100644 index 0000000..583aedb --- /dev/null +++ b/src/CellularManagement.WebUI/src/utils/getUserInfo.ts @@ -0,0 +1,19 @@ +interface User { + userId?: string; + userName?: string; + email?: string; + phoneNumber?: string; +} + +export function getUserInfo(user: User | { user?: User } | null) { + let realUser: User | undefined; + if (user && typeof user === 'object' && 'user' in user) { + realUser = user.user; + } else { + realUser = user as User | undefined; + } + return { + userName: realUser?.userName || '未登录', + email: realUser?.email || '' + }; +} \ No newline at end of file