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:
iven
2026-04-11 03:38:29 +08:00
parent a7cdf67d17
commit 4a03a639a6
9 changed files with 421 additions and 27 deletions

View File

@@ -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 { ConfigProvider, theme as antdTheme } from 'antd';
import zhCN from 'antd/locale/zh_CN'; import zhCN from 'antd/locale/zh_CN';
import MainLayout from './layouts/MainLayout'; 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'; import { useAppStore } from './stores/app';
function HomePage() { function PrivateRoute({ children }: { children: React.ReactNode }) {
return <div> ERP </div>; const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
} }
export default function App() { 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 ( return (
<ConfigProvider <ConfigProvider
locale={zhCN} locale={zhCN}
theme={{ theme={{
algorithm: appTheme === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm, algorithm: theme === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
}} }}
> >
<HashRouter> <HashRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<PrivateRoute>
<MainLayout> <MainLayout>
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<Home />} />
<Route path="/users" element={<div></div>} />
<Route path="/roles" element={<div></div>} />
<Route path="/settings" element={<div></div>} />
</Routes> </Routes>
</MainLayout> </MainLayout>
</PrivateRoute>
}
/>
</Routes>
</HashRouter> </HashRouter>
</ConfigProvider> </ConfigProvider>
); );

52
apps/web/src/api/auth.ts Normal file
View 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');
}

View 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
View 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 });
}

View File

@@ -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 { import {
HomeOutlined, HomeOutlined,
UserOutlined, UserOutlined,
@@ -7,8 +7,11 @@ import {
SettingOutlined, SettingOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
LogoutOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../stores/app'; import { useAppStore } from '../stores/app';
import { useAuthStore } from '../stores/auth';
const { Header, Sider, Content, Footer } = Layout; const { Header, Sider, Content, Footer } = Layout;
@@ -20,8 +23,22 @@ const menuItems = [
]; ];
export default function MainLayout({ children }: { children: React.ReactNode }) { 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 { token } = theme.useToken();
const navigate = useNavigate();
const userMenuItems = [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: async () => {
await logout();
navigate('/login');
},
},
];
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
@@ -39,7 +56,13 @@ export default function MainLayout({ children }: { children: React.ReactNode })
> >
{sidebarCollapsed ? 'E' : 'ERP Platform'} {sidebarCollapsed ? 'E' : 'ERP Platform'}
</div> </div>
<Menu theme="dark" mode="inline" items={menuItems} defaultSelectedKeys={['/']} /> <Menu
theme="dark"
mode="inline"
items={menuItems}
defaultSelectedKeys={['/']}
onClick={({ key }) => navigate(key)}
/>
</Sider> </Sider>
<Layout> <Layout>
<Header <Header
@@ -60,11 +83,13 @@ export default function MainLayout({ children }: { children: React.ReactNode })
/> />
</Space> </Space>
<Space size="middle"> <Space size="middle">
<Badge count={5}> <BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
<BellOutlined style={{ fontSize: 18 }} /> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
</Badge> <Space style={{ cursor: 'pointer' }}>
<Avatar icon={<UserOutlined />} /> <Avatar icon={<UserOutlined />} />
<span>Admin</span> <span>{user?.display_name || user?.username || 'User'}</span>
</Space>
</Dropdown>
</Space> </Space>
</Header> </Header>
<Content <Content
@@ -79,7 +104,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
{children} {children}
</Content> </Content>
<Footer style={{ textAlign: 'center', padding: '8px 16px' }}> <Footer style={{ textAlign: 'center', padding: '8px 16px' }}>
{tenantName || 'ERP Platform'} · v0.1.0 ERP Platform · v0.1.0
</Footer> </Footer>
</Layout> </Layout>
</Layout> </Layout>

View 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>
);
}

View 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>
);
}

View File

@@ -1,23 +1,15 @@
import { create } from 'zustand'; import { create } from 'zustand';
interface AppState { interface AppState {
isLoggedIn: boolean;
tenantName: string;
theme: 'light' | 'dark'; theme: 'light' | 'dark';
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
toggleSidebar: () => void; toggleSidebar: () => void;
setTheme: (theme: 'light' | 'dark') => void; setTheme: (theme: 'light' | 'dark') => void;
login: () => void;
logout: () => void;
} }
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>((set) => ({
isLoggedIn: false,
tenantName: '',
theme: 'light', theme: 'light',
sidebarCollapsed: false, sidebarCollapsed: false,
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setTheme: (theme) => set({ theme }), setTheme: (theme) => set({ theme }),
login: () => set({ isLoggedIn: true }),
logout: () => set({ isLoggedIn: false }),
})); }));

View 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');
}
}
},
}));