feat: 添加操作日志、危险操作确认和业务关联功能
1. 新增操作日志记录功能,记录关键操作 2. 实现危险操作确认对话框,防止误删 3. 添加业务和库房管理模块 4. 支持设备标记为空闲状态 5. 完善API文档和健康检查 6. 优化前端删除操作的确认流程 7. 添加Swagger API文档支持 8. 实现设备与业务的关联功能 9. 改进设备模型,添加空闲相关字段 10. 优化用户、角色管理操作日志
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Card,
|
||||
Space,
|
||||
Select,
|
||||
DatePicker,
|
||||
Input,
|
||||
Tag,
|
||||
Button,
|
||||
message,
|
||||
Modal,
|
||||
Row,
|
||||
Col,
|
||||
Descriptions,
|
||||
Typography,
|
||||
Drawer,
|
||||
Statistic,
|
||||
} from 'antd';
|
||||
import {
|
||||
HistoryOutlined,
|
||||
SearchOutlined,
|
||||
EyeOutlined,
|
||||
FilterOutlined,
|
||||
ClearOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api from '../api';
|
||||
import CloseButton from '../components/CloseButton';
|
||||
import dayjs from 'dayjs';
|
||||
import { selectStyles, filterInputStyles, inputPlaceholders } from '../styles/deviceManagementStyles';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Option } = Select;
|
||||
const { Text } = Typography;
|
||||
|
||||
const MODULE_OPTIONS = [
|
||||
{ value: 'device', label: '设备管理' },
|
||||
{ value: 'user', label: '用户管理' },
|
||||
{ value: 'role', label: '角色管理' },
|
||||
];
|
||||
|
||||
const OPERATION_TYPE_OPTIONS = [
|
||||
{ value: 'create', label: '创建' },
|
||||
{ value: 'update', label: '更新' },
|
||||
{ value: 'delete', label: '删除' },
|
||||
{ value: 'batch_delete', label: '批量删除' },
|
||||
{ value: 'status_change', label: '状态变更' },
|
||||
{ value: 'move', label: '移动' },
|
||||
{ value: 'permission_change', label: '权限变更' },
|
||||
];
|
||||
|
||||
const RESULT_OPTIONS = [
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
];
|
||||
|
||||
function OperationLogs() {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 });
|
||||
const [filters, setFilters] = useState({
|
||||
module: null,
|
||||
operationType: null,
|
||||
keyword: '',
|
||||
dateRange: null,
|
||||
result: null,
|
||||
});
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [currentLog, setCurrentLog] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const fetchLogs = useCallback(async (page = 1, pageSize = 20, currentFilters = filters) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = { page, pageSize };
|
||||
|
||||
if (currentFilters.module) {
|
||||
params.module = currentFilters.module;
|
||||
}
|
||||
if (currentFilters.operationType) {
|
||||
params.operationType = currentFilters.operationType;
|
||||
}
|
||||
if (currentFilters.keyword) {
|
||||
params.keyword = currentFilters.keyword;
|
||||
}
|
||||
if (currentFilters.result) {
|
||||
params.result = currentFilters.result;
|
||||
}
|
||||
if (currentFilters.dateRange && currentFilters.dateRange.length === 2) {
|
||||
params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD');
|
||||
params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const response = await api.get('/operation-logs', { params });
|
||||
if (response.success) {
|
||||
setLogs(response.data.logs);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取操作日志失败');
|
||||
console.error('获取操作日志失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs(1, pagination.pageSize, filters);
|
||||
}, []);
|
||||
|
||||
const handleTableChange = (newPagination, tableFilters) => {
|
||||
fetchLogs(newPagination.current, newPagination.pageSize, filters);
|
||||
};
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
const newFilters = { ...filters, [key]: value };
|
||||
setFilters(newFilters);
|
||||
fetchLogs(1, pagination.pageSize, newFilters);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
const clearedFilters = {
|
||||
module: null,
|
||||
operationType: null,
|
||||
keyword: '',
|
||||
dateRange: null,
|
||||
result: null,
|
||||
};
|
||||
setFilters(clearedFilters);
|
||||
fetchLogs(1, pagination.pageSize, clearedFilters);
|
||||
};
|
||||
|
||||
const handleViewDetail = async (record) => {
|
||||
setDetailLoading(true);
|
||||
setDetailVisible(true);
|
||||
try {
|
||||
const response = await api.get(`/operation-logs/${record.recordId}`);
|
||||
if (response.success) {
|
||||
setCurrentLog(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取日志详情失败');
|
||||
console.error('获取日志详情失败:', error);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getModuleTag = (module) => {
|
||||
const colors = {
|
||||
device: 'blue',
|
||||
user: 'green',
|
||||
role: 'purple',
|
||||
consumable: 'orange',
|
||||
rack: 'cyan',
|
||||
room: 'magenta',
|
||||
ticket: 'red',
|
||||
backup: 'gold',
|
||||
};
|
||||
const names = {
|
||||
device: '设备',
|
||||
user: '用户',
|
||||
role: '角色',
|
||||
consumable: '耗材',
|
||||
rack: '机柜',
|
||||
room: '机房',
|
||||
ticket: '工单',
|
||||
backup: '备份',
|
||||
};
|
||||
return <Tag color={colors[module] || 'default'}>{names[module] || module}</Tag>;
|
||||
};
|
||||
|
||||
const getOperationTag = (type) => {
|
||||
const colors = {
|
||||
create: 'green',
|
||||
update: 'blue',
|
||||
delete: 'red',
|
||||
batch_delete: 'red',
|
||||
batch_update: 'orange',
|
||||
status_change: 'cyan',
|
||||
move: 'purple',
|
||||
permission_change: 'gold',
|
||||
import: 'lime',
|
||||
export: 'lime',
|
||||
};
|
||||
const names = {
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
delete: '删除',
|
||||
batch_delete: '批量删除',
|
||||
batch_update: '批量更新',
|
||||
status_change: '状态变更',
|
||||
move: '移动',
|
||||
permission_change: '权限变更',
|
||||
import: '导入',
|
||||
export: '导出',
|
||||
};
|
||||
return <Tag color={colors[type] || 'default'}>{names[type] || type}</Tag>;
|
||||
};
|
||||
|
||||
const getResultTag = (result) => {
|
||||
return result === 'success'
|
||||
? <Tag color="success">成功</Tag>
|
||||
: <Tag color="error">失败</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
sorter: (a, b) => new Date(a.createdAt) - new Date(b.createdAt),
|
||||
render: date => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '模块',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 100,
|
||||
render: module => getModuleTag(module),
|
||||
},
|
||||
{
|
||||
title: '操作类型',
|
||||
dataIndex: 'operationType',
|
||||
key: 'operationType',
|
||||
width: 120,
|
||||
render: type => getOperationTag(type),
|
||||
},
|
||||
{
|
||||
title: '操作描述',
|
||||
dataIndex: 'operationDescription',
|
||||
key: 'operationDescription',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作对象',
|
||||
dataIndex: 'targetName',
|
||||
key: 'targetName',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (text, record) => text || record.targetId,
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'operatorName',
|
||||
key: 'operatorName',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'result',
|
||||
key: 'result',
|
||||
width: 80,
|
||||
render: result => getResultTag(result),
|
||||
},
|
||||
{
|
||||
title: 'IP地址',
|
||||
dataIndex: 'ipAddress',
|
||||
key: 'ipAddress',
|
||||
width: 140,
|
||||
render: ip => ip || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleViewDetail(record)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const hasFilters = filters.module || filters.operationType || filters.keyword || filters.dateRange || filters.result;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HistoryOutlined />
|
||||
<span>操作日志审计</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Text type="secondary">共 {pagination.total} 条记录</Text>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16} align="middle">
|
||||
<Col flex="200px">
|
||||
<Select
|
||||
placeholder="选择模块"
|
||||
allowClear
|
||||
style={{ width: '100%', ...selectStyles }}
|
||||
value={filters.module}
|
||||
onChange={value => handleFilterChange('module', value)}
|
||||
>
|
||||
{MODULE_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col flex="200px">
|
||||
<Select
|
||||
placeholder="选择操作类型"
|
||||
allowClear
|
||||
style={{ width: '100%', ...selectStyles }}
|
||||
value={filters.operationType}
|
||||
onChange={value => handleFilterChange('operationType', value)}
|
||||
>
|
||||
{OPERATION_TYPE_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col flex="200px">
|
||||
<Select
|
||||
placeholder="选择结果"
|
||||
allowClear
|
||||
style={{ width: '100%', ...selectStyles }}
|
||||
value={filters.result}
|
||||
onChange={value => handleFilterChange('result', value)}
|
||||
>
|
||||
{RESULT_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col flex="auto">
|
||||
<Input
|
||||
placeholder={inputPlaceholders.keyword || '搜索操作描述/对象/操作人'}
|
||||
prefix={<SearchOutlined />}
|
||||
style={{ ...filterInputStyles }}
|
||||
value={filters.keyword}
|
||||
onChange={e => handleFilterChange('keyword', e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col flex="280px">
|
||||
<RangePicker
|
||||
style={{ width: '100%' }}
|
||||
value={filters.dateRange}
|
||||
onChange={dates => handleFilterChange('dateRange', dates)}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => fetchLogs(1, pagination.pageSize, filters)}
|
||||
>
|
||||
筛选
|
||||
</Button>
|
||||
{hasFilters && (
|
||||
<Button
|
||||
icon={<ClearOutlined />}
|
||||
onClick={handleClearFilters}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={logs}
|
||||
loading={loading}
|
||||
rowKey="recordId"
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
title="操作日志详情"
|
||||
placement="right"
|
||||
width={600}
|
||||
onClose={() => {
|
||||
setDetailVisible(false);
|
||||
setCurrentLog(null);
|
||||
}}
|
||||
open={detailVisible}
|
||||
extra={
|
||||
currentLog && (
|
||||
<Space>
|
||||
{getModuleTag(currentLog.module)}
|
||||
{getOperationTag(currentLog.operationType)}
|
||||
{getResultTag(currentLog.result)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
|
||||
) : currentLog ? (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="日志ID">{currentLog.recordId}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作时间">
|
||||
{dayjs(currentLog.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="模块">{currentLog.module}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作类型">{currentLog.operationType}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作描述">{currentLog.operationDescription}</Descriptions.Item>
|
||||
<Descriptions.Item label="目标ID">{currentLog.targetId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="目标名称">{currentLog.targetName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作人ID">{currentLog.operatorId}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作人">{currentLog.operatorName}</Descriptions.Item>
|
||||
{currentLog.operatorRole && (
|
||||
<Descriptions.Item label="操作人角色">{currentLog.operatorRole}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="IP地址">{currentLog.ipAddress || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户代理">{currentLog.userAgent || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="结果">
|
||||
{currentLog.result === 'success' ? '成功' : '失败'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null}
|
||||
|
||||
{currentLog && (currentLog.beforeState || currentLog.afterState) && (
|
||||
<>
|
||||
<h4 style={{ marginTop: 16 }}>状态变更</h4>
|
||||
{currentLog.beforeState && (
|
||||
<>
|
||||
<Text strong>变更前:</Text>
|
||||
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', maxHeight: 200 }}>
|
||||
{JSON.stringify(currentLog.beforeState, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
{currentLog.afterState && (
|
||||
<>
|
||||
<Text strong>变更后:</Text>
|
||||
<pre style={{ background: '#f0f0f0', padding: 12, borderRadius: 4, overflow: 'auto', maxHeight: 200 }}>
|
||||
{JSON.stringify(currentLog.afterState, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentLog && currentLog.metadata && Object.keys(currentLog.metadata).length > 0 && (
|
||||
<>
|
||||
<h4 style={{ marginTop: 16 }}>扩展信息</h4>
|
||||
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', maxHeight: 200 }}>
|
||||
{JSON.stringify(currentLog.metadata, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OperationLogs;
|
||||
Reference in New Issue
Block a user