feat: 实现用户认证与权限管理系统

- 添加用户、角色、权限等数据模型
- 实现JWT认证中间件和密码加密
- 添加用户注册、登录、个人信息管理接口
- 实现前端认证上下文和受保护路由
- 添加登录历史记录和操作日志功能
- 提供管理员初始化脚本和修复工具
- 实现完整的登录页面和用户管理界面
This commit is contained in:
zhang1106
2025-12-25 10:48:35 +08:00
parent 568f9c7db8
commit a911d02999
29 changed files with 4309 additions and 154 deletions
+372 -140
View File
@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import { Layout, Menu, theme, Button } from 'antd';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined } from '@ant-design/icons';
import { Layout, Menu, theme, Button, Dropdown, Avatar, message, Space, Divider } from 'antd';
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, UserOutlined as UserIcon, HistoryOutlined, AuditOutlined } from '@ant-design/icons';
import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
import Dashboard from './pages/Dashboard';
import DeviceManagement from './pages/DeviceManagement';
import RackManagement from './pages/RackManagement';
@@ -12,155 +13,386 @@ import ConsumableManagement from './pages/ConsumableManagement';
import ConsumableStatistics from './pages/ConsumableStatistics';
import ConsumableLogs from './pages/ConsumableLogs';
import CategoryManagement from './pages/CategoryManagement';
import UserManagement from './pages/UserManagement';
import LoginHistory from './pages/LoginHistory';
import OperationLogs from './pages/OperationLogs';
import Login from './pages/Login';
import { Spin } from 'antd';
const { Content, Sider } = Layout;
const { Header, Content, Sider } = Layout;
function App() {
const PrivateRoute = ({ children }) => {
const { token, initialized, loading } = useAuth();
const location = useLocation();
if (!initialized) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: '#f5f5f5'
}}>
<Spin size="large" tip="正在加载认证状态..." />
</div>
);
}
if (!token) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
const AppLayout = ({ children }) => {
const [collapsed, setCollapsed] = useState(false);
const { user, logout } = useAuth();
const navigate = useNavigate();
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
const handleLogout = () => {
logout();
message.success('已退出登录');
navigate('/login');
};
const userMenuItems = [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout
}
];
return (
<Router>
<Layout>
<Sider
width={220}
collapsedWidth={80}
collapsed={collapsed}
<Layout>
<Sider
width={220}
collapsedWidth={80}
collapsed={collapsed}
style={{
backgroundColor: colorBgContainer,
boxShadow: '2px 0 8px rgba(0,0,0,0.08)',
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
height: 64,
padding: '0 12px',
borderBottom: '1px solid #f0f0f0',
marginBottom: 8
}}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: 18,
padding: '8px',
borderRadius: 4
}}
/>
</div>
<Menu
mode="inline"
defaultSelectedKeys={['dashboard']}
style={{
backgroundColor: colorBgContainer,
boxShadow: '2px 0 8px rgba(0,0,0,0.08)'
flex: 1,
borderRight: 0,
backgroundColor: 'transparent'
}}
items={[
{
key: 'dashboard',
icon: <BarChartOutlined />,
label: <Link to="/">仪表盘</Link>,
},
{
key: 'room-management',
icon: <HomeOutlined />,
label: '机房管理',
children: [
{
key: 'rooms',
icon: <HomeOutlined />,
label: <Link to="/rooms">机房管理</Link>,
},
{
key: 'racks',
icon: <DatabaseOutlined />,
label: <Link to="/racks">机柜管理</Link>,
},
{
key: 'visualization',
icon: <EyeOutlined />,
label: <Link to="/visualization">机柜可视化</Link>,
},
],
},
{
key: 'asset-management',
icon: <BuildOutlined />,
label: '资产管理',
children: [
{
key: 'devices',
icon: <CloudServerOutlined />,
label: <Link to="/devices">设备管理</Link>,
},
{
key: 'fields',
icon: <DatabaseOutlined />,
label: <Link to="/fields">字段管理</Link>,
},
],
},
{
key: 'consumables-management',
icon: <ShoppingCartOutlined />,
label: '耗材管理',
children: [
{
key: 'consumables-stats',
icon: <BarChartOutlined />,
label: <Link to="/consumables-stats">耗材统计</Link>,
},
{
key: 'consumables',
icon: <DatabaseOutlined />,
label: <Link to="/consumables">耗材列表</Link>,
},
{
key: 'consumables-categories',
icon: <ImportOutlined />,
label: <Link to="/consumables-categories">分类管理</Link>,
},
{
key: 'consumables-logs',
icon: <FileTextOutlined />,
label: <Link to="/consumables-logs">操作日志</Link>,
},
],
},
{
key: 'system-management',
icon: <UserOutlined />,
label: '系统管理',
children: [
{
key: 'users',
icon: <UserOutlined />,
label: <Link to="/users">用户管理</Link>,
},
{
key: 'login-history',
icon: <HistoryOutlined />,
label: <Link to="/login-history">登录历史</Link>,
},
{
key: 'operation-logs',
icon: <AuditOutlined />,
label: <Link to="/operation-logs">操作日志</Link>,
},
],
},
]}
/>
</Sider>
<Layout style={{ padding: '0 24px 24px' }}>
<Header style={{
padding: '0 16px',
height: 56,
background: colorBgContainer,
marginBottom: 24,
borderRadius: borderRadiusLG,
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)'
}}>
{user && (
<Space size={12}>
<Avatar
style={{ backgroundColor: '#1890ff', cursor: 'pointer' }}
icon={<UserOutlined />}
/>
<span style={{ color: '#666', fontSize: 14 }}>{user.username}</span>
<Divider type="vertical" style={{ margin: 0 }} />
<Button
type="text"
danger
icon={<LogoutOutlined />}
onClick={handleLogout}
style={{ padding: '4px 8px' }}
>
退出
</Button>
</Space>
)}
</Header>
<Content
style={{
padding: 24,
margin: 0,
minHeight: 280,
background: colorBgContainer,
borderRadius: borderRadiusLG,
}}
>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: 64,
borderBottom: '1px solid #f0f0f0',
marginBottom: 16
}}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: 18,
padding: '8px',
borderRadius: 4,
transition: 'all 0.3s'
}}
/>
</div>
<Menu
mode="inline"
defaultSelectedKeys={['dashboard']}
style={{
height: 'calc(100% - 80px)',
borderRight: 0,
backgroundColor: 'transparent'
}}
items={[
{
key: 'dashboard',
icon: <BarChartOutlined />,
label: <Link to="/">仪表盘</Link>,
},
{
key: 'room-management',
icon: <HomeOutlined />,
label: '机房管理',
children: [
{
key: 'rooms',
icon: <HomeOutlined />,
label: <Link to="/rooms">机房管理</Link>,
},
{
key: 'racks',
icon: <DatabaseOutlined />,
label: <Link to="/racks">机柜管理</Link>,
},
{
key: 'visualization',
icon: <EyeOutlined />,
label: <Link to="/visualization">机柜可视化</Link>,
},
],
},
{
key: 'asset-management',
icon: <BuildOutlined />,
label: '资产管理',
children: [
{
key: 'devices',
icon: <CloudServerOutlined />,
label: <Link to="/devices">设备管理</Link>,
},
{
key: 'fields',
icon: <DatabaseOutlined />,
label: <Link to="/fields">字段管理</Link>,
},
],
},
{
key: 'consumables-management',
icon: <ShoppingCartOutlined />,
label: '耗材管理',
children: [
{
key: 'consumables-stats',
icon: <BarChartOutlined />,
label: <Link to="/consumables-stats">耗材统计</Link>,
},
{
key: 'consumables',
icon: <DatabaseOutlined />,
label: <Link to="/consumables">耗材列表</Link>,
},
{
key: 'consumables-categories',
icon: <ImportOutlined />,
label: <Link to="/consumables-categories">分类管理</Link>,
},
{
key: 'consumables-logs',
icon: <FileTextOutlined />,
label: <Link to="/consumables-logs">操作日志</Link>,
},
],
},
]}
/>
</Sider>
<Layout style={{ padding: '0 24px 24px' }}>
<Content
style={{
padding: 24,
margin: 0,
minHeight: 280,
background: colorBgContainer,
borderRadius: borderRadiusLG,
}}
>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/devices" element={<DeviceManagement />} />
<Route path="/racks" element={<RackManagement />} />
<Route path="/rooms" element={<RoomManagement />} />
<Route path="/fields" element={<DeviceFieldManagement />} />
<Route path="/visualization" element={<RackVisualization />} />
<Route path="/consumables" element={<ConsumableManagement />} />
<Route path="/consumables-categories" element={<CategoryManagement />} />
<Route path="/consumables-stats" element={<ConsumableStatistics />} />
<Route path="/consumables-logs" element={<ConsumableLogs />} />
</Routes>
</Content>
</Layout>
{children}
</Content>
</Layout>
</Layout>
);
};
function App() {
return (
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<PrivateRoute>
<AppLayout>
<Dashboard />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/devices"
element={
<PrivateRoute>
<AppLayout>
<DeviceManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/racks"
element={
<PrivateRoute>
<AppLayout>
<RackManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/rooms"
element={
<PrivateRoute>
<AppLayout>
<RoomManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/fields"
element={
<PrivateRoute>
<AppLayout>
<DeviceFieldManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/visualization"
element={
<PrivateRoute>
<AppLayout>
<RackVisualization />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/consumables"
element={
<PrivateRoute>
<AppLayout>
<ConsumableManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/consumables-categories"
element={
<PrivateRoute>
<AppLayout>
<CategoryManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/consumables-stats"
element={
<PrivateRoute>
<AppLayout>
<ConsumableStatistics />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/consumables-logs"
element={
<PrivateRoute>
<AppLayout>
<ConsumableLogs />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/users"
element={
<PrivateRoute>
<AppLayout>
<UserManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/login-history"
element={
<PrivateRoute>
<AppLayout>
<LoginHistory />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/operation-logs"
element={
<PrivateRoute>
<AppLayout>
<OperationLogs />
</AppLayout>
</PrivateRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Router>
);
}
+114
View File
@@ -0,0 +1,114 @@
import axios from 'axios';
const API_BASE_URL = '/api';
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
});
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
} else {
console.log('[API] No token found in localStorage');
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
api.interceptors.response.use(
(response) => {
return response.data;
},
(error) => {
if (error.response) {
const { status, data } = error.response;
if (status === 401) {
const currentPath = window.location.pathname;
console.log('[API] 401 error, current path:', currentPath);
if (!currentPath.startsWith('/login')) {
const savedToken = localStorage.getItem('token');
if (savedToken) {
console.log('[API] Token exists but got 401, might be expired');
}
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
}
return Promise.reject(data.message || '请求失败');
}
if (error.code === 'ECONNABORTED') {
return Promise.reject('请求超时,请稍后重试');
}
return Promise.reject('网络错误,请检查网络连接');
}
);
export const authAPI = {
checkAdmin: () => api.get('/auth/check-admin'),
register: (data) => api.post('/auth/register', data),
login: (data) => api.post('/auth/login', data),
getProfile: () => api.get('/auth/profile'),
updateProfile: (data) => api.put('/auth/profile', data),
changePassword: (data) => api.put('/auth/password', data)
};
export const userAPI = {
list: (params) => api.get('/users', { params }),
all: () => api.get('/users/all'),
get: (userId) => api.get(`/users/${userId}`),
create: (data) => api.post('/users', data),
update: (userId, data) => api.put(`/users/${userId}`, data),
resetPassword: (userId, data) => api.put(`/users/${userId}/password`, data),
delete: (userId) => api.delete(`/users/${userId}`),
uploadAvatar: (userId, file) => {
const formData = new FormData();
formData.append('avatar', file);
return api.post(`/users/${userId}/avatar`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
},
deleteAvatar: (userId) => api.delete(`/users/${userId}/avatar`)
};
export const roleAPI = {
list: (params) => api.get('/roles', { params }),
all: () => api.get('/roles/all'),
get: (roleId) => api.get(`/roles/${roleId}`),
create: (data) => api.post('/roles', data),
update: (roleId, data) => api.put(`/roles/${roleId}`, data),
delete: (roleId) => api.delete(`/roles/${roleId}`),
initRoles: () => api.post('/roles/init-roles')
};
export const loginHistoryAPI = {
list: (params) => api.get('/login-history', { params }),
getByUser: (userId, params) => api.get(`/login-history/user/${userId}`, { params }),
delete: (id) => api.delete(`/login-history/${id}`),
clear: (data) => api.delete('/login-history', { data })
};
export const operationLogAPI = {
list: (params) => api.get('/operation-logs', { params }),
getActions: () => api.get('/operation-logs/actions'),
getModules: () => api.get('/operation-logs/modules'),
delete: (id) => api.delete(`/operation-logs/${id}`),
clear: (data) => api.delete('/operation-logs', { data })
};
export default api;
@@ -0,0 +1,34 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { Spin } from 'antd';
import { useAuth } from '../context/AuthContext';
const ProtectedRoute = ({ children, requiredPermission }) => {
const { user, token, loading, initialized } = useAuth();
const location = useLocation();
if (!initialized) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}>
<Spin size="large" tip="加载中..." />
</div>
);
}
if (!token) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requiredPermission && !user) {
return <Navigate to="/" replace />;
}
return children;
};
export default ProtectedRoute;
+155
View File
@@ -0,0 +1,155 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { authAPI } from '../api';
const AuthContext = createContext(null);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth必须在AuthProvider内部使用');
}
return context;
};
export const AuthProvider = ({ children }) => {
console.log('[AuthContext] Initializing...');
const savedToken = localStorage.getItem('token');
const savedUser = localStorage.getItem('user');
console.log('[AuthContext] Saved token:', savedToken ? 'exists' : 'null');
console.log('[AuthContext] Saved user:', savedUser ? 'exists' : 'null');
const [user, setUser] = useState(() => {
try {
if (savedUser) {
return JSON.parse(savedUser);
}
} catch (e) {
console.error('[AuthContext] Parse user error:', e);
}
return null;
});
const [token, setToken] = useState(() => savedToken);
const [loading, setLoading] = useState(false);
const [initialized, setInitialized] = useState(false);
useEffect(() => {
const currentToken = localStorage.getItem('token');
const currentUser = localStorage.getItem('user');
if (currentToken && currentToken === token) {
if (currentUser) {
try {
const parsedUser = JSON.parse(currentUser);
if (JSON.stringify(parsedUser) !== JSON.stringify(user)) {
setUser(parsedUser);
}
} catch (e) {
console.error('[AuthContext] Parse user error:', e);
}
}
setInitialized(true);
} else if (!currentToken) {
setToken(null);
setUser(null);
setInitialized(true);
} else {
setToken(currentToken);
setInitialized(true);
}
}, []);
const fetchProfile = useCallback(async () => {
const currentToken = localStorage.getItem('token');
if (!currentToken) {
setLoading(false);
return;
}
try {
const response = await authAPI.getProfile();
if (response.success) {
setUser(response.data.user);
}
} catch (error) {
console.error('获取用户信息失败:', error);
} finally {
setLoading(false);
}
}, []);
const login = async (username, password) => {
try {
const response = await authAPI.login({ username, password });
if (response.success) {
const { token: newToken, user: userData } = response.data;
localStorage.setItem('token', newToken);
localStorage.setItem('user', JSON.stringify(userData));
setToken(newToken);
setUser(userData);
return { success: true };
}
return { success: false, message: response.message };
} catch (error) {
return { success: false, message: error };
}
};
const register = async (userData) => {
try {
const response = await authAPI.register(userData);
if (response.success) {
const { token: newToken, user: newUser } = response.data;
localStorage.setItem('token', newToken);
localStorage.setItem('user', JSON.stringify(newUser));
setToken(newToken);
setUser(newUser);
return { success: true, isFirstUser: response.data.isFirstUser };
}
return { success: false, message: response.message };
} catch (error) {
return { success: false, message: error };
}
};
const logout = useCallback(() => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
}, []);
const updateUser = (newUserData) => {
const updatedUser = { ...user, ...newUserData };
setUser(updatedUser);
localStorage.setItem('user', JSON.stringify(updatedUser));
};
const hasPermission = (permission) => {
if (!user) return false;
return true;
};
const value = {
user,
token,
loading,
initialized,
login,
register,
logout,
updateUser,
hasPermission,
checkAdmin: () => authAPI.checkAdmin()
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
export default AuthContext;
+4 -1
View File
@@ -1,10 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { AuthProvider } from './context/AuthContext';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);
+303
View File
@@ -0,0 +1,303 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Button, Card, message, Typography, Tabs, Divider, Space, Modal, Alert } from 'antd';
import { UserOutlined, LockOutlined, MailOutlined, PhoneOutlined, SafetyCertificateOutlined, RobotOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const { Title, Text } = Typography;
const Login = () => {
const [loading, setLoading] = useState(false);
const [isFirstUser, setIsFirstUser] = useState(false);
const [registerMode, setRegisterMode] = useState(false);
const { login, register, checkAdmin } = useAuth();
const navigate = useNavigate();
useEffect(() => {
checkIsFirstUser();
}, []);
const checkIsFirstUser = async () => {
try {
const response = await checkAdmin();
if (response.success) {
setIsFirstUser(!response.data.hasAdmin);
if (!response.data.hasAdmin) {
setRegisterMode(true);
}
}
} catch (error) {
console.error('检查用户状态失败:', error);
}
};
const onFinishLogin = async (values) => {
setLoading(true);
try {
const result = await login(values.username, values.password);
if (result.success) {
message.success('登录成功');
navigate('/');
} else {
message.error(result.message || '登录失败');
}
} catch (error) {
message.error(error || '登录失败');
} finally {
setLoading(false);
}
};
const onFinishRegister = async (values) => {
if (values.password !== values.confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
setLoading(true);
try {
const result = await register({
username: values.username,
password: values.password,
email: values.email,
phone: values.phone,
realName: values.realName
});
if (result.success) {
message.success(result.isFirstUser ? '注册成功,已为您创建管理员账户' : '注册成功');
navigate('/');
} else {
message.error(result.message || '注册失败');
}
} catch (error) {
message.error(error || '注册失败');
} finally {
setLoading(false);
}
};
const containerStyle = {
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '24px'
};
const cardStyle = {
width: '100%',
maxWidth: isFirstUser ? 450 : 400,
borderRadius: '16px',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)'
};
const headerStyle = {
textAlign: 'center',
marginBottom: '32px'
};
const titleStyle = {
fontSize: '28px',
fontWeight: '700',
color: '#1a1a2e',
marginBottom: '8px'
};
const subtitleStyle = {
fontSize: '14px',
color: '#666'
};
const formStyle = {
marginTop: '24px'
};
const submitButtonStyle = {
width: '100%',
height: '48px',
fontSize: '16px',
fontWeight: '600',
borderRadius: '8px'
};
const footerStyle = {
textAlign: 'center',
marginTop: '24px'
};
return (
<div style={containerStyle}>
<Card style={cardStyle}>
<div style={headerStyle}>
<div style={{
fontSize: '48px',
marginBottom: '16px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}>
<RobotOutlined />
</div>
<Title level={2} style={titleStyle}>
{isFirstUser ? '创建管理员账户' : 'IDC设备管理系统'}
</Title>
<Text style={subtitleStyle}>
{isFirstUser ? '首次使用,请创建系统管理员账户' : '请登录您的账户'}
</Text>
</div>
{isFirstUser && (
<Alert
message="欢迎使用IDC设备管理系统"
description="您是第一个用户,系统将自动为您分配管理员权限。"
type="success"
showIcon
style={{ marginBottom: '24px' }}
/>
)}
<Form
name={registerMode ? 'register' : 'login'}
size="large"
onFinish={registerMode ? onFinishRegister : onFinishLogin}
style={formStyle}
>
{registerMode ? (
<>
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, max: 20, message: '用户名长度必须在3-20个字符之间' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
/>
</Form.Item>
<Form.Item
name="realName"
rules={[{ required: true, message: '请输入真实姓名' }]}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder="真实姓名"
/>
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' }
]}
>
<Input
prefix={<MailOutlined />}
placeholder="邮箱"
/>
</Form.Item>
<Form.Item
name="phone"
>
<Input
prefix={<PhoneOutlined />}
placeholder="手机号(可选)"
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度不能少于6个字符' }
]}
>
<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
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
/>
</Form.Item>
</>
)}
<Form.Item style={{ marginBottom: '16px' }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
style={submitButtonStyle}
>
{registerMode ? '立即注册' : '登 录'}
</Button>
</Form.Item>
</Form>
{!isFirstUser && (
<div style={footerStyle}>
<Space split={<Divider type="vertical" />}>
<Button
type="link"
size="small"
onClick={() => setRegisterMode(!registerMode)}
>
{registerMode ? '已有账户?去登录' : '注册新账户'}
</Button>
</Space>
</div>
)}
</Card>
</div>
);
};
export default Login;
+194
View File
@@ -0,0 +1,194 @@
import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Space, Button, DatePicker, Select, message, Popconfirm, Typography, Descriptions } from 'antd';
import { ReloadOutlined, DeleteOutlined, EyeOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import { loginHistoryAPI } from '../api';
import dayjs from 'dayjs';
const { Title } = Typography;
const { RangePicker } = DatePicker;
const LoginHistory = () => {
const [histories, setHistories] = useState([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
const [filters, setFilters] = useState({});
useEffect(() => {
fetchHistories();
}, [pagination.current, filters]);
const fetchHistories = async () => {
setLoading(true);
try {
const params = {
page: pagination.current,
pageSize: pagination.pageSize,
...filters
};
const response = await loginHistoryAPI.list(params);
if (response.success) {
setHistories(response.data.histories);
setPagination(prev => ({ ...prev, total: response.data.total }));
}
} catch (error) {
message.error('获取登录历史失败');
} finally {
setLoading(false);
}
};
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
setPagination(prev => ({ ...prev, current: 1 }));
};
const handleDateChange = (dates) => {
if (dates) {
setFilters(prev => ({
...prev,
startDate: dates[0].toISOString(),
endDate: dates[1].toISOString()
}));
} else {
setFilters(prev => ({ ...prev, startDate: undefined, endDate: undefined }));
}
setPagination(prev => ({ ...prev, current: 1 }));
};
const handleClear = async () => {
try {
const response = await loginHistoryAPI.clear({ days: 30 });
if (response.success) {
message.success('已清理30天前的登录记录');
fetchHistories();
}
} catch (error) {
message.error('清理失败');
}
};
const columns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
width: 120
},
{
title: '真实姓名',
dataIndex: 'realName',
key: 'realName',
width: 100,
render: (name) => name || '-'
},
{
title: '登录时间',
dataIndex: 'loginTime',
key: 'loginTime',
width: 180,
render: (time) => time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-'
},
{
title: 'IP地址',
dataIndex: 'loginIp',
key: 'loginIp',
width: 140,
render: (ip) => ip || '-'
},
{
title: '登录状态',
dataIndex: 'loginType',
key: 'loginType',
width: 100,
render: (type) => (
<Tag color={type === 'success' ? 'green' : 'red'}>
{type === 'success' ? '成功' : '失败'}
</Tag>
)
},
{
title: '失败原因',
dataIndex: 'failReason',
key: 'failReason',
width: 150,
render: (reason) => reason || '-'
},
{
title: '浏览器',
dataIndex: 'userAgent',
key: 'userAgent',
ellipsis: true,
render: (ua) => {
if (!ua) return '-';
let browser = 'Unknown';
if (ua.includes('Chrome')) browser = 'Chrome';
else if (ua.includes('Firefox')) browser = 'Firefox';
else if (ua.includes('Safari')) browser = 'Safari';
else if (ua.includes('Edge')) browser = 'Edge';
return browser;
}
}
];
const pageHeaderStyle = {
marginBottom: '24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
};
const titleStyle = {
fontSize: '20px',
fontWeight: '600',
margin: 0
};
return (
<div>
<div style={pageHeaderStyle}>
<h1 style={titleStyle}>登录历史</h1>
<Space>
<Button icon={<ReloadOutlined />} onClick={fetchHistories}>刷新</Button>
<Popconfirm title="确定清理30天前的登录记录?" onConfirm={handleClear}>
<Button danger>清理旧记录</Button>
</Popconfirm>
</Space>
</div>
<Card style={{ marginBottom: '16px' }}>
<Space wrap>
<Select
placeholder="登录状态"
allowClear
style={{ width: 120 }}
onChange={(value) => handleFilterChange('loginType', value)}
>
<Select.Option value="success">成功</Select.Option>
<Select.Option value="failed">失败</Select.Option>
</Select>
<RangePicker onChange={handleDateChange} showTime />
</Space>
</Card>
<Card>
<Table
columns={columns}
dataSource={histories}
rowKey="id"
loading={loading}
pagination={{
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`
}}
onChange={(newPagination) => {
setPagination(prev => ({ ...prev, ...newPagination }));
}}
/>
</Card>
</div>
);
};
export default LoginHistory;
+335
View File
@@ -0,0 +1,335 @@
import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Space, Button, DatePicker, Select, Input, message, Popconfirm, Typography, Drawer, Descriptions, Timeline } from 'antd';
import { ReloadOutlined, DeleteOutlined, EyeOutlined, FileTextOutlined } from '@ant-design/icons';
import { operationLogAPI } from '../api';
import dayjs from 'dayjs';
const { Title } = Typography;
const { RangePicker } = DatePicker;
const OperationLogs = () => {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 });
const [filters, setFilters] = useState({});
const [actions, setActions] = useState([]);
const [modules, setModules] = useState([]);
const [detailVisible, setDetailVisible] = useState(false);
const [selectedLog, setSelectedLog] = useState(null);
useEffect(() => {
fetchLogs();
fetchOptions();
}, [pagination.current, filters]);
const fetchLogs = async () => {
setLoading(true);
try {
const params = {
page: pagination.current,
pageSize: pagination.pageSize,
...filters
};
const response = await operationLogAPI.list(params);
if (response.success) {
setLogs(response.data.logs);
setPagination(prev => ({ ...prev, total: response.data.total }));
}
} catch (error) {
message.error('获取操作日志失败');
} finally {
setLoading(false);
}
};
const fetchOptions = async () => {
try {
const [actionsRes, modulesRes] = await Promise.all([
operationLogAPI.getActions(),
operationLogAPI.getModules()
]);
if (actionsRes.success) setActions(actionsRes.data);
if (modulesRes.success) setModules(modulesRes.data);
} catch (error) {
console.error('获取选项失败:', error);
}
};
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
setPagination(prev => ({ ...prev, current: 1 }));
};
const handleDateChange = (dates) => {
if (dates) {
setFilters(prev => ({
...prev,
startDate: dates[0].toISOString(),
endDate: dates[1].toISOString()
}));
} else {
setFilters(prev => ({ ...prev, startDate: undefined, endDate: undefined }));
}
setPagination(prev => ({ ...prev, current: 1 }));
};
const handleClear = async () => {
try {
const response = await operationLogAPI.clear({ days: 30 });
if (response.success) {
message.success('已清理30天前的日志');
fetchLogs();
}
} catch (error) {
message.error('清理失败');
}
};
const showDetail = (log) => {
setSelectedLog(log);
setDetailVisible(true);
};
const getActionColor = (action) => {
if (action.includes('删除')) return 'red';
if (action.includes('创建')) return 'green';
if (action.includes('修改')) return 'blue';
if (action.includes('登录')) return 'purple';
return 'default';
};
const getModuleColor = (module) => {
const colors = {
user: 'blue',
role: 'green',
device: 'orange',
consumable: 'purple',
system: 'cyan'
};
return colors[module] || 'default';
};
const columns = [
{
title: '操作时间',
dataIndex: 'operateTime',
key: 'operateTime',
width: 180,
sorter: (a, b) => new Date(b.operateTime) - new Date(a.operateTime),
render: (time) => time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-'
},
{
title: '操作人',
key: 'operator',
width: 150,
render: (_, record) => (
<div>
<div style={{ fontWeight: 500 }}>{record.realName || record.username}</div>
<div style={{ fontSize: '12px', color: '#999' }}>@{record.username}</div>
</div>
)
},
{
title: '操作类型',
dataIndex: 'action',
key: 'action',
width: 120,
render: (action) => (
<Tag color={getActionColor(action)}>{action || '-'}</Tag>
)
},
{
title: '模块',
dataIndex: 'module',
key: 'module',
width: 100,
render: (module) => (
<Tag color={getModuleColor(module)}>
{module === 'user' ? '用户' :
module === 'role' ? '角色' :
module === 'device' ? '设备' :
module === 'consumable' ? '耗材' :
module === 'system' ? '系统' : module}
</Tag>
)
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true
},
{
title: '目标',
key: 'target',
width: 120,
render: (_, record) => record.targetName || record.targetId || '-'
},
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
width: 130,
render: (ip) => ip || '-'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (status) => (
<Tag color={status === 'success' ? 'green' : 'red'}>
{status === 'success' ? '成功' : '失败'}
</Tag>
)
},
{
title: '操作',
key: 'action',
width: 80,
render: (_, record) => (
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => showDetail(record)}
/>
)
}
];
const pageHeaderStyle = {
marginBottom: '24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
};
const titleStyle = {
fontSize: '20px',
fontWeight: '600',
margin: 0
};
return (
<div>
<div style={pageHeaderStyle}>
<h1 style={titleStyle}>操作日志</h1>
<Space>
<Button icon={<ReloadOutlined />} onClick={fetchLogs}>刷新</Button>
<Popconfirm title="确定清理30天前的日志?" onConfirm={handleClear}>
<Button danger>清理旧日志</Button>
</Popconfirm>
</Space>
</div>
<Card style={{ marginBottom: '16px' }}>
<Space wrap>
<Input.Search
placeholder="搜索操作人"
style={{ width: 150 }}
onSearch={(value) => handleFilterChange('username', value)}
allowClear
/>
<Select
placeholder="操作类型"
allowClear
style={{ width: 140 }}
onChange={(value) => handleFilterChange('action', value)}
options={actions}
fieldNames={{ label: 'label', value: 'value' }}
/>
<Select
placeholder="模块"
allowClear
style={{ width: 120 }}
onChange={(value) => handleFilterChange('module', value)}
options={modules}
fieldNames={{ label: 'label', value: 'value' }}
/>
<RangePicker onChange={handleDateChange} showTime />
</Space>
</Card>
<Card>
<Table
columns={columns}
dataSource={logs}
rowKey="id"
loading={loading}
pagination={{
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`
}}
onChange={(newPagination) => {
setPagination(prev => ({ ...prev, ...newPagination }));
}}
/>
</Card>
<Drawer
title="日志详情"
placement="right"
width={500}
open={detailVisible}
onClose={() => setDetailVisible(false)}
>
{selectedLog && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="操作时间">
{selectedLog.operateTime ? dayjs(selectedLog.operateTime).format('YYYY-MM-DD HH:mm:ss') : '-'}
</Descriptions.Item>
<Descriptions.Item label="操作人">
{selectedLog.realName || selectedLog.username} (@{selectedLog.username})
</Descriptions.Item>
<Descriptions.Item label="操作类型">
<Tag color={getActionColor(selectedLog.action)}>{selectedLog.action}</Tag>
</Descriptions.Item>
<Descriptions.Item label="模块">
<Tag color={getModuleColor(selectedLog.module)}>{selectedLog.module}</Tag>
</Descriptions.Item>
<Descriptions.Item label="描述">{selectedLog.description || '-'}</Descriptions.Item>
<Descriptions.Item label="目标对象">
{selectedLog.targetName || selectedLog.targetId || '-'}
</Descriptions.Item>
<Descriptions.Item label="IP地址">{selectedLog.ip || '-'}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={selectedLog.status === 'success' ? 'green' : 'red'}>
{selectedLog.status === 'success' ? '成功' : '失败'}
</Tag>
</Descriptions.Item>
{selectedLog.errorMessage && (
<Descriptions.Item label="错误信息">
<span style={{ color: 'red' }}>{selectedLog.errorMessage}</span>
</Descriptions.Item>
)}
</Descriptions>
)}
{(selectedLog?.oldValue || selectedLog?.newValue) && (
<div style={{ marginTop: '24px' }}>
<Title level={5}>变更内容</Title>
<Descriptions column={1} bordered size="small">
{selectedLog.oldValue && (
<Descriptions.Item label="旧值">
<pre style={{ margin: 0, fontSize: '12px', whiteSpace: 'pre-wrap' }}>
{JSON.stringify(JSON.parse(selectedLog.oldValue), null, 2)}
</pre>
</Descriptions.Item>
)}
{selectedLog.newValue && (
<Descriptions.Item label="新值">
<pre style={{ margin: 0, fontSize: '12px', whiteSpace: 'pre-wrap' }}>
{JSON.stringify(JSON.parse(selectedLog.newValue), null, 2)}
</pre>
</Descriptions.Item>
)}
</Descriptions>
</div>
)}
</Drawer>
</div>
);
};
export default OperationLogs;
+567
View File
@@ -0,0 +1,567 @@
import React, { useState, useEffect, useRef } from 'react';
import { Card, Table, Button, Space, Modal, Form, Input, Select, message, Tag, Popconfirm, Avatar, Tooltip, Badge } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, UserOutlined, ReloadOutlined, LockOutlined, CameraOutlined } from '@ant-design/icons';
import { userAPI, roleAPI } from '../api';
const { Option } = Select;
const UserManagement = () => {
const [users, setUsers] = useState([]);
const [roles, setRoles] = useState([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
const [modalVisible, setModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [avatarModalVisible, setAvatarModalVisible] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [passwordUser, setPasswordUser] = useState(null);
const [avatarUser, setAvatarUser] = useState(null);
const [uploadLoading, setUploadLoading] = useState(false);
const [form] = Form.useForm();
const [passwordForm] = Form.useForm();
const fileInputRef = useRef(null);
useEffect(() => {
fetchUsers();
fetchRoles();
}, [pagination.current]);
const fetchUsers = async () => {
setLoading(true);
try {
const response = await userAPI.list({
page: pagination.current,
pageSize: pagination.pageSize
});
if (response.success) {
setUsers(response.data.users);
setPagination(prev => ({ ...prev, total: response.data.total }));
}
} catch (error) {
message.error('获取用户列表失败');
} finally {
setLoading(false);
}
};
const fetchRoles = async () => {
try {
const response = await roleAPI.all();
if (response.success) {
setRoles(response.data);
} else {
message.error('获取角色列表失败: ' + (response.message || '未知错误'));
}
} catch (error) {
console.error('获取角色列表失败:', error);
message.error('获取角色列表失败,请检查网络连接');
}
};
const handleAdd = () => {
setEditingUser(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (user) => {
setEditingUser(user);
form.setFieldsValue({
username: user.username,
email: user.email,
phone: user.phone,
realName: user.realName,
status: user.status,
roleIds: user.roles?.map(r => r.roleId) || []
});
setModalVisible(true);
};
const handleResetPassword = (user) => {
setPasswordUser(user);
passwordForm.resetFields();
setPasswordModalVisible(true);
};
const handleAvatarClick = (user) => {
setAvatarUser(user);
setAvatarModalVisible(true);
};
const handleAvatarUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.match(/image\/(jpeg|png|gif|webp)/)) {
message.error('只支持 JPG、PNG、GIF 和 WebP 格式的图片');
return;
}
if (file.size > 5 * 1024 * 1024) {
message.error('图片大小不能超过 5MB');
return;
}
setUploadLoading(true);
try {
const response = await userAPI.uploadAvatar(avatarUser.userId, file);
if (response.success) {
message.success('头像上传成功');
fetchUsers();
setAvatarModalVisible(false);
} else {
message.error(response.message || '上传失败');
}
} catch (error) {
message.error('上传失败');
} finally {
setUploadLoading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleAvatarDelete = async () => {
try {
const response = await userAPI.deleteAvatar(avatarUser.userId);
if (response.success) {
message.success('头像已删除');
fetchUsers();
setAvatarModalVisible(false);
} else {
message.error(response.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
};
const handleDelete = async (userId) => {
try {
const response = await userAPI.delete(userId);
if (response.success) {
message.success('删除成功');
fetchUsers();
} else {
message.error(response.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
};
const handleSubmit = async (values) => {
try {
let response;
if (editingUser) {
response = await userAPI.update(editingUser.userId, values);
} else {
response = await userAPI.create(values);
}
if (response.success) {
message.success(editingUser ? '更新成功' : '创建成功');
setModalVisible(false);
fetchUsers();
} else {
message.error(response.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
}
};
const handleResetPasswordSubmit = async (values) => {
try {
const response = await userAPI.resetPassword(passwordUser.userId, values);
if (response.success) {
message.success('密码重置成功');
setPasswordModalVisible(false);
} else {
message.error(response.message || '重置失败');
}
} catch (error) {
message.error('重置失败');
}
};
const getStatusColor = (status) => {
const colors = {
active: 'green',
inactive: 'red',
locked: 'orange'
};
return colors[status] || 'default';
};
const getStatusText = (status) => {
const texts = {
active: '正常',
inactive: '禁用',
locked: '锁定'
};
return texts[status] || status;
};
const getAvatarUrl = (user) => {
if (!user?.avatar) return null;
return user.avatar;
};
const columns = [
{
title: '头像',
key: 'avatar',
width: 80,
render: (_, record) => (
<Badge dot={!!record.avatar} color="green" offset={[-5, 35]}>
<Avatar
size={48}
icon={!record.avatar && <UserOutlined />}
src={getAvatarUrl(record)}
style={{
backgroundColor: record.avatar ? 'transparent' : '#1890ff',
cursor: 'pointer'
}}
onClick={() => handleAvatarClick(record)}
/>
</Badge>
)
},
{
title: '用户名',
key: 'username',
width: 150,
render: (_, record) => (
<div>
<div style={{ fontWeight: 500 }}>{record.realName || record.username}</div>
<div style={{ fontSize: '12px', color: '#999' }}>@{record.username}</div>
</div>
)
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 200,
render: (email) => email || '-'
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 130,
render: (phone) => phone || '-'
},
{
title: '角色',
key: 'roles',
render: (_, record) => (
<Space wrap>
{record.roles?.map(role => (
<Tag key={role.roleId} color={role.roleCode === 'admin' ? 'blue' : 'green'}>
{role.roleName}
</Tag>
)) || '-'}
</Space>
)
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status) => (
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
)
},
{
title: '最后登录',
key: 'lastLogin',
render: (_, record) => (
<div style={{ fontSize: '12px' }}>
<div>{record.lastLoginTime ? new Date(record.lastLoginTime).toLocaleString() : '从未登录'}</div>
<div style={{ color: '#999' }}>{record.lastLoginIp || '-'}</div>
</div>
)
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="small">
<Tooltip title="编辑">
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Tooltip title="重置密码">
<Button
type="text"
icon={<LockOutlined />}
onClick={() => handleResetPassword(record)}
/>
</Tooltip>
<Popconfirm
title="确定要删除此用户吗?"
onConfirm={() => handleDelete(record.userId)}
okText="确定"
cancelText="取消"
>
<Tooltip title="删除">
<Button type="text" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
</Space>
)
}
];
const pageHeaderStyle = {
marginBottom: '24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
};
const titleStyle = {
fontSize: '20px',
fontWeight: '600',
margin: 0
};
return (
<div>
<div style={pageHeaderStyle}>
<h1 style={titleStyle}>用户管理</h1>
<Space>
<Button icon={<ReloadOutlined />} onClick={fetchUsers}>刷新</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
添加用户
</Button>
</Space>
</div>
<Card>
<Table
columns={columns}
dataSource={users}
rowKey="userId"
loading={loading}
pagination={{
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`
}}
onChange={(newPagination) => {
setPagination(prev => ({ ...prev, ...newPagination }));
}}
/>
</Card>
<Modal
title={editingUser ? '编辑用户' : '添加用户'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
footer={null}
width={500}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
style={{ marginTop: '20px' }}
>
<Form.Item
name="username"
label="用户名"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, max: 20, message: '用户名长度必须在3-20个字符之间' }
]}
>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item
name="realName"
label="真实姓名"
rules={[{ required: true, message: '请输入真实姓名' }]}
>
<Input placeholder="请输入真实姓名" />
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' }
]}
>
<Input placeholder="请输入邮箱" />
</Form.Item>
<Form.Item name="phone" label="手机号">
<Input placeholder="请输入手机号" />
</Form.Item>
<Form.Item
name="roleIds"
label="角色"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select mode="multiple" placeholder="请选择角色">
{roles.map(role => (
<Option key={role.roleId} value={role.roleId}>
{role.roleName}
</Option>
))}
</Select>
</Form.Item>
{!editingUser && (
<Form.Item
name="password"
label="初始密码"
rules={[
{ required: true, message: '请输入初始密码' },
{ min: 6, message: '密码长度不能少于6个字符' }
]}
>
<Input.Password placeholder="请输入初始密码" />
</Form.Item>
)}
<Form.Item name="status" label="状态">
<Select placeholder="请选择状态">
<Option value="active">正常</Option>
<Option value="inactive">禁用</Option>
<Option value="locked">锁定</Option>
</Select>
</Form.Item>
{editingUser && (
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ min: 6, message: '密码长度不能少于6个字符' }
]}
>
<Input.Password placeholder="留空则不修改密码" />
</Form.Item>
)}
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setModalVisible(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={loading}>
{editingUser ? '更新' : '创建'}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title={`重置密码 - ${passwordUser?.username}`}
open={passwordModalVisible}
onCancel={() => setPasswordModalVisible(false)}
footer={null}
width={400}
>
<Form
form={passwordForm}
layout="vertical"
onFinish={handleResetPasswordSubmit}
style={{ marginTop: '20px' }}
>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度不能少于6个字符' }
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setPasswordModalVisible(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={loading}>
重置
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title={`设置头像 - ${avatarUser?.username}`}
open={avatarModalVisible}
onCancel={() => setAvatarModalVisible(false)}
footer={null}
width={400}
>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ marginBottom: '24px' }}>
<Badge dot={!!avatarUser?.avatar} color="green" offset={[-5, 35]}>
<Avatar
size={120}
icon={!avatarUser?.avatar && <UserOutlined />}
src={getAvatarUrl(avatarUser)}
style={{
backgroundColor: avatarUser?.avatar ? 'transparent' : '#1890ff',
border: '1px solid #f0f0f0'
}}
/>
</Badge>
</div>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<input
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
ref={fileInputRef}
onChange={handleAvatarUpload}
/>
<Button
type="primary"
icon={<CameraOutlined />}
onClick={() => fileInputRef.current?.click()}
loading={uploadLoading}
block
>
{avatarUser?.avatar ? '更换头像' : '上传头像'}
</Button>
{avatarUser?.avatar && (
<Button
danger
icon={<DeleteOutlined />}
onClick={handleAvatarDelete}
block
>
删除头像
</Button>
)}
</Space>
<div style={{ marginTop: '16px', color: '#999', fontSize: '12px' }}>
支持 JPGPNGGIFWebP 格式大小不超过 5MB
</div>
</div>
</Modal>
</div>
);
};
export default UserManagement;