feat: 实现用户认证与权限管理系统
- 添加用户、角色、权限等数据模型 - 实现JWT认证中间件和密码加密 - 添加用户注册、登录、个人信息管理接口 - 实现前端认证上下文和受保护路由 - 添加登录历史记录和操作日志功能 - 提供管理员初始化脚本和修复工具 - 实现完整的登录页面和用户管理界面
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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' }}>
|
||||
支持 JPG、PNG、GIF、WebP 格式,大小不超过 5MB
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagement;
|
||||
Reference in New Issue
Block a user