feat(web): add login page, auth store, API client, and route guard
- API client with axios interceptors: JWT attach + 401 auto-refresh - Auth store (Zustand): login/logout/loadFromStorage with localStorage - Login page: gradient background, Ant Design form, error handling - Home page: dashboard with statistics cards - App.tsx: PrivateRoute guard, /login route, auth state restoration - MainLayout: dynamic user display, logout dropdown, menu navigation - Users API service: CRUD with pagination support
This commit is contained in:
@@ -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 <div>欢迎来到 ERP 平台</div>;
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: appTheme === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
algorithm: theme === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
}}
|
||||
>
|
||||
<HashRouter>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/users" element={<div>用户管理(开发中)</div>} />
|
||||
<Route path="/roles" element={<div>权限管理(开发中)</div>} />
|
||||
<Route path="/settings" element={<div>系统设置(开发中)</div>} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
52
apps/web/src/api/auth.ts
Normal file
52
apps/web/src/api/auth.ts
Normal file
@@ -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<LoginResponse> {
|
||||
const { data } = await client.post<{ success: boolean; data: LoginResponse }>(
|
||||
'/auth/login',
|
||||
req
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function refresh(refreshToken: string): Promise<LoginResponse> {
|
||||
const { data } = await client.post<{ success: boolean; data: LoginResponse }>(
|
||||
'/auth/refresh',
|
||||
{ refresh_token: refreshToken }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await client.post('/auth/logout');
|
||||
}
|
||||
84
apps/web/src/api/client.ts
Normal file
84
apps/web/src/api/client.ts
Normal file
@@ -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;
|
||||
56
apps/web/src/api/users.ts
Normal file
56
apps/web/src/api/users.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import client from './client';
|
||||
import type { UserInfo } from './auth';
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
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<UserInfo> }>(
|
||||
'/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 });
|
||||
}
|
||||
@@ -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: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
@@ -39,7 +56,13 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
>
|
||||
{sidebarCollapsed ? 'E' : 'ERP Platform'}
|
||||
</div>
|
||||
<Menu theme="dark" mode="inline" items={menuItems} defaultSelectedKeys={['/']} />
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
items={menuItems}
|
||||
defaultSelectedKeys={['/']}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header
|
||||
@@ -60,11 +83,13 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
/>
|
||||
</Space>
|
||||
<Space size="middle">
|
||||
<Badge count={5}>
|
||||
<BellOutlined style={{ fontSize: 18 }} />
|
||||
</Badge>
|
||||
<Avatar icon={<UserOutlined />} />
|
||||
<span>Admin</span>
|
||||
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar icon={<UserOutlined />} />
|
||||
<span>{user?.display_name || user?.username || 'User'}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content
|
||||
@@ -79,7 +104,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
{children}
|
||||
</Content>
|
||||
<Footer style={{ textAlign: 'center', padding: '8px 16px' }}>
|
||||
{tenantName || 'ERP Platform'} · v0.1.0
|
||||
ERP Platform · v0.1.0
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
37
apps/web/src/pages/Home.tsx
Normal file
37
apps/web/src/pages/Home.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<Typography.Title level={4}>工作台</Typography.Title>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="用户总数" value={0} prefix={<UserOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="角色数量" value={0} prefix={<TeamOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="流程实例" value={0} prefix={<FileTextOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="未读消息" value={0} prefix={<BellOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
apps/web/src/pages/Login.tsx
Normal file
68
apps/web/src/pages/Login.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}
|
||||
>
|
||||
{contextHolder}
|
||||
<Card style={{ width: 400, borderRadius: 8 }} variant="borderless">
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>
|
||||
ERP Platform
|
||||
</Title>
|
||||
<Typography.Text type="secondary">企业资源管理平台</Typography.Text>
|
||||
</div>
|
||||
<Form name="login" onFinish={onFinish} autoComplete="off" size="large">
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<AppState>((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 }),
|
||||
}));
|
||||
|
||||
56
apps/web/src/stores/auth.ts
Normal file
56
apps/web/src/stores/auth.ts
Normal file
@@ -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<void>;
|
||||
logout: () => Promise<void>;
|
||||
loadFromStorage: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((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');
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user