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