feat(consumables): 添加耗材管理功能模块

添加耗材管理相关功能,包括:
1. 后端模型、路由和控制器
2. 前端页面和组件
3. 耗材分类管理
4. 耗材出入库记录
5. 耗材统计和日志功能
6. 集成到现有系统菜单
This commit is contained in:
zhang1106
2025-12-24 16:44:27 +08:00
parent fd3c6f5225
commit 568f9c7db8
21 changed files with 2990 additions and 51 deletions
+94 -50
View File
@@ -1,14 +1,17 @@
import React, { useState } from 'react';
import { Layout, Menu, theme, Button } from 'antd';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined } from '@ant-design/icons';
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined } from '@ant-design/icons';
import Dashboard from './pages/Dashboard';
import DeviceManagement from './pages/DeviceManagement';
import RackManagement from './pages/RackManagement';
import RoomManagement from './pages/RoomManagement';
import DeviceFieldManagement from './pages/DeviceFieldManagement';
import RackVisualization from './pages/RackVisualization';
import ConsumableManagement from './pages/ConsumableManagement';
import ConsumableStatistics from './pages/ConsumableStatistics';
import ConsumableLogs from './pages/ConsumableLogs';
import CategoryManagement from './pages/CategoryManagement';
const { Content, Sider } = Layout;
@@ -22,7 +25,7 @@ function App() {
<Router>
<Layout>
<Sider
width={200}
width={220}
collapsedWidth={80}
collapsed={collapsed}
style={{
@@ -30,7 +33,6 @@ function App() {
boxShadow: '2px 0 8px rgba(0,0,0,0.08)'
}}
>
{/* 自定义收起/展开按钮 */}
<div style={{
display: 'flex',
justifyContent: 'center',
@@ -47,55 +49,93 @@ function App() {
fontSize: 18,
padding: '8px',
borderRadius: 4,
transition: 'all 0.3s',
'&:hover': {
backgroundColor: '#e6f7ff'
}
transition: 'all 0.3s'
}}
/>
</div>
<Menu
mode="inline"
defaultSelectedKeys={['1']}
style={{
height: 'calc(100% - 80px)',
borderRight: 0,
backgroundColor: 'transparent'
}}
items={[
{
key: '1',
icon: <BarChartOutlined />,
label: <Link to="/">仪表盘</Link>,
},
{
key: '2',
icon: <CloudServerOutlined />,
label: <Link to="/devices">设备管理</Link>,
},
{
key: '3',
icon: <DatabaseOutlined />,
label: <Link to="/racks">管理</Link>,
},
{
key: '4',
icon: <DatabaseOutlined />,
label: <Link to="/rooms">管理</Link>,
},
{
key: '5',
icon: <BarChartOutlined />,
label: <Link to="/fields">字段管理</Link>,
},
{
key: '6',
icon: <EyeOutlined />,
label: <Link to="/visualization">机柜可视化</Link>,
},
]}
/>
</Sider>
<Menu
mode="inline"
defaultSelectedKeys={['dashboard']}
style={{
height: 'calc(100% - 80px)',
borderRight: 0,
backgroundColor: 'transparent'
}}
items={[
{
key: 'dashboard',
icon: <BarChartOutlined />,
label: <Link to="/">仪表盘</Link>,
},
{
key: 'room-management',
icon: <HomeOutlined />,
label: '机房管理',
children: [
{
key: 'rooms',
icon: <HomeOutlined />,
label: <Link to="/rooms">管理</Link>,
},
{
key: 'racks',
icon: <DatabaseOutlined />,
label: <Link to="/racks">管理</Link>,
},
{
key: 'visualization',
icon: <EyeOutlined />,
label: <Link to="/visualization">机柜可视化</Link>,
},
],
},
{
key: 'asset-management',
icon: <BuildOutlined />,
label: '资产管理',
children: [
{
key: 'devices',
icon: <CloudServerOutlined />,
label: <Link to="/devices">设备管理</Link>,
},
{
key: 'fields',
icon: <DatabaseOutlined />,
label: <Link to="/fields">字段管理</Link>,
},
],
},
{
key: 'consumables-management',
icon: <ShoppingCartOutlined />,
label: '耗材管理',
children: [
{
key: 'consumables-stats',
icon: <BarChartOutlined />,
label: <Link to="/consumables-stats">耗材统计</Link>,
},
{
key: 'consumables',
icon: <DatabaseOutlined />,
label: <Link to="/consumables">耗材列表</Link>,
},
{
key: 'consumables-categories',
icon: <ImportOutlined />,
label: <Link to="/consumables-categories">分类管理</Link>,
},
{
key: 'consumables-logs',
icon: <FileTextOutlined />,
label: <Link to="/consumables-logs">操作日志</Link>,
},
],
},
]}
/>
</Sider>
<Layout style={{ padding: '0 24px 24px' }}>
<Content
style={{
@@ -113,6 +153,10 @@ function App() {
<Route path="/rooms" element={<RoomManagement />} />
<Route path="/fields" element={<DeviceFieldManagement />} />
<Route path="/visualization" element={<RackVisualization />} />
<Route path="/consumables" element={<ConsumableManagement />} />
<Route path="/consumables-categories" element={<CategoryManagement />} />
<Route path="/consumables-stats" element={<ConsumableStatistics />} />
<Route path="/consumables-logs" element={<ConsumableLogs />} />
</Routes>
</Content>
</Layout>
+215
View File
@@ -0,0 +1,215 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, InputNumber, Select, message, Card, Space, Popconfirm, Tag } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
import axios from 'axios';
const { Option } = Select;
function CategoryManagement() {
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [modalVisible, setModalVisible] = useState(false);
const [editingCategory, setEditingCategory] = useState(null);
const [form] = Form.useForm();
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
showTotal: (total) => `${total} 条记录`
});
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const fetchCategories = async (page = 1, pageSize = 10) => {
try {
setLoading(true);
const response = await axios.get('/api/consumable-categories', {
params: { page, pageSize, keyword, status }
});
setCategories(response.data.categories);
setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total }));
} catch (error) {
message.error('获取分类列表失败');
console.error('获取分类列表失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCategories();
}, [keyword, status]);
const showModal = (category = null) => {
setEditingCategory(category);
if (category) {
form.setFieldsValue(category);
} else {
form.resetFields();
form.setFieldsValue({ status: 'active', sortOrder: 0 });
}
setModalVisible(true);
};
const handleCancel = () => {
setModalVisible(false);
setEditingCategory(null);
};
const handleSubmit = async (values) => {
try {
if (editingCategory) {
await axios.put(`/api/consumable-categories/${editingCategory.id}`, values);
message.success('分类更新成功');
} else {
await axios.post('/api/consumable-categories', values);
message.success('分类创建成功');
}
setModalVisible(false);
fetchCategories();
setEditingCategory(null);
} catch (error) {
message.error(error.response?.data?.error || (editingCategory ? '分类更新失败' : '分类创建失败'));
console.error('提交失败:', error);
}
};
const handleDelete = async (id) => {
try {
await axios.delete(`/api/consumable-categories/${id}`);
message.success('删除成功');
fetchCategories();
} catch (error) {
message.error(error.response?.data?.error || '删除失败');
console.error('删除失败:', error);
}
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80
},
{
title: '分类名称',
dataIndex: 'name',
key: 'name',
width: 150
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
width: 200,
render: (value) => value || '-'
},
{
title: '排序',
dataIndex: 'sortOrder',
key: 'sortOrder',
width: 80
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (value) => (
<Tag color={value === 'active' ? 'green' : 'red'}>
{value === 'active' ? '启用' : '停用'}
</Tag>
)
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (value) => value ? new Date(value).toLocaleString('zh-CN') : '-'
},
{
title: '操作',
key: 'action',
width: 150,
render: (_, record) => (
<Space>
<Button type="primary" icon={<EditOutlined />} size="small" onClick={() => showModal(record)}>编辑</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record.id)}>
<Button danger icon={<DeleteOutlined />} size="small">删除</Button>
</Popconfirm>
</Space>
)
}
];
return (
<div>
<Card title="耗材分类管理" extra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>添加分类</Button>
}>
<Card size="small" style={{ marginBottom: 16 }}>
<Space>
<Input.Search
placeholder="搜索分类名称、描述"
style={{ width: 300 }}
onSearch={(value) => setKeyword(value)}
allowClear
/>
<Select value={status} onChange={setStatus} style={{ width: 120 }}>
<Option value="all">所有状态</Option>
<Option value="active">启用</Option>
<Option value="inactive">停用</Option>
</Select>
<Button onClick={() => fetchCategories()}>刷新</Button>
</Space>
</Card>
<Table
columns={columns}
dataSource={categories}
rowKey="id"
loading={loading}
pagination={pagination}
onChange={(pagination) => fetchCategories(pagination.current, pagination.pageSize)}
scroll={{ x: 1000 }}
/>
</Card>
<Modal
title={editingCategory ? '编辑分类' : '添加分类'}
open={modalVisible}
onCancel={handleCancel}
footer={null}
width={500}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入分类名称' }, { max: 50, message: '分类名称不能超过50个字符' }]}>
<Input placeholder="请输入分类名称" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="请输入分类描述" maxLength={200} showCount />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} placeholder="数值越小越靠前" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
<Select>
<Option value="active">启用</Option>
<Option value="inactive">停用</Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">{editingCategory ? '更新' : '创建'}</Button>
<Button onClick={handleCancel}>取消</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default CategoryManagement;
+469
View File
@@ -0,0 +1,469 @@
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;
+611
View File
@@ -0,0 +1,611 @@
import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Modal, Form, Input, Select, InputNumber, message, Card, Space, Popconfirm, Upload, Table as AntTable } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ExportOutlined, ImportOutlined, UploadOutlined, FileExcelOutlined, InboxOutlined } from '@ant-design/icons';
import axios from 'axios';
const { Option } = Select;
function ConsumableManagement() {
const [consumables, setConsumables] = useState([]);
const [loading, setLoading] = useState(true);
const [modalVisible, setModalVisible] = useState(false);
const [editingConsumable, setEditingConsumable] = useState(null);
const [form] = Form.useForm();
const [categories, setCategories] = useState([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
showTotal: (total) => `${total} 条记录`
});
const [keyword, setKeyword] = useState('');
const [category, setCategory] = useState('all');
const [status, setStatus] = useState('all');
const [importModalVisible, setImportModalVisible] = useState(false);
const [importPreview, setImportPreview] = useState([]);
const [importFile, setImportFile] = useState(null);
const [importing, setImporting] = useState(false);
const importFormRef = useRef(null);
const [stockModalVisible, setStockModalVisible] = useState(false);
const [stockRecord, setStockRecord] = useState(null);
const [stockType, setStockType] = useState('in');
const [stockForm] = Form.useForm();
const fetchConsumables = async (page = 1, pageSize = 10) => {
try {
setLoading(true);
const response = await axios.get('/api/consumables', {
params: { page, pageSize, keyword, category, status }
});
setConsumables(response.data.consumables);
setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total }));
} catch (error) {
message.error('获取耗材列表失败');
console.error('获取耗材列表失败:', error);
} finally {
setLoading(false);
}
};
const fetchCategories = async () => {
try {
const response = await axios.get('/api/consumable-categories/list');
setCategories(response.data);
} catch (error) {
console.error('获取分类列表失败:', error);
}
};
useEffect(() => {
fetchConsumables();
fetchCategories();
}, [keyword, category, status]);
const showModal = (consumable = null) => {
setEditingConsumable(consumable);
if (consumable) {
form.setFieldsValue(consumable);
} else {
form.resetFields();
}
setModalVisible(true);
};
const handleCancel = () => {
setModalVisible(false);
setEditingConsumable(null);
};
const handleSubmit = async (values) => {
try {
if (editingConsumable) {
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, values);
message.success('耗材更新成功');
} else {
await axios.post('/api/consumables', {
...values,
consumableId: `CON${Date.now()}`
});
message.success('耗材创建成功');
}
setModalVisible(false);
fetchConsumables();
setEditingConsumable(null);
} catch (error) {
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
console.error('提交失败:', error);
}
};
const handleDelete = async (consumableId) => {
try {
await axios.delete(`/api/consumables/${consumableId}`);
message.success('删除成功');
fetchConsumables();
} catch (error) {
message.error('删除失败');
console.error('删除失败:', error);
}
};
const handleSearch = (value) => {
setKeyword(value);
};
const exportToCSV = (data, filename) => {
const headers = ['耗材ID', '名称', '分类', '单位', '当前库存', '最小库存', '最大库存', '单价', '供应商', '存放位置', '状态'];
const keys = ['consumableId', 'name', 'category', 'unit', 'currentStock', 'minStock', 'maxStock', 'unitPrice', 'supplier', 'location', 'status'];
const csvContent = [
headers.join(','),
...data.map(row => keys.map(key => {
let value = row[key];
if (key === 'unitPrice') value = `¥${parseFloat(value || 0).toFixed(2)}`;
if (key === 'status') value = value === 'active' ? '启用' : '停用';
if (value === null || value === undefined) value = '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}).join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
const handleExport = async () => {
try {
const response = await axios.get('/api/consumables', {
params: { keyword, category, status, pageSize: 1000 }
});
const consumables = response.data.consumables;
exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`);
message.success('导出成功');
} catch (error) {
message.error('导出失败');
console.error('导出失败:', error);
}
};
const parseCSV = (text) => {
const lines = text.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
const data = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
let values = [];
let inQuotes = false;
let current = '';
for (let j = 0; j < line.length; j++) {
const char = line[j];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
values.push(current.trim().replace(/^"|"$/g, ''));
current = '';
} else {
current += char;
}
}
values.push(current.trim().replace(/^"|"$/g, ''));
const row = {};
headers.forEach((header, idx) => {
row[header] = values[idx] || '';
});
data.push(row);
}
return data;
};
const handleFileChange = (info) => {
const file = info.fileList[info.fileList.length - 1];
if (file && file.originFileObj) {
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target.result;
const parsedData = parseCSV(text);
setImportPreview(parsedData.slice(0, 10));
setImportFile(file.originFileObj);
};
reader.readAsText(file.originFileObj);
}
};
const showImportModal = () => {
setImportPreview([]);
setImportFile(null);
setImportModalVisible(true);
};
const handleImportCancel = () => {
setImportModalVisible(false);
setImportPreview([]);
setImportFile(null);
};
const handleImport = async () => {
if (!importFile) {
message.warning('请先选择文件');
return;
}
setImporting(true);
try {
const reader = new FileReader();
reader.onload = async (e) => {
const text = e.target.result;
const items = parseCSV(text);
const response = await axios.post('/api/consumables/import', { items });
message.success(response.data.message);
if (response.data.results.failed > 0) {
response.data.results.errors.forEach(err => console.error(err));
}
setImportModalVisible(false);
setImportPreview([]);
setImportFile(null);
fetchConsumables();
setImporting(false);
};
reader.readAsText(importFile);
} catch (error) {
message.error('导入失败');
console.error('导入失败:', error);
setImporting(false);
}
};
const downloadTemplate = () => {
const template = '耗材ID,名称,分类,单位,当前库存,最小库存,最大库存,单价,供应商,存放位置,描述,状态\n,测试耗材,办公用品,个,100,10,500,5.00,XX公司,A柜-01层,测试数据,active';
const blob = new Blob([template], { type: 'text/csv;charset=utf-8;' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = '耗材导入模板.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
const showStockModal = (record, type) => {
setStockRecord(record);
setStockType(type);
stockForm.setFieldsValue({
consumableId: record.consumableId,
consumableName: record.name,
quantity: 1,
reason: '',
notes: ''
});
setStockModalVisible(true);
};
const handleStockCancel = () => {
setStockModalVisible(false);
setStockRecord(null);
};
const handleStockSubmit = async (values) => {
try {
const response = await axios.post('/api/consumables/quick-inout', {
consumableId: stockRecord.consumableId,
type: stockType,
quantity: values.quantity,
operator: values.operator || '系统管理员',
reason: values.reason,
notes: values.notes
});
message.success(`${stockType === 'in' ? '入库' : '出库'}操作成功`);
setStockModalVisible(false);
fetchConsumables();
} catch (error) {
message.error(error.response?.data?.error || `${stockType === 'in' ? '入库' : '出库'}操作失败`);
console.error('操作失败:', error);
}
};
const columns = [
{
title: '耗材ID',
dataIndex: 'consumableId',
key: 'consumableId',
width: 150
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 150
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120
},
{
title: '单位',
dataIndex: 'unit',
key: 'unit',
width: 80
},
{
title: '当前库存',
dataIndex: 'currentStock',
key: 'currentStock',
width: 100,
render: (value, record) => {
const isLow = value <= record.minStock;
return (
<span style={{ color: isLow ? '#ff4d4f' : '#52c41a', fontWeight: 'bold' }}>
{value}
</span>
);
}
},
{
title: '最小库存',
dataIndex: 'minStock',
key: 'minStock',
width: 100
},
{
title: '最大库存',
dataIndex: 'maxStock',
key: 'maxStock',
width: 100
},
{
title: '单价(元)',
dataIndex: 'unitPrice',
key: 'unitPrice',
width: 100,
render: (value) => `¥${parseFloat(value || 0).toFixed(2)}`
},
{
title: '供应商',
dataIndex: 'supplier',
key: 'supplier',
width: 150,
render: (value) => value || '-'
},
{
title: '位置',
dataIndex: 'location',
key: 'location',
width: 120,
render: (value) => value || '-'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (value) => (
<span style={{ color: value === 'active' ? '#52c41a' : '#ff4d4f' }}>
{value === 'active' ? '启用' : '停用'}
</span>
)
},
{
title: '操作',
key: 'action',
width: 200,
render: (_, record) => (
<Space>
<Button type="primary" icon={<EditOutlined />} size="small" onClick={() => showModal(record)}>编辑</Button>
<Button type="default" icon={<InboxOutlined />} size="small" style={{ background: '#f6ffed', borderColor: '#b7eb8f', color: '#52c41a' }} onClick={() => showStockModal(record, 'in')}>入库</Button>
<Button type="default" icon={<ExportOutlined />} size="small" style={{ background: '#fff2f0', borderColor: '#ffccc7', color: '#ff4d4f' }} onClick={() => showStockModal(record, 'out')}>出库</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record.consumableId)}>
<Button danger icon={<DeleteOutlined />} size="small">删除</Button>
</Popconfirm>
</Space>
)
}
];
const previewColumns = [
{ title: '名称', dataIndex: '名称', key: 'name', width: 120 },
{ title: '分类', dataIndex: '分类', key: 'category', width: 100 },
{ title: '单位', dataIndex: '单位', key: 'unit', width: 80 },
{ title: '当前库存', dataIndex: '当前库存', key: 'currentStock', width: 90 },
{ title: '单价', dataIndex: '单价', key: 'unitPrice', width: 80 }
];
return (
<div>
<Card title="耗材管理" extra={
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>添加耗材</Button>
<Button icon={<ImportOutlined />} onClick={showImportModal}>导入</Button>
<Button icon={<ExportOutlined />} onClick={handleExport}>导出</Button>
</Space>
}>
<Card size="small" style={{ marginBottom: 16 }}>
<Space>
<Input.Search
placeholder="搜索耗材ID、名称、分类、供应商"
style={{ width: 300 }}
onSearch={handleSearch}
allowClear
/>
<Select value={category} onChange={setCategory} style={{ width: 150 }}>
<Option value="all">所有分类</Option>
{categories.map(cat => (
<Option key={cat.id} value={cat.name}>{cat.name}</Option>
))}
</Select>
<Select value={status} onChange={setStatus} style={{ width: 120 }}>
<Option value="all">所有状态</Option>
<Option value="active">启用</Option>
<Option value="inactive">停用</Option>
</Select>
</Space>
</Card>
<Table
columns={columns}
dataSource={consumables}
rowKey="consumableId"
loading={loading}
pagination={pagination}
onChange={(pagination) => fetchConsumables(pagination.current, pagination.pageSize)}
scroll={{ x: 1300 }}
/>
</Card>
<Modal
title={editingConsumable ? '编辑耗材' : '添加耗材'}
open={modalVisible}
onCancel={handleCancel}
footer={null}
width={600}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="请输入耗材名称" />
</Form.Item>
<Form.Item name="category" label="分类" rules={[{ required: true, message: '请选择分类' }]}>
<Select
placeholder="请选择分类"
allowClear
>
{categories.map(cat => (
<Option key={cat.id} value={cat.name}>{cat.name}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="unit" label="单位" rules={[{ required: true, message: '请输入单位' }]} initialValue="个">
<Input placeholder="如: 个、盒、卷、箱" />
</Form.Item>
<Space style={{ width: '100%' }}>
<Form.Item name="currentStock" label="当前库存" rules={[{ required: true, message: '请输入当前库存' }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="minStock" label="最小库存" rules={[{ required: true, message: '请输入最小库存' }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="maxStock" label="最大库存" rules={[{ required: true, message: '请输入最大库存' }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Space>
<Form.Item name="unitPrice" label="单价(元)">
<InputNumber min={0} step={0.01} precision={2} style={{ width: '100%' }} placeholder="请输入单价" />
</Form.Item>
<Form.Item name="supplier" label="供应商">
<Input placeholder="请输入供应商" />
</Form.Item>
<Form.Item name="location" label="存放位置">
<Input placeholder="如: A柜-01层、B区货架3" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="请输入描述信息" />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
<Select>
<Option value="active">启用</Option>
<Option value="inactive">停用</Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">{editingConsumable ? '更新' : '创建'}</Button>
<Button onClick={handleCancel}>取消</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title="导入耗材"
open={importModalVisible}
onCancel={handleImportCancel}
footer={null}
width={700}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Card size="small" style={{ background: '#f5f5f5' }}>
<Space>
<Button icon={<FileExcelOutlined />} onClick={downloadTemplate}>下载模板</Button>
<span style={{ color: '#888', fontSize: 12 }}>请下载模板后填写数据再导入</span>
</Space>
</Card>
<Upload
accept=".csv"
maxCount={1}
beforeUpload={() => false}
onChange={handleFileChange}
>
<Button icon={<UploadOutlined />}>选择CSV文件</Button>
</Upload>
{importPreview.length > 0 && (
<div>
<div style={{ marginBottom: 8, fontWeight: 'bold' }}>预览 (前10条):</div>
<Table
columns={previewColumns}
dataSource={importPreview}
rowKey={(record, index) => index}
pagination={false}
size="small"
scroll={{ x: 500 }}
/>
</div>
)}
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={handleImportCancel}>取消</Button>
<Button
type="primary"
onClick={handleImport}
loading={importing}
disabled={!importFile}
>
开始导入
</Button>
</Space>
</div>
</Space>
</Modal>
<Modal
title={stockType === 'in' ? '耗材入库' : '耗材出库'}
open={stockModalVisible}
onCancel={handleStockCancel}
footer={null}
width={500}
>
<Form form={stockForm} layout="vertical" onFinish={handleStockSubmit}>
<Form.Item name="consumableId" label="耗材ID">
<Input disabled />
</Form.Item>
<Form.Item name="consumableName" label="耗材名称">
<Input disabled />
</Form.Item>
<Form.Item name="quantity" label="数量" rules={[{ required: true, message: '请输入数量' }]}>
<InputNumber min={1} style={{ width: '100%' }} placeholder="请输入数量" />
</Form.Item>
<Form.Item name="operator" label="操作人">
<Input placeholder="请输入操作人姓名" />
</Form.Item>
<Form.Item name="reason" label="入库/出库原因">
<Input placeholder="如: 采购入库、部门领用、报损出库" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder="请输入备注信息" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">{stockType === 'in' ? '确认入库' : '确认出库'}</Button>
<Button onClick={handleStockCancel}>取消</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default ConsumableManagement;
+289
View File
@@ -0,0 +1,289 @@
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Statistic, Table, Tag, DatePicker, Space, Select, Progress, message } from 'antd';
import { InboxOutlined, ExportOutlined, WarningOutlined, DollarOutlined, ShoppingCartOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
const { RangePicker } = DatePicker;
function ConsumableStatistics() {
const [summary, setSummary] = useState({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] });
const [lowStockItems, setLowStockItems] = useState([]);
const [stats, setStats] = useState({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [] });
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState([]);
const fetchSummary = async () => {
try {
const response = await axios.get('/api/consumables/statistics/summary');
setSummary(response.data);
} catch (error) {
message.error('获取统计信息失败');
}
};
const fetchLowStock = async () => {
try {
const response = await axios.get('/api/consumables/low-stock');
setLowStockItems(response.data);
} catch (error) {
message.error('获取低库存信息失败');
}
};
const fetchInOutStats = async () => {
try {
const params = {};
if (dateRange && dateRange.length === 2) {
params.startDate = dateRange[0].toISOString();
params.endDate = dateRange[1].toISOString();
}
const response = await axios.get('/api/consumable-records/statistics', { params });
setStats(response.data);
} catch (error) {
message.error('获取出入库统计失败');
}
};
useEffect(() => {
fetchSummary();
fetchLowStock();
fetchInOutStats();
}, []);
useEffect(() => {
fetchInOutStats();
}, [dateRange]);
const lowStockColumns = [
{
title: '耗材名称',
dataIndex: 'name',
key: 'name',
width: 150
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120
},
{
title: '当前库存',
dataIndex: 'currentStock',
key: 'currentStock',
width: 100,
render: (value) => (
<span style={{ color: '#ff4d4f', fontWeight: 'bold' }}>{value}</span>
)
},
{
title: '最小库存',
dataIndex: 'minStock',
key: 'minStock',
width: 100
},
{
title: '单位',
dataIndex: 'unit',
key: 'unit',
width: 80
},
{
title: '库存充足率',
key: 'rate',
width: 150,
render: (_, record) => {
const rate = Math.min(100, Math.round((record.currentStock / record.maxStock) * 100));
const status = rate < 30 ? 'exception' : rate < 60 ? 'active' : 'success';
return <Progress percent={rate} size="small" status={status} />;
}
},
{
title: '供应商',
dataIndex: 'supplier',
key: 'supplier',
width: 150,
render: (value) => value || '-'
}
];
const recentColumns = [
{
title: '时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss')
},
{
title: '耗材名称',
dataIndex: ['Consumable', 'name'],
key: 'consumableName',
width: 150
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 100,
render: (type) => (
<Tag color={type === 'in' ? 'green' : 'red'}>
{type === 'in' ? '入库' : '出库'}
</Tag>
)
},
{
title: '数量',
dataIndex: 'quantity',
key: 'quantity',
width: 100,
render: (value, record) => (
<span style={{ color: record.type === 'in' ? '#52c41a' : '#ff4d4f' }}>
{record.type === 'in' ? '+' : '-'}{value}
</span>
)
},
{
title: '操作人',
dataIndex: 'operator',
key: 'operator',
width: 120
},
{
title: '原因',
dataIndex: 'reason',
key: 'reason',
width: 150,
render: (value) => value || '-'
}
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic
title="耗材种类总数"
value={summary.total}
prefix={<ShoppingCartOutlined style={{ color: '#1890ff' }} />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="低库存预警"
value={summary.lowStock}
prefix={<WarningOutlined style={{ color: summary.lowStock > 0 ? '#ff4d4f' : '#52c41a' }} />}
valueStyle={{ color: summary.lowStock > 0 ? '#ff4d4f' : '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="库存总价值"
value={parseFloat(summary.totalValue || 0)}
prefix={<DollarOutlined style={{ color: '#52c41a' }} />}
precision={2}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="净入库量"
value={stats.inQuantity - stats.outQuantity}
prefix={<InboxOutlined style={{ color: stats.inQuantity - stats.outQuantity >= 0 ? '#52c41a' : '#ff4d4f' }} />}
valueStyle={{ color: stats.inQuantity - stats.outQuantity >= 0 ? '#52c41a' : '#ff4d4f' }}
/>
</Card>
</Col>
</Row>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={12}>
<Card title="入库出库统计" extra={
<RangePicker value={dateRange} onChange={setDateRange} />
}>
<Row gutter={16}>
<Col span={12}>
<Card size="small" style={{ background: '#f6ffed', borderColor: '#b7eb8f' }}>
<Statistic
title="入库次数"
value={stats.inCount}
prefix={<InboxOutlined style={{ color: '#52c41a' }} />}
/>
<div style={{ marginTop: 8, color: '#52c41a', fontSize: 20, fontWeight: 'bold' }}>
+{stats.inQuantity}
</div>
</Card>
</Col>
<Col span={12}>
<Card size="small" style={{ background: '#fff1f0', borderColor: '#ffa39e' }}>
<Statistic
title="出库次数"
value={stats.outCount}
prefix={<ExportOutlined style={{ color: '#ff4d4f' }} />}
/>
<div style={{ marginTop: 8, color: '#ff4d4f', fontSize: 20, fontWeight: 'bold' }}>
-{stats.outQuantity}
</div>
</Card>
</Col>
</Row>
</Card>
</Col>
<Col span={12}>
<Card title="分类统计">
<Space wrap>
{summary.byCategory?.map(item => (
<Card size="small" key={item.category} style={{ width: 140 }}>
<Statistic
title={item.category}
value={item.count}
valueStyle={{ fontSize: 24 }}
/>
</Card>
))}
</Space>
</Card>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Card
title={<span><ExclamationCircleOutlined style={{ color: '#ff4d4f', marginRight: 8 }} />低库存预警</span>}
extra={<Tag color="red">{lowStockItems.length}</Tag>}
>
<Table
columns={lowStockColumns}
dataSource={lowStockItems}
rowKey="consumableId"
pagination={false}
size="small"
scroll={{ x: 800 }}
/>
</Card>
</Col>
<Col span={12}>
<Card title="最近出入库记录">
<Table
columns={recentColumns}
dataSource={stats.recentRecords}
rowKey="recordId"
pagination={false}
size="small"
scroll={{ x: 900 }}
/>
</Card>
</Col>
</Row>
</div>
);
}
export default ConsumableStatistics;