diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index bf386a9..08c47af 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,29 +1,53 @@ -import { HashRouter, Routes, Route } from 'react-router-dom'; +import { useEffect } from 'react'; +import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'; import { ConfigProvider, theme as antdTheme } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import MainLayout from './layouts/MainLayout'; +import Login from './pages/Login'; +import Home from './pages/Home'; +import { useAuthStore } from './stores/auth'; import { useAppStore } from './stores/app'; -function HomePage() { - return
欢迎来到 ERP 平台
; +function PrivateRoute({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + return isAuthenticated ? <>{children} : ; } export default function App() { - const { theme: appTheme } = useAppStore(); + const loadFromStorage = useAuthStore((s) => s.loadFromStorage); + const theme = useAppStore((s) => s.theme); + + // Restore auth state from localStorage on app load + useEffect(() => { + loadFromStorage(); + }, [loadFromStorage]); return ( - - - } /> - - + + } /> + + + + } /> + 用户管理(开发中)} /> + 权限管理(开发中)} /> + 系统设置(开发中)} /> + + + + } + /> + ); diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts new file mode 100644 index 0000000..ba813f3 --- /dev/null +++ b/apps/web/src/api/auth.ts @@ -0,0 +1,52 @@ +import client from './client'; + +export interface LoginRequest { + username: string; + password: string; +} + +export interface UserInfo { + id: string; + username: string; + email?: string; + phone?: string; + display_name?: string; + avatar_url?: string; + status: string; + roles: RoleInfo[]; +} + +export interface RoleInfo { + id: string; + name: string; + code: string; + description?: string; + is_system: boolean; +} + +export interface LoginResponse { + access_token: string; + refresh_token: string; + expires_in: number; + user: UserInfo; +} + +export async function login(req: LoginRequest): Promise { + const { data } = await client.post<{ success: boolean; data: LoginResponse }>( + '/auth/login', + req + ); + return data.data; +} + +export async function refresh(refreshToken: string): Promise { + const { data } = await client.post<{ success: boolean; data: LoginResponse }>( + '/auth/refresh', + { refresh_token: refreshToken } + ); + return data.data; +} + +export async function logout(): Promise { + await client.post('/auth/logout'); +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..c600010 --- /dev/null +++ b/apps/web/src/api/client.ts @@ -0,0 +1,84 @@ +import axios from 'axios'; + +const client = axios.create({ + baseURL: '/api/v1', + timeout: 10000, + headers: { 'Content-Type': 'application/json' }, +}); + +// Request interceptor: attach access token +client.interceptors.request.use((config) => { + const token = localStorage.getItem('access_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Response interceptor: auto-refresh on 401 +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (token: string) => void; + reject: (error: unknown) => void; +}> = []; + +function processQueue(error: unknown, token: string | null) { + failedQueue.forEach(({ resolve, reject }) => { + if (token) resolve(token); + else reject(error); + }); + failedQueue = []; +} + +client.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + return client(originalRequest); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const refreshToken = localStorage.getItem('refresh_token'); + if (!refreshToken) throw new Error('No refresh token'); + + const { data } = await axios.post('/api/v1/auth/refresh', { + refresh_token: refreshToken, + }); + + const newAccessToken = data.data.access_token; + const newRefreshToken = data.data.refresh_token; + + localStorage.setItem('access_token', newAccessToken); + localStorage.setItem('refresh_token', newRefreshToken); + + processQueue(null, newAccessToken); + + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + return client(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + window.location.hash = '#/login'; + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + return Promise.reject(error); + } +); + +export default client; diff --git a/apps/web/src/api/users.ts b/apps/web/src/api/users.ts new file mode 100644 index 0000000..43062ac --- /dev/null +++ b/apps/web/src/api/users.ts @@ -0,0 +1,56 @@ +import client from './client'; +import type { UserInfo } from './auth'; + +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface CreateUserRequest { + username: string; + password: string; + email?: string; + phone?: string; + display_name?: string; +} + +export interface UpdateUserRequest { + email?: string; + phone?: string; + display_name?: string; + status?: string; +} + +export async function listUsers(page = 1, pageSize = 20) { + const { data } = await client.get<{ success: boolean; data: PaginatedResponse }>( + '/users', + { params: { page, page_size: pageSize } } + ); + return data.data; +} + +export async function getUser(id: string) { + const { data } = await client.get<{ success: boolean; data: UserInfo }>(`/users/${id}`); + return data.data; +} + +export async function createUser(req: CreateUserRequest) { + const { data } = await client.post<{ success: boolean; data: UserInfo }>('/users', req); + return data.data; +} + +export async function updateUser(id: string, req: UpdateUserRequest) { + const { data } = await client.put<{ success: boolean; data: UserInfo }>(`/users/${id}`, req); + return data.data; +} + +export async function deleteUser(id: string) { + await client.delete(`/users/${id}`); +} + +export async function assignRoles(userId: string, roleIds: string[]) { + await client.post(`/users/${userId}/roles`, { role_ids: roleIds }); +} diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index d38c16f..633b66a 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -1,4 +1,4 @@ -import { Layout, Menu, theme, Button, Avatar, Badge, Space } from 'antd'; +import { Layout, Menu, theme, Avatar, Space, Dropdown, Button } from 'antd'; import { HomeOutlined, UserOutlined, @@ -7,8 +7,11 @@ import { SettingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, + LogoutOutlined, } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; import { useAppStore } from '../stores/app'; +import { useAuthStore } from '../stores/auth'; const { Header, Sider, Content, Footer } = Layout; @@ -20,8 +23,22 @@ const menuItems = [ ]; export default function MainLayout({ children }: { children: React.ReactNode }) { - const { sidebarCollapsed, toggleSidebar, tenantName } = useAppStore(); + const { sidebarCollapsed, toggleSidebar } = useAppStore(); + const { user, logout } = useAuthStore(); const { token } = theme.useToken(); + const navigate = useNavigate(); + + const userMenuItems = [ + { + key: 'logout', + icon: , + label: '退出登录', + onClick: async () => { + await logout(); + navigate('/login'); + }, + }, + ]; return ( @@ -39,7 +56,13 @@ export default function MainLayout({ children }: { children: React.ReactNode }) > {sidebarCollapsed ? 'E' : 'ERP Platform'} - + navigate(key)} + />
- - - - } /> - Admin + + + + } /> + {user?.display_name || user?.username || 'User'} + +
- {tenantName || 'ERP Platform'} · v0.1.0 + ERP Platform · v0.1.0
diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx new file mode 100644 index 0000000..2cd75a5 --- /dev/null +++ b/apps/web/src/pages/Home.tsx @@ -0,0 +1,37 @@ +import { Typography, Card, Row, Col, Statistic } from 'antd'; +import { + UserOutlined, + TeamOutlined, + FileTextOutlined, + BellOutlined, +} from '@ant-design/icons'; + +export default function Home() { + return ( +
+ 工作台 + + + + } /> + + + + + } /> + + + + + } /> + + + + + } /> + + + +
+ ); +} diff --git a/apps/web/src/pages/Login.tsx b/apps/web/src/pages/Login.tsx new file mode 100644 index 0000000..5d12831 --- /dev/null +++ b/apps/web/src/pages/Login.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Form, Input, Button, Card, message, Typography } from 'antd'; +import { UserOutlined, LockOutlined } from '@ant-design/icons'; +import { useAuthStore } from '../stores/auth'; + +const { Title } = Typography; + +export default function Login() { + const navigate = useNavigate(); + const login = useAuthStore((s) => s.login); + const loading = useAuthStore((s) => s.loading); + const [messageApi, contextHolder] = message.useMessage(); + + const onFinish = async (values: { username: string; password: string }) => { + try { + await login(values.username, values.password); + messageApi.success('登录成功'); + navigate('/'); + } catch (err: unknown) { + const errorMsg = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message || + '登录失败,请检查用户名和密码'; + messageApi.error(errorMsg); + } + }; + + return ( +
+ {contextHolder} + +
+ + ERP Platform + + 企业资源管理平台 +
+
+ + } placeholder="用户名" /> + + + } placeholder="密码" /> + + + + +
+
+
+ ); +} diff --git a/apps/web/src/stores/app.ts b/apps/web/src/stores/app.ts index 42a2faf..34eab85 100644 --- a/apps/web/src/stores/app.ts +++ b/apps/web/src/stores/app.ts @@ -1,23 +1,15 @@ import { create } from 'zustand'; interface AppState { - isLoggedIn: boolean; - tenantName: string; theme: 'light' | 'dark'; sidebarCollapsed: boolean; toggleSidebar: () => void; setTheme: (theme: 'light' | 'dark') => void; - login: () => void; - logout: () => void; } export const useAppStore = create((set) => ({ - isLoggedIn: false, - tenantName: '', theme: 'light', sidebarCollapsed: false, toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), setTheme: (theme) => set({ theme }), - login: () => set({ isLoggedIn: true }), - logout: () => set({ isLoggedIn: false }), })); diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts new file mode 100644 index 0000000..8cabba0 --- /dev/null +++ b/apps/web/src/stores/auth.ts @@ -0,0 +1,56 @@ +import { create } from 'zustand'; +import { login as apiLogin, logout as apiLogout, type UserInfo } from '../api/auth'; + +interface AuthState { + user: UserInfo | null; + isAuthenticated: boolean; + loading: boolean; + login: (username: string, password: string) => Promise; + logout: () => Promise; + loadFromStorage: () => void; +} + +export const useAuthStore = create((set) => ({ + user: null, + isAuthenticated: false, + loading: false, + + login: async (username, password) => { + set({ loading: true }); + try { + const resp = await apiLogin({ username, password }); + localStorage.setItem('access_token', resp.access_token); + localStorage.setItem('refresh_token', resp.refresh_token); + localStorage.setItem('user', JSON.stringify(resp.user)); + set({ user: resp.user, isAuthenticated: true, loading: false }); + } catch (error) { + set({ loading: false }); + throw error; + } + }, + + logout: async () => { + try { + await apiLogout(); + } catch { + // Ignore logout API errors + } + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user'); + set({ user: null, isAuthenticated: false }); + }, + + loadFromStorage: () => { + const token = localStorage.getItem('access_token'); + const userStr = localStorage.getItem('user'); + if (token && userStr) { + try { + const user = JSON.parse(userStr) as UserInfo; + set({ user, isAuthenticated: true }); + } catch { + localStorage.removeItem('user'); + } + } + }, +}));