Files
yunrui_asset/frontend/src/pages/ConsumableLogs.jsx
T
zhang1106 568f9c7db8 feat(consumables): 添加耗材管理功能模块
添加耗材管理相关功能,包括:
1. 后端模型、路由和控制器
2. 前端页面和组件
3. 耗材分类管理
4. 耗材出入库记录
5. 耗材统计和日志功能
6. 集成到现有系统菜单
2025-12-24 16:44:27 +08:00

470 lines
15 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 } from 'react';
import { Table, Card, Space, Select, DatePicker, Input, Tag, Button, message, Modal, Upload, Radio, Dropdown } from 'antd';
import { HistoryOutlined, SearchOutlined, FileTextOutlined, DownloadOutlined, UploadOutlined, FileExcelOutlined, FileOutlined, DownOutlined } from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
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: 'all',
consumableId: '',
dateRange: null
});
const [importModalVisible, setImportModalVisible] = useState(false);
const [importType, setImportType] = useState('excel');
const [importing, setImporting] = useState(false);
const fileInputRef = useRef(null);
const fetchLogs = async (page = 1, pageSize = 10, currentFilters = filters) => {
try {
setLoading(true);
const params = { page, pageSize };
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 });
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);
}, [filters.operationType, filters.consumableId, filters.dateRange]);
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
fetchLogs(1, pagination.pageSize);
};
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 <Tag color={color}>{text}</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: '耗材ID',
dataIndex: 'consumableId',
key: 'consumableId',
width: 150,
render: (value) => <code>{value}</code>
},
{
title: '耗材名称',
dataIndex: 'consumableName',
key: 'consumableName',
width: 150
},
{
title: '操作类型',
dataIndex: 'operationType',
key: 'operationType',
width: 100,
render: (type) => getOperationTag(type)
},
{
title: '变动数量',
dataIndex: 'quantity',
key: 'quantity',
width: 100,
render: (value, record) => (
<span style={{
color: value > 0 ? '#52c41a' : value < 0 ? '#ff4d4f' : '#888',
fontWeight: 'bold'
}}>
{value > 0 ? '+' : ''}{value}
</span>
)
},
{
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: '备注',
dataIndex: 'notes',
key: 'notes',
width: 200,
render: (value) => value || '-',
ellipsis: true
}
];
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 || ''
}));
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('模板下载成功');
};
return (
<div>
<Card
title={
<Space>
<FileTextOutlined />
<span>耗材操作日志</span>
</Space>
}
>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<Input.Search
placeholder="搜索耗材ID"
style={{ width: 200 }}
allowClear
onSearch={(value) => handleFilterChange('consumableId', value)}
prefix={<SearchOutlined />}
/>
<Select
value={filters.operationType}
onChange={(value) => handleFilterChange('operationType', value)}
style={{ width: 120 }}
>
<Option value="all">全部类型</Option>
<Option value="in">入库</Option>
<Option value="out">出库</Option>
<Option value="create">创建</Option>
<Option value="update">更新</Option>
<Option value="delete">删除</Option>
<Option value="adjust">调整</Option>
<Option value="import">导入</Option>
</Select>
<RangePicker
value={filters.dateRange}
onChange={(dates) => handleFilterChange('dateRange', dates)}
placeholder={['开始日期', '结束日期']}
/>
<Button
icon={<HistoryOutlined />}
onClick={() => {
setFilters({ operationType: 'all', consumableId: '', dateRange: null });
fetchLogs(1, pagination.pageSize);
}}
>
重置筛选
</Button>
<Dropdown
menu={{
items: [
{
key: 'csv',
icon: <FileOutlined />,
label: '导出CSV',
onClick: () => handleExport(filters)
},
{
key: 'excel',
icon: <FileExcelOutlined />,
label: '导出Excel',
onClick: () => handleExportExcel(filters)
}
]
}}
>
<Button icon={<DownloadOutlined />}>
导出 <DownOutlined />
</Button>
</Dropdown>
<Button icon={<UploadOutlined />} onClick={() => setImportModalVisible(true)}>
导入
</Button>
</Space>
</Card>
<Table
columns={columns}
dataSource={logs}
rowKey="id"
loading={loading}
pagination={{
...pagination,
showTotal: (total) => `共 ${total} 条记录`,
showSizeChanger: true,
showQuickJumper: true
}}
onChange={(pagination) => fetchLogs(pagination.current, pagination.pageSize)}
scroll={{ x: 1500 }}
/>
</Card>
<Modal
title="导入操作日志"
open={importModalVisible}
onCancel={() => {
setImportModalVisible(false);
setImportType('excel');
}}
footer={null}
width={500}
>
<div style={{ marginBottom: 16 }}>
<Radio.Group
value={importType}
onChange={(e) => setImportType(e.target.value)}
style={{ marginBottom: 16 }}
>
<Radio.Button value="excel">Excel文件</Radio.Button>
<Radio.Button value="csv">CSV文件</Radio.Button>
</Radio.Group>
</div>
<div style={{ marginBottom: 16 }}>
<Button type="link" onClick={downloadTemplate}>
下载导入模板
</Button>
<span style={{ color: '#888', marginLeft: 8 }}>建议先下载模板填写</span>
</div>
<Upload
accept={importType === 'excel' ? '.xlsx,.xls' : '.csv'}
showUploadList={false}
beforeUpload={handleImport}
>
<Button type="primary" icon={<UploadOutlined />} loading={importing}>
选择{importType === 'excel' ? 'Excel' : 'CSV'}文件并导入
</Button>
</Upload>
<div style={{ marginTop: 16, color: '#888', fontSize: 12 }}>
<p>注意事项</p>
<ul>
<li>支持 Excel (.xlsx, .xls) CSV 格式</li>
<li>文件列名必须包含耗材ID操作类型</li>
<li>操作类型支持入库出库创建更新删除调整导入</li>
<li>系统将根据筛选条件过滤需要导出的数据</li>
</ul>
</div>
</Modal>
</div>
);
}
export default ConsumableLogs;