feat: 添加操作日志、危险操作确认和业务关联功能

1. 新增操作日志记录功能,记录关键操作
2. 实现危险操作确认对话框,防止误删
3. 添加业务和库房管理模块
4. 支持设备标记为空闲状态
5. 完善API文档和健康检查
6. 优化前端删除操作的确认流程
7. 添加Swagger API文档支持
8. 实现设备与业务的关联功能
9. 改进设备模型,添加空闲相关字段
10. 优化用户、角色管理操作日志
This commit is contained in:
zhang1106
2026-03-20 17:15:14 +08:00
parent c392df9ce3
commit 73cbe4ac1b
52 changed files with 11149 additions and 1756 deletions
+482
View File
@@ -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;