import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useDebounce } from '../hooks/useDebounce';
import {
Table,
Button,
Modal,
Form,
Input,
Select,
InputNumber,
message,
Card,
Space,
Popconfirm,
Popover,
Upload,
Progress,
Checkbox,
Row,
Col,
Badge,
Tag,
Tooltip,
Empty,
Skeleton,
Alert,
Typography,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
ExportOutlined,
ImportOutlined,
UploadOutlined,
FileExcelOutlined,
ShoppingOutlined,
FilterOutlined,
ClearOutlined,
ReloadOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { motion, AnimatePresence } from 'framer-motion';
const { Option } = Select;
const { Text, Title } = Typography;
const { TextArea } = Input;
// 设计令牌 - 与接线/端口管理页面保持一致
const designTokens = {
colors: {
primary: {
main: '#6366f1',
gradient: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
light: '#818cf8',
dark: '#4f46e5',
bg: '#eef2ff',
},
success: {
main: '#10b981',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
light: '#34d399',
dark: '#047857',
bg: '#ecfdf5',
},
warning: {
main: '#f59e0b',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
light: '#fbbf24',
dark: '#b45309',
bg: '#fffbeb',
},
error: {
main: '#ef4444',
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
light: '#f87171',
dark: '#b91c1c',
bg: '#fef2f2',
},
info: {
main: '#3b82f6',
bg: '#eff6ff',
},
neutral: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
},
borderRadius: {
sm: '6px',
md: '10px',
lg: '16px',
xl: '20px',
},
shadows: {
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
},
};
// 动画配置
const animations = {
container: {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
delayChildren: 0.1,
},
},
},
item: {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
},
};
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 debouncedKeyword = useDebounce(keyword, 300);
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 [importProgress, setImportProgress] = useState(0);
const [importPhase, setImportPhase] = useState('');
const [importResult, setImportResult] = useState(null);
const [stockModalVisible, setStockModalVisible] = useState(false);
const [stockRecord, setStockRecord] = useState(null);
const [stockType, setStockType] = useState('in');
const [stockForm] = Form.useForm();
const [maxStockUnlimited, setMaxStockUnlimited] = useState(false);
const [snList, setSnList] = useState([]);
const [snInputVisible, setSnInputVisible] = useState(false);
const [snInputValue, setSnInputValue] = useState('');
const [selectedSnList, setSelectedSnList] = useState([]);
const [snSearchKeyword, setSnSearchKeyword] = useState('');
const fetchConsumables = useCallback(
async (page = 1, pageSize = 10) => {
try {
setLoading(true);
const response = await axios.get('/api/consumables', {
params: { page, pageSize, keyword: debouncedKeyword, category, status },
});
const data = response.data.consumables || [];
setConsumables(data);
setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total }));
} catch (error) {
message.error('获取耗材列表失败');
console.error('获取耗材列表失败:', error);
} finally {
setLoading(false);
}
},
[debouncedKeyword, category, status]
);
const fetchCategories = useCallback(async () => {
try {
const response = await axios.get('/api/consumable-categories/list');
setCategories(response.data || []);
} catch (error) {
console.error('获取分类列表失败:', error);
}
}, []);
useEffect(() => {
fetchConsumables();
fetchCategories();
}, [fetchConsumables, fetchCategories]);
const showModal = useCallback(
(consumable = null) => {
setEditingConsumable(consumable);
if (consumable) {
const isUnlimited =
consumable.maxStock === 0 ||
consumable.maxStock === null ||
consumable.maxStock === undefined;
setMaxStockUnlimited(isUnlimited);
setSnList(consumable.snList || []);
form.setFieldsValue({
...consumable,
maxStock: isUnlimited ? undefined : consumable.maxStock,
});
} else {
setMaxStockUnlimited(true);
setSnList([]);
form.resetFields();
form.setFieldsValue({
unit: '个',
currentStock: 0,
minStock: 0,
status: 'active',
unitPrice: 0,
});
}
setModalVisible(true);
},
[form]
);
const handleCancel = useCallback(() => {
setModalVisible(false);
setEditingConsumable(null);
setSnList([]);
setSnInputVisible(false);
setSnInputValue('');
}, []);
const handleSubmit = useCallback(
async values => {
try {
const submitData = {
...values,
maxStock: maxStockUnlimited ? 0 : values.maxStock,
unitPrice: values.unitPrice || 0,
snList: snList,
};
if (editingConsumable) {
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData);
message.success({
content: '耗材更新成功',
icon: ,
});
} else {
await axios.post('/api/consumables', {
...submitData,
consumableId: `CON${Date.now()}`,
});
message.success({
content: '耗材创建成功',
icon: ,
});
}
setModalVisible(false);
fetchConsumables();
setEditingConsumable(null);
setSnList([]);
} catch (error) {
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
console.error('提交失败:', error);
}
},
[editingConsumable, fetchConsumables, maxStockUnlimited, snList]
);
const handleDelete = useCallback(
async consumableId => {
try {
await axios.delete(`/api/consumables/${consumableId}`);
message.success({
content: '删除成功',
icon: ,
});
fetchConsumables();
} catch (error) {
message.error('删除失败');
console.error('删除失败:', error);
}
},
[fetchConsumables]
);
const handleSearch = useCallback(value => {
setKeyword(value);
}, []);
const handleReset = () => {
setKeyword('');
setCategory('all');
setStatus('all');
};
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({
content: '导出成功',
icon: ,
});
} 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);
setImportProgress(0);
setImportPhase('');
setImportResult(null);
};
const handleImport = async () => {
if (!importFile) {
message.warning('请先选择文件');
return;
}
setImporting(true);
setImportProgress(0);
setImportPhase('正在读取文件...');
setImportResult(null);
try {
const reader = new FileReader();
reader.onload = async e => {
const text = e.target.result;
setImportProgress(10);
setTimeout(() => {
setImportProgress(20);
setImportPhase('正在解析CSV数据...');
}, 100);
const items = parseCSV(text);
const totalItems = items.length;
setTimeout(() => {
setImportProgress(30);
setImportPhase(`共解析 ${totalItems} 条记录,准备提交...`);
}, 200);
setTimeout(() => {
setImportProgress(40);
setImportPhase('正在连接服务器...');
}, 300);
const response = await axios.post('/api/consumables/import', { items });
setTimeout(() => {
setImportProgress(60);
setImportPhase('正在处理服务器响应...');
}, 100);
setTimeout(() => {
setImportProgress(80);
setImportPhase('正在更新本地数据...');
}, 200);
const results = response.data.results;
setTimeout(() => {
setImportResult(results);
setImportProgress(100);
setImportPhase('导入完成');
setImporting(false);
if (results.failed > 0) {
message.warning(`导入完成,成功 ${results.success} 条,失败 ${results.failed} 条`);
} else {
message.success({
content: response.data.message || `成功导入 ${results.success} 条记录`,
icon: ,
});
}
fetchConsumables();
}, 300);
};
reader.onerror = () => {
setImporting(false);
setImportProgress(0);
setImportPhase('文件读取失败');
message.error('文件读取失败');
};
reader.readAsText(importFile);
} catch (error) {
setImporting(false);
setImportProgress(0);
setImportPhase('导入失败');
message.error('导入失败,请检查网络连接或服务器状态');
console.error('导入耗材失败:', error);
}
};
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 = useCallback(
(record, type) => {
setStockRecord(record);
setStockType(type);
setSelectedSnList([]);
setSnSearchKeyword('');
stockForm.setFieldsValue({
consumableId: record.consumableId,
consumableName: record.name,
quantity: 1,
reason: '',
notes: '',
});
setStockModalVisible(true);
},
[stockForm]
);
const handleStockCancel = useCallback(() => {
setStockModalVisible(false);
setStockRecord(null);
setSelectedSnList([]);
setSnSearchKeyword('');
}, []);
const handleStockSubmit = useCallback(
async values => {
try {
await axios.post('/api/consumables/quick-inout', {
consumableId: stockRecord.consumableId,
type: stockType,
quantity: values.quantity,
operator: values.operator || '系统管理员',
reason: values.reason,
notes: values.notes,
snList: stockType === 'out' ? selectedSnList : values.snList || [],
});
message.success({
content: `${stockType === 'in' ? '入库' : '出库'}操作成功`,
icon: ,
});
setStockModalVisible(false);
setSelectedSnList([]);
fetchConsumables();
} catch (error) {
message.error(
error.response?.data?.error || `${stockType === 'in' ? '入库' : '出库'}操作失败`
);
console.error('操作失败:', error);
}
},
[stockRecord, stockType, fetchConsumables, selectedSnList]
);
const columns = useMemo(
() => [
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 150,
render: text => {text},
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120,
render: text => {text},
},
{
title: '单位',
dataIndex: 'unit',
key: 'unit',
width: 80,
render: text => {text},
},
{
title: 'SN数量',
key: 'snCount',
width: 120,
render: (_, record) => {
const snList = record.snList || [];
const snCount = snList.length;
const stock = record.currentStock || 0;
if (snCount === 0) {
return (
0/{stock}
);
}
const snContent = (
{snList.map((sn, index) => (
{sn}
))}
);
return (
{snCount}/{stock} 🔍
);
},
},
{
title: '当前库存',
dataIndex: 'currentStock',
key: 'currentStock',
width: 100,
render: (value, record) => {
const isLow = value <= record.minStock;
return (
{value}
}
/>
);
},
},
{
title: '最小库存',
dataIndex: 'minStock',
key: 'minStock',
width: 100,
render: text => {text},
},
{
title: '最大库存',
dataIndex: 'maxStock',
key: 'maxStock',
width: 100,
render: value => (
{value === 0 || value === null || value === undefined ? '无限制' : value}
),
},
{
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: 'description',
key: 'description',
width: 200,
ellipsis: true,
render: value => value || -,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: value => (
: }
>
{value === 'active' ? '启用' : '停用'}
),
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
render: (_, record) => (
}
onClick={() => showModal(record)}
style={{ color: designTokens.colors.primary.main }}
/>
}
onClick={() => showStockModal(record, 'in')}
style={{ color: designTokens.colors.success.main }}
/>
}
onClick={() => showStockModal(record, 'out')}
style={{ color: designTokens.colors.error.main }}
/>
handleDelete(record.consumableId)}
okText="确定"
cancelText="取消"
>
} />
),
},
],
[showModal, showStockModal, handleDelete]
);
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 (
{/* 页面标题 */}
{/* 主内容区 */}
{/* 过滤器区域 */}
搜索
handleSearch(e.target.value)}
onPressEnter={() => fetchConsumables()}
prefix={}
allowClear
/>
分类
状态
操作
}
onClick={() => fetchConsumables()}
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
}}
>
筛选
}
onClick={handleReset}
style={{ borderRadius: designTokens.borderRadius.sm }}
/>
{/* 操作按钮区域 */}
}
onClick={() => showModal()}
size="large"
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
boxShadow: designTokens.shadows.md,
}}
>
添加耗材
}
onClick={showImportModal}
size="large"
style={{ borderRadius: designTokens.borderRadius.sm }}
>
批量导入
}
onClick={handleExport}
size="large"
style={{ borderRadius: designTokens.borderRadius.sm }}
>
导出
}
onClick={() => fetchConsumables()}
loading={loading}
style={{ borderRadius: designTokens.borderRadius.sm }}
/>
{/* 数据表格 */}
{loading ? (
) : consumables.length === 0 ? (
暂无耗材数据
点击"添加耗材"按钮创建第一个耗材
}
>
}
onClick={() => showModal()}
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
}}
>
添加耗材
) : (
fetchConsumables(pagination.current, pagination.pageSize)}
scroll={{ x: 1400 }}
style={{
borderRadius: designTokens.borderRadius.md,
overflow: 'hidden',
}}
/>
)}
{/* 添加/编辑耗材弹窗 */}
{editingConsumable ? '编辑耗材' : '添加耗材'}
}
open={modalVisible}
onCancel={handleCancel}
footer={null}
width={700}
>
SN序列号
(非必填,已录入 {snList.length} 个)
}>
}
onClick={() => setSnInputVisible(!snInputVisible)}
>
批量添加
{snInputVisible && (
)}
{snList.length > 0 && (
{snList.map((sn, index) => (
{
setSnList(snList.filter((_, i) => i !== index));
}}
style={{ marginBottom: '4px' }}
>
{sn}
))}
)}
{/* 导入耗材弹窗 */}
批量导入耗材
}
open={importModalVisible}
onCancel={handleImportCancel}
footer={null}
width={700}
>
}
onClick={downloadTemplate}
style={{ borderRadius: designTokens.borderRadius.sm }}
>
下载模板
请下载模板后填写数据再导入
false}
onChange={handleFileChange}
>
}
style={{ borderRadius: designTokens.borderRadius.sm }}
>
选择CSV文件
{importPreview.length > 0 && (
数据预览(前10条)