init
This commit is contained in:
BIN
frontend/.DS_Store
vendored
Normal file
BIN
frontend/.DS_Store
vendored
Normal file
Binary file not shown.
46
frontend/README.md
Normal file
46
frontend/README.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
17346
frontend/package-lock.json
generated
Normal file
17346
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
frontend/package.json
Normal file
49
frontend/package.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.7.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"antd": "^5.27.0",
|
||||
"axios": "^1.11.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.8.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
43
frontend/public/index.html
Normal file
43
frontend/public/index.html
Normal file
@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
BIN
frontend/public/logo192.png
Normal file
BIN
frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/public/logo512.png
Normal file
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
BIN
frontend/src/.DS_Store
vendored
Normal file
BIN
frontend/src/.DS_Store
vendored
Normal file
Binary file not shown.
38
frontend/src/App.css
Normal file
38
frontend/src/App.css
Normal file
@ -0,0 +1,38 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
9
frontend/src/App.test.tsx
Normal file
9
frontend/src/App.test.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
87
frontend/src/App.tsx
Normal file
87
frontend/src/App.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { AuthProvider, useAuth } from './hooks/useAuth';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Users from './pages/Users';
|
||||
import Roles from './pages/Roles';
|
||||
import Permissions from './pages/Permissions';
|
||||
import Menus from './pages/Menus';
|
||||
import './App.css';
|
||||
|
||||
// 受保护的路由组件
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// 公共路由组件(已登录用户不能访问)
|
||||
const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
} />
|
||||
<Route path="/register" element={
|
||||
<PublicRoute>
|
||||
<Register />
|
||||
</PublicRoute>
|
||||
} />
|
||||
|
||||
{/* 受保护的路由 */}
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="roles" element={<Roles />} />
|
||||
<Route path="permissions" element={<Permissions />} />
|
||||
<Route path="menus" element={<Menus />} />
|
||||
</Route>
|
||||
|
||||
{/* 404 重定向 */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
175
frontend/src/components/Layout.tsx
Normal file
175
frontend/src/components/Layout.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Layout as AntLayout,
|
||||
Menu,
|
||||
Button,
|
||||
Avatar,
|
||||
Dropdown,
|
||||
Typography,
|
||||
Space,
|
||||
MenuProps,
|
||||
} from 'antd';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
SafetyOutlined,
|
||||
AppstoreOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
DashboardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
|
||||
const { Header, Sider, Content } = AntLayout;
|
||||
const { Title } = Typography;
|
||||
|
||||
const Layout: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: React.createElement(DashboardOutlined),
|
||||
label: '仪表盘',
|
||||
},
|
||||
{
|
||||
key: '/users',
|
||||
icon: React.createElement(UserOutlined),
|
||||
label: '用户管理',
|
||||
},
|
||||
{
|
||||
key: '/roles',
|
||||
icon: React.createElement(TeamOutlined),
|
||||
label: '角色管理',
|
||||
},
|
||||
{
|
||||
key: '/permissions',
|
||||
icon: React.createElement(SafetyOutlined),
|
||||
label: '权限管理',
|
||||
},
|
||||
{
|
||||
key: '/menus',
|
||||
icon: React.createElement(AppstoreOutlined),
|
||||
label: '菜单管理',
|
||||
},
|
||||
];
|
||||
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: React.createElement(SettingOutlined),
|
||||
label: '个人设置',
|
||||
onClick: () => navigate('/profile'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: React.createElement(LogoutOutlined),
|
||||
label: '退出登录',
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AntLayout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
style={{
|
||||
background: '#fff',
|
||||
boxShadow: '2px 0 8px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
{!collapsed ? (
|
||||
<Title level={3} style={{ margin: 0, color: '#1890ff' }}>
|
||||
UAdmin
|
||||
</Title>
|
||||
) : (
|
||||
<Title level={3} style={{ margin: 0, color: '#1890ff' }}>
|
||||
U
|
||||
</Title>
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ border: 'none' }}
|
||||
/>
|
||||
</Sider>
|
||||
<AntLayout>
|
||||
<Header
|
||||
style={{
|
||||
padding: '0 16px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? React.createElement(MenuUnfoldOutlined) : React.createElement(MenuFoldOutlined)}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
width: 64,
|
||||
height: 64,
|
||||
}}
|
||||
/>
|
||||
<Space>
|
||||
<span>欢迎,{user?.username}</span>
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<Avatar
|
||||
style={{ backgroundColor: '#1890ff', cursor: 'pointer' }}
|
||||
icon={React.createElement(UserOutlined)}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content
|
||||
style={{
|
||||
margin: '16px',
|
||||
padding: '24px',
|
||||
background: '#fff',
|
||||
borderRadius: '8px',
|
||||
minHeight: 'calc(100vh - 112px)',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</AntLayout>
|
||||
</AntLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
113
frontend/src/hooks/useAuth.ts
Normal file
113
frontend/src/hooks/useAuth.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import React, { useState, useEffect, createContext, useContext, ReactNode } from 'react';
|
||||
import { User, LoginRequest, RegisterRequest } from '../types';
|
||||
import { apiService } from '../services/api';
|
||||
import { message } from 'antd';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (data: LoginRequest) => Promise<boolean>;
|
||||
register: (data: RegisterRequest) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 从localStorage恢复认证状态
|
||||
const savedToken = localStorage.getItem('token');
|
||||
const savedUser = localStorage.getItem('user');
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
try {
|
||||
setToken(savedToken);
|
||||
setUser(JSON.parse(savedUser));
|
||||
} catch (error) {
|
||||
console.error('Failed to parse saved user data:', error);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (data: LoginRequest): Promise<boolean> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiService.login(data);
|
||||
|
||||
setToken(response.token);
|
||||
setUser(response.user);
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem('token', response.token);
|
||||
localStorage.setItem('user', JSON.stringify(response.user));
|
||||
|
||||
message.success('登录成功');
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('Login failed:', error);
|
||||
message.error(error.response?.data?.message || '登录失败');
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (data: RegisterRequest): Promise<boolean> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiService.register(data);
|
||||
message.success('注册成功,请登录');
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('Register failed:', error);
|
||||
message.error(error.response?.data?.message || '注册失败');
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await apiService.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
message.success('已退出登录');
|
||||
}
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
isAuthenticated: !!token && !!user,
|
||||
};
|
||||
|
||||
return React.createElement(AuthContext.Provider, { value }, children);
|
||||
};
|
||||
13
frontend/src/index.css
Normal file
13
frontend/src/index.css
Normal file
@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
19
frontend/src/index.tsx
Normal file
19
frontend/src/index.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
1
frontend/src/logo.svg
Normal file
1
frontend/src/logo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
342
frontend/src/pages/Dashboard.tsx
Normal file
342
frontend/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,342 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Space,
|
||||
Avatar,
|
||||
List,
|
||||
Typography,
|
||||
Progress,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
SafetyOutlined,
|
||||
MenuOutlined,
|
||||
TrophyOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface DashboardStats {
|
||||
totalUsers: number;
|
||||
totalRoles: number;
|
||||
totalPermissions: number;
|
||||
totalMenus: number;
|
||||
}
|
||||
|
||||
interface RecentActivity {
|
||||
id: number;
|
||||
action: string;
|
||||
target: string;
|
||||
user: string;
|
||||
timestamp: string;
|
||||
type: 'create' | 'update' | 'delete';
|
||||
}
|
||||
|
||||
interface SystemInfo {
|
||||
cpuUsage: number;
|
||||
memoryUsage: number;
|
||||
diskUsage: number;
|
||||
uptime: string;
|
||||
}
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalUsers: 0,
|
||||
totalRoles: 0,
|
||||
totalPermissions: 0,
|
||||
totalMenus: 0,
|
||||
});
|
||||
const [recentActivities, setRecentActivities] = useState<RecentActivity[]>([]);
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo>({
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
diskUsage: 0,
|
||||
uptime: '0天',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 模拟数据加载
|
||||
useEffect(() => {
|
||||
const loadDashboardData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 模拟统计数据
|
||||
setStats({
|
||||
totalUsers: 156,
|
||||
totalRoles: 8,
|
||||
totalPermissions: 24,
|
||||
totalMenus: 12,
|
||||
});
|
||||
|
||||
// 模拟最近活动
|
||||
setRecentActivities([
|
||||
{
|
||||
id: 1,
|
||||
action: '创建用户',
|
||||
target: 'john_doe',
|
||||
user: 'admin',
|
||||
timestamp: '2024-01-15 10:30:00',
|
||||
type: 'create',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
action: '更新角色',
|
||||
target: '管理员',
|
||||
user: 'admin',
|
||||
timestamp: '2024-01-15 09:45:00',
|
||||
type: 'update',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
action: '删除权限',
|
||||
target: 'users:delete',
|
||||
user: 'admin',
|
||||
timestamp: '2024-01-15 09:15:00',
|
||||
type: 'delete',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
action: '创建菜单',
|
||||
target: '系统设置',
|
||||
user: 'admin',
|
||||
timestamp: '2024-01-15 08:30:00',
|
||||
type: 'create',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
action: '更新用户',
|
||||
target: 'jane_smith',
|
||||
user: 'admin',
|
||||
timestamp: '2024-01-14 16:20:00',
|
||||
type: 'update',
|
||||
},
|
||||
]);
|
||||
|
||||
// 模拟系统信息
|
||||
setSystemInfo({
|
||||
cpuUsage: Math.floor(Math.random() * 100),
|
||||
memoryUsage: Math.floor(Math.random() * 100),
|
||||
diskUsage: Math.floor(Math.random() * 100),
|
||||
uptime: '15天 8小时 32分钟',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('加载仪表板数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
const getActivityTypeColor = (type: RecentActivity['type']) => {
|
||||
switch (type) {
|
||||
case 'create':
|
||||
return 'green';
|
||||
case 'update':
|
||||
return 'blue';
|
||||
case 'delete':
|
||||
return 'red';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getActivityTypeIcon = (type: RecentActivity['type']) => {
|
||||
switch (type) {
|
||||
case 'create':
|
||||
return React.createElement(TrophyOutlined);
|
||||
case 'update':
|
||||
return React.createElement(ClockCircleOutlined);
|
||||
case 'delete':
|
||||
return React.createElement(ClockCircleOutlined);
|
||||
default:
|
||||
return React.createElement(ClockCircleOutlined);
|
||||
}
|
||||
};
|
||||
|
||||
const activityColumns: ColumnsType<RecentActivity> = [
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
render: (action: string, record: RecentActivity) => (
|
||||
<Space>
|
||||
<Tag color={getActivityTypeColor(record.type)}>
|
||||
{action}
|
||||
</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '目标',
|
||||
dataIndex: 'target',
|
||||
key: 'target',
|
||||
render: (target: string) => (
|
||||
<Text code>{target}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'user',
|
||||
key: 'user',
|
||||
render: (user: string) => (
|
||||
<Space>
|
||||
<Avatar size="small" icon={React.createElement(UserOutlined)} />
|
||||
{user}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 欢迎信息 */}
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<Row align="middle">
|
||||
<Col>
|
||||
<Avatar size={64} icon={React.createElement(UserOutlined)} />
|
||||
</Col>
|
||||
<Col style={{ marginLeft: 16 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
欢迎回来,{user?.username}!
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
今天是 {new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long'
|
||||
})}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="用户总数"
|
||||
value={stats.totalUsers}
|
||||
prefix={React.createElement(UserOutlined)}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="角色总数"
|
||||
value={stats.totalRoles}
|
||||
prefix={React.createElement(TeamOutlined)}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="权限总数"
|
||||
value={stats.totalPermissions}
|
||||
prefix={React.createElement(SafetyOutlined)}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="菜单总数"
|
||||
value={stats.totalMenus}
|
||||
prefix={React.createElement(MenuOutlined)}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
{/* 最近活动 */}
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="最近活动" style={{ height: 500 }}>
|
||||
<Table
|
||||
columns={activityColumns}
|
||||
dataSource={recentActivities}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 系统信息 */}
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="系统信息" style={{ height: 500 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div>
|
||||
<Text strong>CPU 使用率</Text>
|
||||
<Progress
|
||||
percent={systemInfo.cpuUsage}
|
||||
status={systemInfo.cpuUsage > 80 ? 'exception' : 'active'}
|
||||
strokeColor={systemInfo.cpuUsage > 80 ? '#ff4d4f' : '#52c41a'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>内存使用率</Text>
|
||||
<Progress
|
||||
percent={systemInfo.memoryUsage}
|
||||
status={systemInfo.memoryUsage > 80 ? 'exception' : 'active'}
|
||||
strokeColor={systemInfo.memoryUsage > 80 ? '#ff4d4f' : '#1890ff'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>磁盘使用率</Text>
|
||||
<Progress
|
||||
percent={systemInfo.diskUsage}
|
||||
status={systemInfo.diskUsage > 80 ? 'exception' : 'active'}
|
||||
strokeColor={systemInfo.diskUsage > 80 ? '#ff4d4f' : '#faad14'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>系统运行时间</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text code>{systemInfo.uptime}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
107
frontend/src/pages/Login.tsx
Normal file
107
frontend/src/pages/Login.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, Card, Typography, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { LoginRequest } from '../types';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onFinish = async (values: LoginRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const success = await login(values);
|
||||
if (success) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
}}>
|
||||
<Card
|
||||
style={{
|
||||
width: 400,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Title level={2} style={{ color: '#1890ff', marginBottom: 8 }}>
|
||||
UAdmin
|
||||
</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: '请输入用户名!' },
|
||||
{ min: 3, message: '用户名至少3个字符!' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码!' },
|
||||
{ min: 6, message: '密码至少6个字符!' }
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Typography.Text type="secondary">
|
||||
还没有账号? <Link to="/register">立即注册</Link>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
421
frontend/src/pages/Menus.tsx
Normal file
421
frontend/src/pages/Menus.tsx
Normal file
@ -0,0 +1,421 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
InputNumber,
|
||||
TreeSelect,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Menu, CreateMenuRequest, UpdateMenuRequest, QueryParams } from '../types';
|
||||
import { apiService } from '../services/api';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface TreeNode {
|
||||
title: string;
|
||||
value: number;
|
||||
children?: TreeNode[];
|
||||
}
|
||||
|
||||
const Menus: React.FC = () => {
|
||||
const [menus, setMenus] = useState<Menu[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingMenu, setEditingMenu] = useState<Menu | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [searchForm] = Form.useForm();
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const fetchMenus = async (params?: QueryParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiService.getMenus({
|
||||
page: pagination.current,
|
||||
per_page: pagination.pageSize,
|
||||
...params,
|
||||
});
|
||||
setMenus(response.data || []);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: response.total,
|
||||
current: response.page,
|
||||
}));
|
||||
} catch (error) {
|
||||
message.error('获取菜单列表失败');
|
||||
setMenus([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMenus();
|
||||
}, [pagination.current, pagination.pageSize]);
|
||||
|
||||
const handleSearch = (values: any) => {
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
fetchMenus(values);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.resetFields();
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
fetchMenus();
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingMenu(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (menu: Menu) => {
|
||||
setEditingMenu(menu);
|
||||
form.setFieldsValue({
|
||||
name: menu.name,
|
||||
path: menu.path,
|
||||
icon: menu.icon,
|
||||
parent_id: menu.parent_id,
|
||||
sort_order: menu.sort_order,
|
||||
status: menu.status,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await apiService.deleteMenu(id);
|
||||
message.success('删除成功');
|
||||
fetchMenus();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: CreateMenuRequest | UpdateMenuRequest) => {
|
||||
try {
|
||||
if (editingMenu) {
|
||||
await apiService.updateMenu(editingMenu.id, values as UpdateMenuRequest);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await apiService.createMenu(values as CreateMenuRequest);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchMenus();
|
||||
} catch (error) {
|
||||
message.error(editingMenu ? '更新失败' : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 构建树形结构数据
|
||||
const buildTreeData = (menus: Menu[]): TreeNode[] => {
|
||||
if (!menus || !Array.isArray(menus)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const menuMap = new Map<number, Menu>();
|
||||
menus.forEach(menu => menuMap.set(menu.id, menu));
|
||||
|
||||
const buildTree = (parentId: number | null = null): TreeNode[] => {
|
||||
return menus
|
||||
.filter(menu => menu.parent_id === parentId)
|
||||
.map(menu => ({
|
||||
title: menu.name,
|
||||
value: menu.id,
|
||||
children: buildTree(menu.id),
|
||||
}));
|
||||
};
|
||||
|
||||
return buildTree();
|
||||
};
|
||||
|
||||
const treeData = buildTreeData(menus);
|
||||
|
||||
const columns: ColumnsType<Menu> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '菜单名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '路径',
|
||||
dataIndex: 'path',
|
||||
key: 'path',
|
||||
render: (path: string) => (
|
||||
<code style={{ background: '#f5f5f5', padding: '2px 4px', borderRadius: '3px' }}>
|
||||
{path}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '图标',
|
||||
dataIndex: 'icon',
|
||||
key: 'icon',
|
||||
render: (icon: string) => icon ? (
|
||||
<Tag color="blue">{icon}</Tag>
|
||||
) : '-',
|
||||
},
|
||||
{
|
||||
title: '父级菜单',
|
||||
dataIndex: 'parent_id',
|
||||
key: 'parent_id',
|
||||
render: (parentId: number | null) => {
|
||||
if (!parentId) return '-';
|
||||
const parentMenu = menus.find(m => m.id === parentId);
|
||||
return parentMenu ? parentMenu.name : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort_order',
|
||||
key: 'sort_order',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'active' ? 'green' : 'red'}>
|
||||
{status === 'active' ? '激活' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (date: string) => new Date(date).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="link"
|
||||
icon={React.createElement(EditOutlined)}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个菜单吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={React.createElement(DeleteOutlined)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Form
|
||||
form={searchForm}
|
||||
layout="inline"
|
||||
onFinish={handleSearch}
|
||||
>
|
||||
<Row gutter={16} style={{ width: '100%' }}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="search" label="搜索">
|
||||
<Input placeholder="菜单名称或路径" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select placeholder="选择状态" allowClear>
|
||||
<Option value="active">激活</Option>
|
||||
<Option value="inactive">禁用</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={React.createElement(SearchOutlined)}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={React.createElement(PlusOutlined)}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
新增菜单
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={menus}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) =>
|
||||
`第 ${range[0]}-${range[1]} 条/共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize: pageSize || 10,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingMenu ? '编辑菜单' : '新增菜单'}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="菜单名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入菜单名称' },
|
||||
{ min: 2, message: '菜单名称至少2个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入菜单名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="path"
|
||||
label="菜单路径"
|
||||
rules={[
|
||||
{ required: true, message: '请输入菜单路径' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入菜单路径,如:/users, /roles" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="icon"
|
||||
label="图标"
|
||||
>
|
||||
<Input placeholder="请输入图标名称,如:UserOutlined" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="parent_id"
|
||||
label="父级菜单"
|
||||
>
|
||||
<TreeSelect
|
||||
placeholder="请选择父级菜单(可选)"
|
||||
allowClear
|
||||
treeData={treeData}
|
||||
treeDefaultExpandAll
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="sort_order"
|
||||
label="排序"
|
||||
rules={[
|
||||
{ required: true, message: '请输入排序值' },
|
||||
]}
|
||||
initialValue={0}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={9999}
|
||||
placeholder="请输入排序值"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{editingMenu && (
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
>
|
||||
<Select placeholder="请选择状态">
|
||||
<Option value="active">激活</Option>
|
||||
<Option value="inactive">禁用</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item style={{ textAlign: 'right', marginBottom: 0 }}>
|
||||
<Space>
|
||||
<Button onClick={() => setModalVisible(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{editingMenu ? '更新' : '创建'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menus;
|
||||
311
frontend/src/pages/Permissions.tsx
Normal file
311
frontend/src/pages/Permissions.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Permission, CreatePermissionRequest, UpdatePermissionRequest, QueryParams } from '../types';
|
||||
import { apiService } from '../services/api';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const Permissions: React.FC = () => {
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingPermission, setEditingPermission] = useState<Permission | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [searchForm] = Form.useForm();
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const fetchPermissions = async (params?: QueryParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiService.getPermissions({
|
||||
page: pagination.current,
|
||||
per_page: pagination.pageSize,
|
||||
...params,
|
||||
});
|
||||
setPermissions(response.data || []);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: response.total,
|
||||
current: response.page,
|
||||
}));
|
||||
} catch (error) {
|
||||
message.error('获取权限列表失败');
|
||||
setPermissions([]); } finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPermissions();
|
||||
}, [pagination.current, pagination.pageSize]);
|
||||
|
||||
const handleSearch = (values: any) => {
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
fetchPermissions(values);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.resetFields();
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
fetchPermissions();
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingPermission(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (permission: Permission) => {
|
||||
setEditingPermission(permission);
|
||||
form.setFieldsValue({
|
||||
name: permission.name,
|
||||
key: permission.key,
|
||||
description: permission.description,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await apiService.deletePermission(id);
|
||||
message.success('删除成功');
|
||||
fetchPermissions();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: CreatePermissionRequest | UpdatePermissionRequest) => {
|
||||
try {
|
||||
if (editingPermission) {
|
||||
await apiService.updatePermission(editingPermission.id, values as UpdatePermissionRequest);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await apiService.createPermission(values as CreatePermissionRequest);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchPermissions();
|
||||
} catch (error) {
|
||||
message.error(editingPermission ? '更新失败' : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Permission> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '权限名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '权限标识',
|
||||
dataIndex: 'key',
|
||||
key: 'key',
|
||||
render: (key: string) => (
|
||||
<Tag color="blue">{key}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (date: string) => new Date(date).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action_buttons',
|
||||
render: (_, record) => (
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="link"
|
||||
icon={React.createElement(EditOutlined)}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个权限吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={React.createElement(DeleteOutlined)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Form
|
||||
form={searchForm}
|
||||
layout="inline"
|
||||
onFinish={handleSearch}
|
||||
>
|
||||
<Row gutter={16} style={{ width: '100%' }}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="search" label="搜索">
|
||||
<Input placeholder="权限名称" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="key" label="权限标识">
|
||||
<Input placeholder="权限标识" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={React.createElement(SearchOutlined)}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={React.createElement(PlusOutlined)}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
新增权限
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={permissions}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) =>
|
||||
`第 ${range[0]}-${range[1]} 条/共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize: pageSize || 10,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingPermission ? '编辑权限' : '新增权限'}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="权限名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入权限名称' },
|
||||
{ min: 2, message: '权限名称至少2个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入权限名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="key"
|
||||
label="权限标识"
|
||||
rules={[
|
||||
{ required: true, message: '请输入权限标识' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入权限标识,如:users:create, roles:read" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<TextArea rows={4} placeholder="请输入权限描述" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ textAlign: 'right', marginBottom: 0 }}>
|
||||
<Space>
|
||||
<Button onClick={() => setModalVisible(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{editingPermission ? '更新' : '创建'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Permissions;
|
||||
143
frontend/src/pages/Register.tsx
Normal file
143
frontend/src/pages/Register.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, Card, Typography, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { RegisterRequest } from '../types';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const Register: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onFinish = async (values: RegisterRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const success = await register(values);
|
||||
if (success) {
|
||||
navigate('/login');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
}}>
|
||||
<Card
|
||||
style={{
|
||||
width: 400,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Title level={2} style={{ color: '#1890ff', marginBottom: 8 }}>
|
||||
UAdmin
|
||||
</Title>
|
||||
<Typography.Text type="secondary">
|
||||
用户权限管理系统 - 注册
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
name="register"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名!' },
|
||||
{ min: 3, message: '用户名至少3个字符!' },
|
||||
{ max: 20, message: '用户名最多20个字符!' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱!' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址!' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="邮箱"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码!' },
|
||||
{ min: 6, message: '密码至少6个字符!' },
|
||||
{ max: 20, message: '密码最多20个字符!' }
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认密码!' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致!'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="确认密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Typography.Text type="secondary">
|
||||
已有账号? <Link to="/login">立即登录</Link>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
413
frontend/src/pages/Roles.tsx
Normal file
413
frontend/src/pages/Roles.tsx
Normal file
@ -0,0 +1,413 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Transfer,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Role, Permission, CreateRoleRequest, UpdateRoleRequest, QueryParams } from '../types';
|
||||
import { apiService } from '../services/api';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { TransferDirection } from 'antd/es/transfer';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface TransferItem {
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const Roles: React.FC = () => {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [permissionModalVisible, setPermissionModalVisible] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [searchForm] = Form.useForm();
|
||||
const [targetKeys, setTargetKeys] = useState<string[]>([]);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const fetchRoles = async (params?: QueryParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiService.getRoles({
|
||||
page: pagination.current,
|
||||
per_page: pagination.pageSize,
|
||||
...params,
|
||||
});
|
||||
setRoles(response.data || []);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: response.total,
|
||||
current: response.page,
|
||||
}));
|
||||
} catch (error) {
|
||||
message.error('获取角色列表失败');
|
||||
setRoles([]); } finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
const response = await apiService.getPermissions({ page: 1, per_page: 1000 });
|
||||
setPermissions(response.data || []);
|
||||
} catch (error) {
|
||||
message.error('获取权限列表失败');
|
||||
setPermissions([]); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
fetchPermissions();
|
||||
}, [pagination.current, pagination.pageSize]);
|
||||
|
||||
const handleSearch = (values: any) => {
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
fetchRoles(values);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.resetFields();
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
fetchRoles();
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingRole(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (role: Role) => {
|
||||
setEditingRole(role);
|
||||
form.setFieldsValue({
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
status: role.status,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await apiService.deleteRole(id);
|
||||
message.success('删除成功');
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: CreateRoleRequest | UpdateRoleRequest) => {
|
||||
try {
|
||||
if (editingRole) {
|
||||
await apiService.updateRole(editingRole.id, values as UpdateRoleRequest);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await apiService.createRole(values as CreateRoleRequest);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
message.error(editingRole ? '更新失败' : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleManagePermissions = (role: Role) => {
|
||||
setSelectedRole(role);
|
||||
const rolePermissionIds: string[] = [];
|
||||
setTargetKeys(rolePermissionIds);
|
||||
setPermissionModalVisible(true);
|
||||
};
|
||||
|
||||
const handlePermissionChange = (newTargetKeys: React.Key[], direction: TransferDirection, moveKeys: React.Key[]) => {
|
||||
setTargetKeys(newTargetKeys.map(key => key.toString()));
|
||||
};
|
||||
|
||||
const handleSavePermissions = async () => {
|
||||
if (!selectedRole) return;
|
||||
|
||||
try {
|
||||
const permissionIds = targetKeys.map(key => parseInt(key));
|
||||
// TODO: Implement assignRolePermissions API method
|
||||
// await apiService.assignRolePermissions(selectedRole.id, permissionIds);
|
||||
message.success('权限分配成功');
|
||||
setPermissionModalVisible(false);
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
message.error('权限分配失败');
|
||||
}
|
||||
};
|
||||
|
||||
const transferData: TransferItem[] = permissions.map(permission => ({
|
||||
key: permission.id.toString(),
|
||||
title: permission.name,
|
||||
description: permission.description,
|
||||
}));
|
||||
|
||||
const columns: ColumnsType<Role> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '角色名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '权限数量',
|
||||
key: 'permissions_count',
|
||||
render: (_, record) => (
|
||||
<Tag color="blue">
|
||||
0 个权限
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'active' ? 'green' : 'red'}>
|
||||
{status === 'active' ? '激活' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (date: string) => new Date(date).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => handleManagePermissions(record)}
|
||||
>
|
||||
权限管理
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
icon={React.createElement(EditOutlined)}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个角色吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={React.createElement(DeleteOutlined)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Form
|
||||
form={searchForm}
|
||||
layout="inline"
|
||||
onFinish={handleSearch}
|
||||
>
|
||||
<Row gutter={16} style={{ width: '100%' }}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="search" label="搜索">
|
||||
<Input placeholder="角色名称" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select placeholder="选择状态" allowClear>
|
||||
<Option value="active">激活</Option>
|
||||
<Option value="inactive">禁用</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={React.createElement(SearchOutlined)}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={React.createElement(PlusOutlined)}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
新增角色
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={roles}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) =>
|
||||
`第 ${range[0]}-${range[1]} 条/共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize: pageSize || 10,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 角色编辑模态框 */}
|
||||
<Modal
|
||||
title={editingRole ? '编辑角色' : '新增角色'}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="角色名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入角色名称' },
|
||||
{ min: 2, message: '角色名称至少2个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入角色名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<TextArea rows={4} placeholder="请输入角色描述" />
|
||||
</Form.Item>
|
||||
|
||||
{editingRole && (
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
>
|
||||
<Select placeholder="请选择状态">
|
||||
<Option value="active">激活</Option>
|
||||
<Option value="inactive">禁用</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item style={{ textAlign: 'right', marginBottom: 0 }}>
|
||||
<Space>
|
||||
<Button onClick={() => setModalVisible(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{editingRole ? '更新' : '创建'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 权限管理模态框 */}
|
||||
<Modal
|
||||
title={`管理角色权限 - ${selectedRole?.name}`}
|
||||
open={permissionModalVisible}
|
||||
onCancel={() => setPermissionModalVisible(false)}
|
||||
onOk={handleSavePermissions}
|
||||
width={800}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Transfer
|
||||
dataSource={transferData}
|
||||
titles={['可用权限', '已分配权限']}
|
||||
targetKeys={targetKeys}
|
||||
onChange={handlePermissionChange}
|
||||
render={item => item.title}
|
||||
showSearch
|
||||
listStyle={{
|
||||
width: 350,
|
||||
height: 400,
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Roles;
|
||||
347
frontend/src/pages/Users.tsx
Normal file
347
frontend/src/pages/Users.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { User, CreateUserRequest, UpdateUserRequest, QueryParams } from '../types';
|
||||
import { apiService } from '../services/api';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const Users: React.FC = () => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [searchForm] = Form.useForm();
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const fetchUsers = async (params?: QueryParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiService.getUsers({
|
||||
page: pagination.current,
|
||||
per_page: pagination.pageSize,
|
||||
...params,
|
||||
});
|
||||
setUsers(response.data || []);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: response.total,
|
||||
current: response.page,
|
||||
}));
|
||||
} catch (error) {
|
||||
message.error('获取用户列表失败');
|
||||
setUsers([]); } finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [pagination.current, pagination.pageSize]);
|
||||
|
||||
const handleSearch = (values: any) => {
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
fetchUsers(values);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.resetFields();
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
setEditingUser(user);
|
||||
form.setFieldsValue({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
status: user.status,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await apiService.deleteUser(id);
|
||||
message.success('删除成功');
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: CreateUserRequest | UpdateUserRequest) => {
|
||||
try {
|
||||
if (editingUser) {
|
||||
await apiService.updateUser(editingUser.id, values as UpdateUserRequest);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await apiService.createUser(values as CreateUserRequest);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
message.error(editingUser ? '更新失败' : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<User> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'active' ? 'green' : 'red'}>
|
||||
{status === 'active' ? '激活' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (date: string) => new Date(date).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="link"
|
||||
icon={React.createElement(EditOutlined)}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个用户吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={React.createElement(DeleteOutlined)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Form
|
||||
form={searchForm}
|
||||
layout="inline"
|
||||
onFinish={handleSearch}
|
||||
>
|
||||
<Row gutter={16} style={{ width: '100%' }}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="search" label="搜索">
|
||||
<Input placeholder="用户名或邮箱" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select placeholder="选择状态" allowClear>
|
||||
<Option value="active">激活</Option>
|
||||
<Option value="inactive">禁用</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={React.createElement(SearchOutlined)}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={React.createElement(PlusOutlined)}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
新增用户
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) =>
|
||||
`第 ${range[0]}-${range[1]} 条/共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize: pageSize || 10,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingUser ? '编辑用户' : '新增用户'}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '用户名至少3个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入用户名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
|
||||
{!editingUser && (
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
>
|
||||
<Input placeholder="请输入手机号" />
|
||||
</Form.Item>
|
||||
|
||||
{editingUser && (
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
>
|
||||
<Select placeholder="请选择状态">
|
||||
<Option value="active">激活</Option>
|
||||
<Option value="inactive">禁用</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item style={{ textAlign: 'right', marginBottom: 0 }}>
|
||||
<Space>
|
||||
<Button onClick={() => setModalVisible(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{editingUser ? '更新' : '创建'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
1
frontend/src/react-app-env.d.ts
vendored
Normal file
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
15
frontend/src/reportWebVitals.ts
Normal file
15
frontend/src/reportWebVitals.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
229
frontend/src/services/api.ts
Normal file
229
frontend/src/services/api.ts
Normal file
@ -0,0 +1,229 @@
|
||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import {
|
||||
User,
|
||||
Role,
|
||||
Permission,
|
||||
Menu,
|
||||
CreateUserRequest,
|
||||
UpdateUserRequest,
|
||||
CreateRoleRequest,
|
||||
UpdateRoleRequest,
|
||||
CreatePermissionRequest,
|
||||
UpdatePermissionRequest,
|
||||
CreateMenuRequest,
|
||||
UpdateMenuRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
QueryParams,
|
||||
} from '../types';
|
||||
|
||||
class ApiService {
|
||||
private api: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.api = axios.create({
|
||||
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:3000/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器 - 添加认证token
|
||||
this.api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器 - 处理错误
|
||||
this.api.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token过期或无效,清除本地存储并跳转到登录页
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 认证相关API
|
||||
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||
const response = await this.api.post<ApiResponse<LoginResponse>>('/auth/login', data);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async register(data: RegisterRequest): Promise<User> {
|
||||
const response = await this.api.post<ApiResponse<User>>('/auth/register', data);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await this.api.post('/auth/logout');
|
||||
}
|
||||
|
||||
// 用户管理API
|
||||
async getUsers(params?: QueryParams): Promise<PaginatedResponse<User>> {
|
||||
const response = await this.api.get('/users', { params });
|
||||
const backendData = response.data.data;
|
||||
// 转换后端数据结构到前端期望的结构
|
||||
return {
|
||||
data: backendData.items.map((user: any) => ({
|
||||
...user,
|
||||
status: user.status === 1 ? 'active' : 'inactive'
|
||||
})),
|
||||
total: backendData.total,
|
||||
page: backendData.page,
|
||||
per_page: backendData.page_size,
|
||||
total_pages: Math.ceil(backendData.total / backendData.page_size)
|
||||
};
|
||||
}
|
||||
|
||||
async getUser(id: number): Promise<User> {
|
||||
const response = await this.api.get<ApiResponse<User>>(`/users/${id}`);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async createUser(data: CreateUserRequest): Promise<User> {
|
||||
const response = await this.api.post<ApiResponse<User>>('/users', data);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async updateUser(id: number, data: UpdateUserRequest): Promise<User> {
|
||||
// 转换前端的字符串状态为后端期望的数字格式
|
||||
const backendData = {
|
||||
...data,
|
||||
status: data.status ? (data.status === 'active' ? 1 : 0) : undefined
|
||||
};
|
||||
const response = await this.api.put<ApiResponse<User>>(`/users/${id}`, backendData);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async deleteUser(id: number): Promise<void> {
|
||||
await this.api.delete(`/users/${id}`);
|
||||
}
|
||||
|
||||
// 角色管理API
|
||||
async getRoles(params?: QueryParams): Promise<PaginatedResponse<Role>> {
|
||||
const response = await this.api.get<ApiResponse<PaginatedResponse<Role>>>('/roles', { params });
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async getAllRoles(): Promise<Role[]> {
|
||||
const response = await this.api.get<ApiResponse<Role[]>>('/roles/all');
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async getRole(id: number): Promise<Role> {
|
||||
const response = await this.api.get<ApiResponse<Role>>(`/roles/${id}`);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async createRole(data: CreateRoleRequest): Promise<Role> {
|
||||
const response = await this.api.post<ApiResponse<Role>>('/roles', data);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async updateRole(id: number, data: UpdateRoleRequest): Promise<Role> {
|
||||
// 转换前端的字符串状态为后端期望的数字格式
|
||||
const backendData = {
|
||||
...data,
|
||||
status: data.status ? (data.status === 'active' ? 1 : 0) : undefined
|
||||
};
|
||||
const response = await this.api.put<ApiResponse<Role>>(`/roles/${id}`, backendData);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async deleteRole(id: number): Promise<void> {
|
||||
await this.api.delete(`/roles/${id}`);
|
||||
}
|
||||
|
||||
// 权限管理API
|
||||
async getPermissions(params?: QueryParams): Promise<PaginatedResponse<Permission>> {
|
||||
const response = await this.api.get<ApiResponse<PaginatedResponse<Permission>>>('/permissions', { params });
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async getAllPermissions(): Promise<Permission[]> {
|
||||
const response = await this.api.get<ApiResponse<Permission[]>>('/permissions/all');
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async getPermission(id: number): Promise<Permission> {
|
||||
const response = await this.api.get<ApiResponse<Permission>>(`/permissions/${id}`);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async createPermission(data: CreatePermissionRequest): Promise<Permission> {
|
||||
const response = await this.api.post<ApiResponse<Permission>>('/permissions', data);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async updatePermission(id: number, data: UpdatePermissionRequest): Promise<Permission> {
|
||||
// 转换前端的字符串状态为后端期望的数字格式
|
||||
const backendData = {
|
||||
...data,
|
||||
status: data.status ? (data.status === 'active' ? 1 : 0) : undefined
|
||||
};
|
||||
const response = await this.api.put<ApiResponse<Permission>>(`/permissions/${id}`, backendData);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async deletePermission(id: number): Promise<void> {
|
||||
await this.api.delete(`/permissions/${id}`);
|
||||
}
|
||||
|
||||
// 菜单管理API
|
||||
async getMenus(params?: QueryParams): Promise<PaginatedResponse<Menu>> {
|
||||
const response = await this.api.get<ApiResponse<PaginatedResponse<Menu>>>('/menus', { params });
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async getAllMenus(): Promise<Menu[]> {
|
||||
const response = await this.api.get<ApiResponse<Menu[]>>('/menus/all');
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async getMenu(id: number): Promise<Menu> {
|
||||
const response = await this.api.get<ApiResponse<Menu>>(`/menus/${id}`);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async createMenu(data: CreateMenuRequest): Promise<Menu> {
|
||||
const response = await this.api.post<ApiResponse<Menu>>('/menus', data);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async updateMenu(id: number, data: UpdateMenuRequest): Promise<Menu> {
|
||||
// 转换前端的字符串状态为后端期望的数字格式
|
||||
const backendData = {
|
||||
...data,
|
||||
status: data.status ? (data.status === 'active' ? 1 : 0) : undefined
|
||||
};
|
||||
const response = await this.api.put<ApiResponse<Menu>>(`/menus/${id}`, backendData);
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
async deleteMenu(id: number): Promise<void> {
|
||||
await this.api.delete(`/menus/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiService = new ApiService();
|
||||
export default apiService;
|
||||
5
frontend/src/setupTests.ts
Normal file
5
frontend/src/setupTests.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
143
frontend/src/types/index.ts
Normal file
143
frontend/src/types/index.ts
Normal file
@ -0,0 +1,143 @@
|
||||
// 用户相关类型
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
status: 'active' | 'inactive';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
username?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
// 角色相关类型
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'active' | 'inactive';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRoleRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
// 权限相关类型
|
||||
export interface Permission {
|
||||
id: number;
|
||||
name: string;
|
||||
key: string;
|
||||
description?: string;
|
||||
status: 'active' | 'inactive';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreatePermissionRequest {
|
||||
name: string;
|
||||
key: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePermissionRequest {
|
||||
name?: string;
|
||||
key?: string;
|
||||
description?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
// 菜单相关类型
|
||||
export interface Menu {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
icon?: string;
|
||||
parent_id?: number;
|
||||
sort_order: number;
|
||||
status: 'active' | 'inactive';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
children?: Menu[];
|
||||
}
|
||||
|
||||
export interface CreateMenuRequest {
|
||||
name: string;
|
||||
path: string;
|
||||
icon?: string;
|
||||
parent_id?: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface UpdateMenuRequest {
|
||||
name?: string;
|
||||
path?: string;
|
||||
icon?: string;
|
||||
parent_id?: number;
|
||||
sort_order?: number;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
// 认证相关类型
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// 表格查询参数
|
||||
export interface QueryParams {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user