Files
yunrui_asset/frontend/src/pages/UserManagement.jsx
T
zhang1106 73cbe4ac1b feat: 添加操作日志、危险操作确认和业务关联功能
1. 新增操作日志记录功能,记录关键操作
2. 实现危险操作确认对话框,防止误删
3. 添加业务和库房管理模块
4. 支持设备标记为空闲状态
5. 完善API文档和健康检查
6. 优化前端删除操作的确认流程
7. 添加Swagger API文档支持
8. 实现设备与业务的关联功能
9. 改进设备模型,添加空闲相关字段
10. 优化用户、角色管理操作日志
2026-03-20 17:15:14 +08:00

883 lines
25 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useCallback, useMemo } 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,
CheckOutlined,
CloseOutlined,
} from '@ant-design/icons';
import { userAPI, roleAPI } from '../api';
import CloseButton from '../components/CloseButton';
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 [activeTab, setActiveTab] = useState('all');
const [form] = Form.useForm();
const [passwordForm] = Form.useForm();
const fileInputRef = useRef(null);
useEffect(() => {
fetchUsers();
fetchRoles();
}, [pagination.current]);
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const params = {
page: pagination.current,
pageSize: pagination.pageSize,
};
if (activeTab !== 'all') {
params.status = activeTab;
}
const response = await userAPI.list(params);
if (response.success) {
setUsers(response.data.users);
setPagination(prev => ({ ...prev, total: response.data.total }));
}
} catch (error) {
message.error('获取用户列表失败');
} finally {
setLoading(false);
}
}, [pagination.current, pagination.pageSize, activeTab]);
const fetchRoles = useCallback(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 = useCallback(() => {
setEditingUser(null);
form.resetFields();
setModalVisible(true);
}, []);
const handleEdit = useCallback(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 = useCallback(user => {
setPasswordUser(user);
passwordForm.resetFields();
setPasswordModalVisible(true);
}, []);
const handleAvatarClick = useCallback(user => {
setAvatarUser(user);
setAvatarModalVisible(true);
}, []);
const handleAvatarUpload = useCallback(
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 = '';
}
}
},
[avatarUser, fetchUsers]
);
const handleAvatarDelete = useCallback(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('删除失败');
}
}, [avatarUser, fetchUsers]);
const handleDelete = useCallback(
async userId => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个用户吗?此操作不可恢复!',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const response = await userAPI.delete(userId);
if (response.success) {
message.success('删除成功');
fetchUsers();
} else {
message.error(response.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
},
});
},
[fetchUsers]
);
const handleLockUnlock = useCallback(
async record => {
try {
const newStatus = record.status === 'locked' ? 'active' : 'locked';
const response = await userAPI.update(record.userId, {
status: newStatus,
});
if (response.success) {
message.success(record.status === 'locked' ? '解锁成功' : '锁定成功');
fetchUsers();
} else {
message.error(response.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
}
},
[fetchUsers]
);
const handleSubmit = useCallback(
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('操作失败');
}
},
[editingUser, fetchUsers]
);
const handleResetPasswordSubmit = useCallback(
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('重置失败');
}
},
[passwordUser]
);
const getStatusColor = status => {
const colors = {
active: 'green',
inactive: 'red',
locked: 'orange',
pending: 'blue',
};
return colors[status] || 'default';
};
const getStatusText = status => {
const texts = {
active: '正常',
inactive: '禁用',
locked: '锁定',
pending: '待审核',
};
return texts[status] || status;
};
const handleApprove = useCallback(
async userId => {
try {
const response = await userAPI.approve(userId);
if (response.success) {
message.success('审核通过');
fetchUsers();
} else {
message.error(response.message || '审核失败');
}
} catch (error) {
message.error('审核失败');
}
},
[fetchUsers]
);
const handleReject = useCallback(
async userId => {
try {
const response = await userAPI.reject(userId);
if (response.success) {
message.success('已拒绝该用户的注册申请');
fetchUsers();
} else {
message.error(response.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
}
},
[fetchUsers]
);
const getAvatarUrl = user => {
if (!user?.avatar) return null;
return user.avatar;
};
const tableColumns = useMemo(
() => [
{
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">
{record.status === 'pending' ? (
<>
<Popconfirm
title="确定要通过该用户的注册申请吗?"
onConfirm={() => handleApprove(record.userId)}
okText="通过"
cancelText="取消"
>
<Tooltip title="通过">
<Button type="text" icon={<CheckOutlined />} style={{ color: '#52c41a' }} />
</Tooltip>
</Popconfirm>
<Popconfirm
title="确定要拒绝该用户的注册申请吗?"
onConfirm={() => handleReject(record.userId)}
okText="拒绝"
cancelText="取消"
>
<Tooltip title="拒绝">
<Button type="text" icon={<CloseOutlined />} style={{ color: '#ff4d4f' }} />
</Tooltip>
</Popconfirm>
</>
) : (
<>
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
</Tooltip>
<Tooltip title="重置密码">
<Button
type="text"
icon={<LockOutlined />}
onClick={() => handleResetPassword(record)}
/>
</Tooltip>
<Popconfirm
title={
record.status === 'locked' ? '确定要解锁此用户吗?' : '确定要锁定此用户吗?'
}
onConfirm={() => handleLockUnlock(record)}
okText="确定"
cancelText="取消"
>
<Tooltip title={record.status === 'locked' ? '解锁' : '锁定'}>
<Button
type="text"
icon={record.status === 'locked' ? <ReloadOutlined /> : <LockOutlined />}
style={{ color: record.status === 'locked' ? '#52c41a' : '#faad14' }}
/>
</Tooltip>
</Popconfirm>
<Popconfirm
title="确定要删除此用户吗?"
onConfirm={() => handleDelete(record.userId)}
okText="确定"
cancelText="取消"
>
<Tooltip title="删除">
<Button type="text" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
</>
)}
</Space>
),
},
],
[
handleAvatarClick,
handleEdit,
handleResetPassword,
handleDelete,
handleLockUnlock,
handleApprove,
handleReject,
]
);
const pageHeaderStyle = {
marginBottom: '24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '16px',
};
const titleStyle = {
fontSize: '24px',
fontWeight: '700',
margin: 0,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
};
const cardStyle = {
borderRadius: '16px',
border: 'none',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
overflow: 'hidden',
};
const cardHeadStyle = {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
background: 'linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%)',
};
const primaryButtonStyle = {
height: '40px',
borderRadius: '8px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.35)',
fontWeight: '500',
transition: 'all 0.3s ease',
};
const secondaryButtonStyle = {
height: '40px',
borderRadius: '8px',
border: '1px solid #e8e8e8',
transition: 'all 0.3s ease',
};
const actionButtonStyle = {
height: '32px',
borderRadius: '6px',
border: '1px solid #e8e8e8',
transition: 'all 0.3s ease',
};
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: '600',
};
const modalHeaderAccent = {
width: '4px',
height: '20px',
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: '2px',
};
const avatarModalStyle = {
textAlign: 'center',
padding: '20px 0',
};
const avatarWrapperStyle = {
marginBottom: '24px',
position: 'relative',
display: 'inline-block',
};
return (
<div style={{ padding: '8px' }}>
<div style={pageHeaderStyle}>
<h1 style={titleStyle}>用户管理</h1>
<Space size="middle">
<Button icon={<ReloadOutlined />} onClick={fetchUsers} style={secondaryButtonStyle}>
刷新
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
style={primaryButtonStyle}
>
添加用户
</Button>
</Space>
</div>
<Card
style={cardStyle}
styles={{ header: cardHeadStyle, body: { padding: '20px 24px' } }}
tabList={[
{ key: 'all', tab: '全部用户' },
{ key: 'pending', tab: '待审核' },
{ key: 'active', tab: '正常' },
{ key: 'locked', tab: '锁定' },
{ key: 'inactive', tab: '禁用' },
]}
activeTabKey={activeTab}
onTabChange={key => {
setActiveTab(key);
setPagination(prev => ({ ...prev, current: 1 }));
}}
>
<Table
columns={tableColumns}
dataSource={users}
rowKey="userId"
loading={loading}
pagination={{
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: total => `共 ${total} 条记录`,
}}
onChange={newPagination => {
setPagination(prev => ({ ...prev, ...newPagination }));
}}
rowClassName={() => 'table-row'}
/>
</Card>
<Modal
title={
<div style={modalHeaderStyle}>
<span style={modalHeaderAccent}></span>
{editingUser ? '编辑用户' : '添加用户'}
</div>
}
open={modalVisible}
closeIcon={<CloseButton />}
onCancel={() => setModalVisible(false)}
footer={null}
width={500}
destroyOnHidden
styles={{
body: { padding: '24px' },
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
}}
style={{ borderRadius: '16px', overflow: 'hidden' }}
>
<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="请输入用户名" style={{ borderRadius: '8px' }} />
</Form.Item>
<Form.Item
name="realName"
label="真实姓名"
rules={[{ required: true, message: '请输入真实姓名' }]}
>
<Input placeholder="请输入真实姓名" style={{ borderRadius: '8px' }} />
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input placeholder="请输入邮箱" style={{ borderRadius: '8px' }} />
</Form.Item>
<Form.Item name="phone" label="手机号">
<Input placeholder="请输入手机号" style={{ borderRadius: '8px' }} />
</Form.Item>
<Form.Item
name="roleIds"
label="角色"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select mode="multiple" placeholder="请选择角色" style={{ borderRadius: '8px' }}>
{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="请输入初始密码" style={{ borderRadius: '8px' }} />
</Form.Item>
)}
<Form.Item name="status" label="状态">
<Select placeholder="请选择状态" style={{ borderRadius: '8px' }}>
<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="留空则不修改密码" style={{ borderRadius: '8px' }} />
</Form.Item>
)}
<Form.Item style={{ marginBottom: 0, textAlign: 'right', marginTop: '24px' }}>
<Space>
<Button
onClick={() => setModalVisible(false)}
style={{ borderRadius: '8px', height: '40px' }}
>
取消
</Button>
<Button
type="primary"
htmlType="submit"
loading={loading}
style={{
...primaryButtonStyle,
width: 'auto',
paddingLeft: '24px',
paddingRight: '24px',
}}
>
{editingUser ? '更新' : '创建'}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title={
<div style={modalHeaderStyle}>
<span style={modalHeaderAccent}></span>
{`重置密码 - ${passwordUser?.username}`}
</div>
}
open={passwordModalVisible}
closeIcon={<CloseButton />}
onCancel={() => setPasswordModalVisible(false)}
footer={null}
width={400}
destroyOnHidden
styles={{
body: { padding: '24px' },
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
}}
style={{ borderRadius: '16px', overflow: 'hidden' }}
>
<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="请输入新密码" style={{ borderRadius: '8px' }} />
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right', marginTop: '24px' }}>
<Space>
<Button
onClick={() => setPasswordModalVisible(false)}
style={{ borderRadius: '8px', height: '40px' }}
>
取消
</Button>
<Button
type="primary"
htmlType="submit"
loading={loading}
style={{
...primaryButtonStyle,
width: 'auto',
paddingLeft: '24px',
paddingRight: '24px',
}}
>
重置
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title={
<div style={modalHeaderStyle}>
<span style={modalHeaderAccent}></span>
{`设置头像 - ${avatarUser?.username}`}
</div>
}
open={avatarModalVisible}
closeIcon={<CloseButton />}
onCancel={() => setAvatarModalVisible(false)}
footer={null}
width={400}
destroyOnHidden
styles={{
body: { padding: '24px' },
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
}}
style={{ borderRadius: '16px', overflow: 'hidden' }}
>
<div style={avatarModalStyle}>
<div style={avatarWrapperStyle}>
<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',
cursor: 'pointer',
}}
/>
</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
style={{
...primaryButtonStyle,
height: '44px',
}}
>
{avatarUser?.avatar ? '更换头像' : '上传头像'}
</Button>
{avatarUser?.avatar && (
<Button
danger
icon={<DeleteOutlined />}
onClick={handleAvatarDelete}
block
style={{ borderRadius: '8px', height: '44px' }}
>
删除头像
</Button>
)}
</Space>
<div style={{ marginTop: '16px', color: '#8c8c8c', fontSize: '12px' }}>
支持 JPGPNGGIFWebP 格式大小不超过 5MB
</div>
</div>
</Modal>
</div>
);
};
export default UserManagement;