import React, { useState, useEffect, useRef } from 'react';
import { useDebounce } from '../hooks/useDebounce';
import {
Table,
Card,
Space,
Select,
DatePicker,
Input,
Tag,
Button,
message,
Modal,
Upload,
Radio,
Dropdown,
Form,
Tooltip,
Timeline,
Row,
Col,
Statistic,
Divider,
Popover,
} from 'antd';
import {
HistoryOutlined,
SearchOutlined,
FileTextOutlined,
DownloadOutlined,
UploadOutlined,
FileExcelOutlined,
FileOutlined,
DownOutlined,
EditOutlined,
EyeOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import CloseButton from '../components/CloseButton';
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
import {
inputStyles,
selectStyles,
textAreaStyles,
filterInputStyles,
datePickerStyles,
inputPlaceholders,
inputValidationRules,
} from '../styles/deviceManagementStyles';
const { RangePicker } = DatePicker;
const { Option } = Select;
function ConsumableLogs() {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
const [filters, setFilters] = useState({
operationType: ['in', 'out'],
consumableId: '',
dateRange: null,
});
const debouncedConsumableId = useDebounce(filters.consumableId, 300);
const [importModalVisible, setImportModalVisible] = useState(false);
const [importType, setImportType] = useState('excel');
const [importing, setImporting] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [historyModalVisible, setHistoryModalVisible] = useState(false);
const [currentLog, setCurrentLog] = useState(null);
const [logHistory, setLogHistory] = useState([]);
const [editLoading, setEditLoading] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [form] = Form.useForm();
const fileInputRef = useRef(null);
// 归档详情弹窗状态
const [archiveModalVisible, setArchiveModalVisible] = useState(false);
const [currentArchive, setCurrentArchive] = useState(null);
const [archiveLoading, setArchiveLoading] = useState(false);
const fetchLogs = async (page = 1, pageSize = 10, currentFilters = filters) => {
try {
setLoading(true);
const params = { page, pageSize };
if (
currentFilters.operationType &&
currentFilters.operationType !== 'all' &&
currentFilters.operationType.length > 0
) {
params.operationType = currentFilters.operationType.join(',');
}
if (currentFilters.consumableId) {
params.consumableId = currentFilters.consumableId;
}
if (currentFilters.dateRange) {
params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD');
params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD');
}
const response = await axios.get('/api/consumables/logs', { params });
setLogs(response.data.logs);
setPagination(prev => ({ ...prev, current: page, total: response.data.total }));
} catch (error) {
message.error('获取操作日志失败');
console.error('获取操作日志失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchLogs(1, pagination.pageSize, {
...filters,
consumableId: debouncedConsumableId,
});
}, [debouncedConsumableId, filters.operationType, filters.dateRange]);
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
};
const getOperationTag = type => {
const config = {
in: { color: 'green', text: '入库' },
out: { color: 'red', text: '出库' },
create: { color: 'blue', text: '创建' },
update: { color: 'orange', text: '更新' },
delete: { color: 'magenta', text: '删除' },
adjust: { color: 'purple', text: '调整' },
import: { color: 'cyan', text: '导入' },
};
const { color, text } = config[type] || { color: 'default', text: type };
return {text};
};
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: '耗材ID',
dataIndex: 'consumableId',
key: 'consumableId',
width: 150,
render: value => {value},
},
{
title: '耗材名称',
dataIndex: 'consumableName',
key: 'consumableName',
width: 180,
render: (value, record) => (
分类: {record.consumableSnapshot.category || '-'}
单位: {record.consumableSnapshot.unit || '-'}
单价: {record.consumableSnapshot.unitPrice || '-'}
供应商: {record.consumableSnapshot.supplier || '-'}
位置: {record.consumableSnapshot.location || '-'}
) : null
}
>
{value}
{record.isConsumableDeleted && (
已删除
)}
),
},
{
title: '操作类型',
dataIndex: 'operationType',
key: 'operationType',
width: 120,
render: (type, record) => (
{getOperationTag(type)}
{type === 'delete' && record.relatedId && (
handleViewArchive(record.relatedId)}
>
已归档
)}
),
},
{
title: '变动数量',
dataIndex: 'quantity',
key: 'quantity',
width: 100,
render: (value, record) => (
0 ? '#52c41a' : value < 0 ? '#ff4d4f' : '#888',
fontWeight: 'bold',
}}
>
{value > 0 ? '+' : ''}
{value}
),
},
{
title: '操作前库存',
dataIndex: 'previousStock',
key: 'previousStock',
width: 100,
},
{
title: '操作后库存',
dataIndex: 'currentStock',
key: 'currentStock',
width: 100,
},
{
title: '操作人',
dataIndex: 'operator',
key: 'operator',
width: 120,
},
{
title: '原因',
dataIndex: 'reason',
key: 'reason',
width: 150,
render: value => value || '-',
},
{
title: 'SN序列号',
dataIndex: 'snList',
key: 'snList',
width: 150,
render: (snList, record) => {
const snListArray = Array.isArray(snList) ? snList : [];
if (!snListArray || snListArray.length === 0) {
return '-';
}
if (record.operationType !== 'in' && record.operationType !== 'out') {
return '-';
}
if (snListArray.length <= 3) {
return (
{snListArray.map((sn, index) => (
{sn}
))}
);
}
const content = (
{snListArray.map((sn, index) => (
{sn}
))}
);
return (
{snListArray.length} 个SN 🔍
);
},
},
{
title: '备注',
dataIndex: 'notes',
key: 'notes',
width: 200,
render: value => (
{value || '-'}
),
},
{
title: '操作',
key: 'action',
width: 120,
fixed: 'right',
render: (_, record) => (
{record.isEditable && (
}
onClick={() => handleEdit(record)}
/>
)}
}
onClick={() => handleViewHistory(record)}
/>
),
},
];
const handleExport = async (currentFilters = filters) => {
try {
const params = {};
if (currentFilters.operationType !== 'all') {
params.operationType = currentFilters.operationType;
}
if (currentFilters.consumableId) {
params.consumableId = currentFilters.consumableId;
}
if (currentFilters.dateRange) {
params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD');
params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD');
}
const response = await axios.get('/api/consumables/logs/export', {
params,
responseType: 'blob',
});
const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = `耗材操作日志_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
link.click();
message.success('导出成功');
} catch (error) {
message.error('导出失败');
console.error('导出失败:', error);
}
};
const handleExportExcel = async (currentFilters = filters) => {
try {
const params = {};
if (currentFilters.operationType !== 'all') {
params.operationType = currentFilters.operationType;
}
if (currentFilters.consumableId) {
params.consumableId = currentFilters.consumableId;
}
if (currentFilters.dateRange) {
params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD');
params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD');
}
const response = await axios.get('/api/consumables/logs', {
params: { ...params, page: 1, pageSize: 10000 },
});
const exportData = response.data.logs.map(log => ({
时间: dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss'),
耗材ID: log.consumableId,
耗材名称: log.consumableName,
操作类型: getOperationTypeText(log.operationType),
变动数量: log.quantity,
操作前库存: log.previousStock,
操作后库存: log.currentStock,
操作人: log.operator,
原因: log.reason || '',
备注: log.notes || '',
耗材状态: log.isConsumableDeleted ? '已删除' : '正常',
分类: log.consumableSnapshot?.category || '',
单位: log.consumableSnapshot?.unit || '',
单价: log.consumableSnapshot?.unitPrice || '',
}));
const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '操作日志');
XLSX.writeFile(wb, `耗材操作日志_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`);
message.success('导出Excel成功');
} catch (error) {
message.error('导出Excel失败');
console.error('导出Excel失败:', error);
}
};
const getOperationTypeText = type => {
const map = {
in: '入库',
out: '出库',
create: '创建',
update: '更新',
delete: '删除',
adjust: '调整',
import: '导入',
};
return map[type] || type;
};
const handleImport = async file => {
setImporting(true);
try {
const reader = new FileReader();
reader.onload = async e => {
try {
let logItems = [];
if (importType === 'excel') {
const workbook = XLSX.read(e.target.result, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
logItems = XLSX.utils.sheet_to_json(worksheet);
} else {
const text = e.target.result;
const lines = text.split('\n').filter(line => line.trim());
const headers = lines[0].split(',').map(h => h.replace(/"/g, ''));
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.replace(/"/g, ''));
const item = {};
headers.forEach((h, idx) => {
item[h] = values[idx];
});
logItems.push(item);
}
}
const response = await axios.post('/api/consumables/logs/import', {
logs: logItems,
operator: '前端导入',
});
if (response.data.success > 0) {
message.success(`成功导入 ${response.data.success} 条记录`);
}
if (response.data.failed > 0) {
message.warning(`导入失败 ${response.data.failed} 条`);
response.data.errors.forEach(err => console.error(err));
}
setImportModalVisible(false);
fetchLogs(1, pagination.pageSize);
} catch (err) {
message.error('解析文件失败: ' + err.message);
} finally {
setImporting(false);
}
};
if (importType === 'excel') {
reader.readAsArrayBuffer(file);
} else {
reader.readAsText(file);
}
} catch (error) {
message.error('导入失败');
setImporting(false);
}
return false;
};
const downloadTemplate = () => {
const template = [
{
耗材ID: 'CON123456',
耗材名称: '示例耗材',
操作类型: '入库',
变动数量: 10,
操作前库存: 100,
操作后库存: 110,
操作人: '管理员',
原因: '示例原因',
备注: '示例备注',
},
];
const ws = XLSX.utils.json_to_sheet(template);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '日志模板');
XLSX.writeFile(wb, '耗材操作日志导入模板.xlsx');
message.success('模板下载成功');
};
// 编辑日志
const handleEdit = record => {
setCurrentLog(record);
form.setFieldsValue({
reason: record.reason,
notes: record.notes,
modificationReason: '',
});
setEditModalVisible(true);
};
// 查看归档详情
const handleViewArchive = async archiveId => {
setArchiveModalVisible(true);
setArchiveLoading(true);
try {
const response = await axios.get(`/api/consumables/archives/${archiveId}`);
setCurrentArchive(response.data);
} catch (error) {
message.error('获取归档详情失败');
console.error('获取归档详情失败:', error);
} finally {
setArchiveLoading(false);
}
};
// 提交编辑
const handleEditSubmit = async values => {
if (!currentLog) return;
setEditLoading(true);
try {
const response = await axios.put(`/api/consumables/logs/${currentLog.id}`, {
reason: values.reason,
notes: values.notes,
operator: values.operator || '管理员',
modificationReason: values.modificationReason,
});
message.success('日志修改成功');
setEditModalVisible(false);
fetchLogs(pagination.current, pagination.pageSize);
} catch (error) {
message.error(error.response?.data?.error || '修改失败');
} finally {
setEditLoading(false);
}
};
// 查看修改历史
const handleViewHistory = async record => {
setCurrentLog(record);
setHistoryModalVisible(true);
setHistoryLoading(true);
try {
const response = await axios.get(`/api/consumables/logs/${record.id}/history`);
setLogHistory(response.data.history || []);
} catch (error) {
message.error('获取修改历史失败');
setLogHistory([]);
} finally {
setHistoryLoading(false);
}
};
return (
耗材操作日志
}
>
handleFilterChange('consumableId', e.target.value)}
prefix={}
/>
handleFilterChange('dateRange', dates)}
placeholder={inputPlaceholders.dateRange}
style={datePickerStyles.range}
/>
}
onClick={() => {
setFilters({ operationType: ['in', 'out'], consumableId: '', dateRange: null });
fetchLogs(1, pagination.pageSize);
}}
style={{ height: '40px', borderRadius: '10px' }}
>
重置筛选
,
label: '导出CSV',
onClick: () => handleExport(filters),
},
{
key: 'excel',
icon: ,
label: '导出Excel',
onClick: () => handleExportExcel(filters),
},
],
}}
>
} style={{ height: '40px', borderRadius: '10px' }}>
导出
}
onClick={() => setImportModalVisible(true)}
style={{ height: '40px', borderRadius: '10px' }}
>
导入
`共 ${total} 条记录`,
showSizeChanger: true,
showQuickJumper: true,
}}
onChange={pagination => fetchLogs(pagination.current, pagination.pageSize)}
scroll={{ x: 1500 }}
/>
}
onCancel={() => {
setImportModalVisible(false);
setImportType('excel');
}}
footer={null}
width={500}
>
setImportType(e.target.value)}
style={{ marginBottom: 16 }}
>
Excel文件
CSV文件
(建议先下载模板填写)
} loading={importing}>
选择{importType === 'excel' ? 'Excel' : 'CSV'}文件并导入
注意事项:
- 支持 Excel (.xlsx, .xls) 或 CSV 格式
- 文件列名必须包含:耗材ID、操作类型
- 操作类型支持:入库、出库、创建、更新、删除、调整、导入
- 系统将根据筛选条件过滤需要导出的数据
{/* 编辑日志弹窗 */}
}
onCancel={() => {
setEditModalVisible(false);
form.resetFields();
}}
onOk={() => form.submit()}
confirmLoading={editLoading}
width={600}
>
{/* 修改历史弹窗 */}
}
onCancel={() => {
setHistoryModalVisible(false);
setLogHistory([]);
}}
footer={null}
width={700}
>
{historyLoading ? (
加载中...
) : logHistory.length <= 1 ? (
该记录暂无修改历史
) : (
{logHistory.map((item, index) => (
{getOperationTag(item.operationType).props.children}
{item.modifiedBy && 已修改}
耗材: {item.consumableName} ({item.consumableId})
{item.isConsumableDeleted && (
已删除
)}
操作人: {item.operator}
{item.reason && (
原因: {item.reason}
)}
{item.notes && (
备注: {item.notes}
)}
{item.consumableSnapshot && (
快照信息: 分类:{item.consumableSnapshot.category || '-'} |
单位:{item.consumableSnapshot.unit || '-'} | 单价:
{item.consumableSnapshot.unitPrice || '-'}
)}
{item.modifiedBy && (
<>
修改人: {item.modifiedBy}
修改时间:{' '}
{dayjs(item.modifiedAt).format('YYYY-MM-DD HH:mm:ss')}
{item.modificationReason && (
修改原因: {item.modificationReason}
)}
>
)}
))}
)}
{/* 归档详情弹窗 */}
}
onCancel={() => {
setArchiveModalVisible(false);
setCurrentArchive(null);
}}
footer={null}
width={600}
>
{archiveLoading ? (
加载中...
) : !currentArchive ? (
无法获取归档信息
) : (
归档ID: {currentArchive.archiveId}
耗材ID: {currentArchive.consumableId}
耗材名称: {currentArchive.consumableName}
删除人: {currentArchive.deletedBy}
删除时间:{' '}
{dayjs(currentArchive.deletedAt).format('YYYY-MM-DD HH:mm:ss')}
删除原因: {currentArchive.deleteReason || '-'}
首次操作:{' '}
{currentArchive.firstOperationAt
? dayjs(currentArchive.firstOperationAt).format('YYYY-MM-DD HH:mm:ss')
: '-'}
最后操作:{' '}
{currentArchive.lastOperationAt
? dayjs(currentArchive.lastOperationAt).format('YYYY-MM-DD HH:mm:ss')
: '-'}
删除时库存: {currentArchive.finalStock}
{currentArchive.consumableSnapshot && (
分类: {currentArchive.consumableSnapshot.category || '-'}
单位: {currentArchive.consumableSnapshot.unit || '-'}
单价: {currentArchive.consumableSnapshot.unitPrice || '-'}
供应商: {currentArchive.consumableSnapshot.supplier || '-'}
位置: {currentArchive.consumableSnapshot.location || '-'}
最小库存: {currentArchive.consumableSnapshot.minStock || '-'}
最大库存: {currentArchive.consumableSnapshot.maxStock || '-'}
)}
)}
);
}
export default ConsumableLogs;