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,
Radio,
Row,
Col,
Badge,
Tag,
Tooltip,
Empty,
Skeleton,
Alert,
Typography,
Divider,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
ExportOutlined,
ImportOutlined,
UploadOutlined,
FileExcelOutlined,
FileTextOutlined,
ShoppingOutlined,
FilterOutlined,
ClearOutlined,
ReloadOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
ScanOutlined,
BarcodeOutlined,
DownloadOutlined,
WarningOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import * as XLSX from 'xlsx';
import { motion, AnimatePresence } from 'framer-motion';
import { designTokens } from '../config/theme';
import CloseButton from '../components/CloseButton';
import {
inputStyles,
selectStyles,
inputNumberStyles,
textAreaStyles,
filterInputStyles,
inputPlaceholders,
inputValidationRules,
} from '../styles/deviceManagementStyles';
const { Option } = Select;
const { Text, Title } = Typography;
const { TextArea } = Input;
// 动画配置
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 [importMode, setImportMode] = useState('create');
const [importValidationErrors, setImportValidationErrors] = useState([]);
const [importStep, setImportStep] = useState('upload');
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 [scanModalVisible, setScanModalVisible] = useState(false);
const [scanMode, setScanMode] = useState('add');
const [scanValue, setScanValue] = useState('');
const [scanChecking, setScanChecking] = useState(false);
const [scannedSnList, setScannedSnList] = useState([]);
const [selectedScanInConsumable, setSelectedScanInConsumable] = useState(null);
const scanInputRef = React.useRef(null);
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 => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个耗材吗?此操作不可恢复!',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
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 parseFile = file => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
try {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: 'array' });
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
const jsonData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 });
if (jsonData.length < 2) {
resolve([]);
return;
}
const headers = jsonData[0].map(h => String(h || '').trim());
const result = [];
for (let i = 1; i < jsonData.length; i++) {
const row = jsonData[i];
const obj = {};
headers.forEach((header, idx) => {
obj[header] = row[idx] !== undefined ? String(row[idx] || '').trim() : '';
});
if (Object.values(obj).some(v => v)) {
result.push(obj);
}
}
resolve(result);
} catch (error) {
reject(error);
}
};
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsArrayBuffer(file);
});
};
const validateImportData = (data, validCategories) => {
const errors = [];
const validCategoryNames = validCategories.map(c => c.name);
data.forEach((item, index) => {
const rowNum = index + 1;
const name = item['名称'] || item.name;
const category = item['分类'] || item.category;
if (!name) {
errors.push({ row: rowNum, field: '名称', message: '名称为必填项' });
}
if (!category) {
errors.push({ row: rowNum, field: '分类', message: '分类为必填项' });
} else if (validCategoryNames.length > 0 && !validCategoryNames.includes(category)) {
errors.push({ row: rowNum, field: '分类', message: `分类"${category}"不存在,请使用系统已有的分类` });
}
});
return errors;
};
const handleFileChange = async info => {
const file = info.fileList[info.fileList.length - 1];
if (file && file.originFileObj) {
try {
setImporting(true);
setImportPhase('正在解析文件...');
const parsedData = await parseFile(file.originFileObj);
if (parsedData.length === 0) {
message.warning('文件中没有有效数据');
setImporting(false);
return;
}
const validationErrors = validateImportData(parsedData, categories);
setImportValidationErrors(validationErrors);
setImportPreview(parsedData);
setImportFile(file.originFileObj);
setImportStep('preview');
if (validationErrors.length > 0) {
message.warning(`数据校验发现 ${validationErrors.length} 个问题,请检查预览`);
} else {
message.success(`成功解析 ${parsedData.length} 条数据`);
}
} catch (error) {
message.error('文件解析失败,请确保文件格式正确');
console.error('文件解析失败:', error);
} finally {
setImporting(false);
setImportPhase('');
}
}
};
const showImportModal = () => {
setImportPreview([]);
setImportFile(null);
setImportModalVisible(true);
setImportStep('upload');
setImportMode('create');
setImportValidationErrors([]);
setImportResult(null);
};
const handleImportCancel = () => {
setImportModalVisible(false);
setImportPreview([]);
setImportFile(null);
setImportProgress(0);
setImportPhase('');
setImportResult(null);
setImportStep('upload');
setImportValidationErrors([]);
};
const handleImport = async () => {
if (!importFile || importPreview.length === 0) {
message.warning('请先选择并解析文件');
return;
}
setImporting(true);
setImportProgress(10);
setImportPhase('准备导入数据...');
setImportResult(null);
try {
setImportProgress(30);
setImportPhase('正在提交到服务器...');
const response = await axios.post('/api/consumables/import', {
items: importPreview,
mode: importMode
});
setImportProgress(70);
setImportPhase('处理导入结果...');
const results = response.data.results;
setImportResult(results);
setImportStep('result');
setImportProgress(100);
setImportPhase('导入完成');
if (results.failed > 0) {
message.warning(response.data.message);
} else {
message.success({
content: response.data.message,
icon: ,
});
}
fetchConsumables();
} catch (error) {
message.error('导入失败,请检查网络连接或服务器状态');
console.error('导入耗材失败:', error);
} finally {
setImporting(false);
}
};
const downloadTemplate = () => {
const template = [
{
'耗材ID': '',
'名称': '示例耗材-网络模块',
'分类': '光模块',
'单位': '个',
'当前库存': 100,
'最小库存': 10,
'最大库存': 500,
'单价': 5.00,
'供应商': 'XX公司',
'存放位置': 'A柜-01层',
'描述': '测试数据',
'SN序列号': 'SN001,SN002,SN003',
'状态': 'active'
}
];
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 downloadFailedRecords = () => {
if (!importResult || !importResult.details) return;
const failedRecords = importResult.details
.filter(d => d.status === 'failed')
.map(d => {
const original = importPreview[d.row - 1] || {};
return {
'行号': d.row,
'错误原因': d.error,
...original
};
});
if (failedRecords.length === 0) {
message.info('没有失败记录');
return;
}
const ws = XLSX.utils.json_to_sheet(failedRecords);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '失败记录');
XLSX.writeFile(wb, '耗材导入失败记录.xlsx');
message.success('失败记录下载成功');
};
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 showScanModal = useCallback((mode) => {
setScanMode(mode);
setScanValue('');
setScannedSnList([]);
setSelectedScanInConsumable(null);
setScanModalVisible(true);
setTimeout(() => {
scanInputRef.current?.focus();
}, 100);
}, []);
const handleScanCancel = useCallback(() => {
setScanModalVisible(false);
setScanValue('');
setScannedSnList([]);
setSelectedScanInConsumable(null);
setScanChecking(false);
}, []);
const handleScanKeyDown = useCallback(async (e) => {
if (e.key === 'Enter' && scanValue.trim()) {
e.preventDefault();
const code = scanValue.trim();
if (scanMode === 'add') {
setScanChecking(true);
try {
const res = await axios.get(`/api/consumables/by-sn/${encodeURIComponent(code)}`);
if (res.data.found) {
const existingConsumable = res.data.consumable;
Modal.confirm({
title: 'SN已存在',
content: (
该SN已关联耗材:{existingConsumable.name}
分类:{existingConsumable.category}
当前库存:{existingConsumable.currentStock} {existingConsumable.unit}
是否为该耗材入库?
),
okText: '入库',
cancelText: '关闭',
onOk: () => {
handleScanCancel();
setModalVisible(false);
showStockModal(existingConsumable, 'in');
setSelectedSnList([code]);
stockForm.setFieldsValue({ quantity: 1 });
},
onCancel: () => {
setScanValue('');
scanInputRef.current?.focus();
}
});
} else {
if (!snList.includes(code)) {
setSnList(prev => [...prev, code]);
const currentStock = form.getFieldValue('currentStock') || 0;
form.setFieldsValue({ currentStock: currentStock + 1 });
message.success(`已添加SN: ${code}`);
} else {
message.warning(`SN已在列表中: ${code}`);
}
setScanValue('');
scanInputRef.current?.focus();
}
} catch (error) {
message.error('查询SN失败');
console.error('查询SN失败:', error);
} finally {
setScanChecking(false);
}
} else if (scanMode === 'in') {
if (!scannedSnList.includes(code)) {
setScannedSnList(prev => [...prev, code]);
message.success(`已添加SN: ${code}`);
} else {
message.warning(`SN已存在: ${code}`);
}
setScanValue('');
scanInputRef.current?.focus();
} else if (scanMode === 'out') {
setScanChecking(true);
try {
const res = await axios.get(`/api/consumables/by-sn/${encodeURIComponent(code)}`);
if (res.data.found) {
handleScanCancel();
showStockModal(res.data.consumable, 'out');
setSelectedSnList([code]);
stockForm.setFieldsValue({ quantity: 1 });
} else {
message.warning('未找到该SN对应的耗材');
setScanValue('');
scanInputRef.current?.focus();
}
} catch (error) {
message.error('查询SN失败');
console.error('查询SN失败:', error);
} finally {
setScanChecking(false);
}
}
}
}, [scanMode, scanValue, scannedSnList, handleScanCancel, showModal, form, showStockModal, stockForm]);
const handleScanInSubmit = useCallback(async (consumableId) => {
if (scannedSnList.length === 0) {
message.warning('请先扫描SN序列号');
return;
}
try {
await axios.post('/api/consumables/quick-inout', {
consumableId,
type: 'in',
quantity: scannedSnList.length,
operator: '系统管理员',
reason: '扫码入库',
snList: scannedSnList,
});
message.success({
content: `成功入库 ${scannedSnList.length} 个SN`,
icon: ,
});
handleScanCancel();
fetchConsumables();
} catch (error) {
message.error(error.response?.data?.error || '入库操作失败');
console.error('入库失败:', error);
}
}, [scannedSnList, handleScanCancel, fetchConsumables]);
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 = Array.isArray(record.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: '行号', key: 'row', width: 60, render: (_, __, index) => index + 1 },
{ title: '耗材ID', dataIndex: '耗材ID', key: 'consumableId', width: 120 },
{ title: '名称', dataIndex: '名称', key: 'name', width: 150 },
{ title: '分类', dataIndex: '分类', key: 'category', width: 100 },
{ title: '单位', dataIndex: '单位', key: 'unit', width: 70 },
{ title: '当前库存', dataIndex: '当前库存', key: 'currentStock', width: 90 },
{ title: '供应商', dataIndex: '供应商', key: 'supplier', width: 120 },
{ title: 'SN序列号', dataIndex: 'SN序列号', key: 'snList', width: 150, ellipsis: true },
];
return (
{/* 页面标题 */}
{/* 主内容区 */}
{/* 过滤器区域 */}
搜索
handleSearch(e.target.value)}
onPressEnter={() => fetchConsumables()}
prefix={}
allowClear
style={filterInputStyles.input}
/>
分类
状态
操作
}
onClick={() => fetchConsumables()}
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
height: '40px',
}}
>
筛选
}
onClick={handleReset}
style={{ borderRadius: designTokens.borderRadius.sm, height: '40px' }}
/>
{/* 操作按钮区域 */}
}
onClick={() => showModal()}
size="large"
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
boxShadow: designTokens.shadows.md,
}}
>
添加耗材
}
onClick={() => showScanModal('in')}
size="large"
style={{ borderRadius: designTokens.borderRadius.sm, color: designTokens.colors.success.main, borderColor: designTokens.colors.success.main }}
>
扫码入库
}
onClick={() => showScanModal('out')}
size="large"
style={{ borderRadius: designTokens.borderRadius.sm, color: designTokens.colors.error.main, borderColor: designTokens.colors.error.main }}
>
扫码出库
}
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}
closeIcon={}
onCancel={handleCancel}
footer={null}
width={900}
style={{ top: 20 }}
>
{/* 导入耗材弹窗 - 全新UI/UX设计 */}
批量导入耗材
支持 Excel/CSV 格式批量导入
}
open={importModalVisible}
closeIcon={}
onCancel={handleImportCancel}
footer={null}
width={920}
bodyStyle={{ padding: '24px' }}
style={{ top: 40 }}
>
{/* 步骤指示器 */}
{[
{ key: 'upload', label: '上传文件', icon: UploadOutlined },
{ key: 'preview', label: '预览确认', icon: FileTextOutlined },
{ key: 'result', label: '完成', icon: CheckCircleOutlined },
].map((step, index) => {
const isActive = importStep === step.key;
const isPast = ['upload', 'preview', 'result'].indexOf(importStep) > index;
const StepIcon = step.icon;
return (
{index < 2 && (
)}
);
})}
{/* 步骤1: 上传文件 */}
{importStep === 'upload' && (
{/* 模板下载区域 */}
下载导入模板
先下载标准模板,填写数据后再上传,支持 .xlsx、.xls、.csv 格式
}
onClick={downloadTemplate}
size="large"
style={{
background: `linear-gradient(135deg, ${designTokens.colors.success.main} 0%, ${designTokens.colors.primary.main} 100%)`,
border: 'none',
borderRadius: '10px',
height: '44px',
paddingInline: '24px',
boxShadow: `0 4px 12px ${designTokens.colors.success.main}40`,
}}
>
下载模板
{/* 字段说明 */}
{[
{ field: '耗材ID', desc: '留空自动生成;填写后可识别并更新现有耗材', required: false, icon: '🔑' },
{ field: '名称', desc: '耗材名称,必填项', required: true, icon: '📝' },
{ field: '分类', desc: '耗材分类,如"光模块"或"光纤跳线",必填', required: true, icon: '📂' },
{ field: '单位', desc: '计量单位,如"个"、"根"、"箱",默认"个"', required: false, icon: '📏' },
{ field: '当前库存', desc: '当前库存数量,数字类型', required: false, icon: '📦' },
{ field: '最小库存', desc: '安全库存阈值,低于此值会触发预警', required: false, icon: '⚠️' },
{ field: '最大库存', desc: '最大库存限制,0表示无限制', required: false, icon: '📈' },
{ field: '单价', desc: '耗材单价,数字类型', required: false, icon: '💰' },
{ field: '供应商', desc: '耗材供应商名称', required: false, icon: '🏭' },
{ field: '存放位置', desc: '仓库内存放位置,如"A柜-01层"', required: false, icon: '📍' },
{ field: '描述', desc: '耗材的详细描述或备注', required: false, icon: '📄' },
{ field: 'SN序列号', desc: '多个SN用逗号分隔,如"SN001,SN002"', required: false, icon: '🏷️' },
{ field: '状态', desc: '"active"启用,"inactive"停用,默认启用', required: false, icon: '✅' },
].map((item, idx) => (
{item.icon}
{item.field}
{item.required && (
必填
)}
{item.desc}
))}
{/* 拖拽上传区域 */}
false}
onChange={handleFileChange}
showUploadList={false}
style={{
borderRadius: '16px',
overflow: 'hidden',
}}
>
点击或拖拽文件到此处上传
支持 .xlsx、.xls、.csv 格式,文件大小不超过 10MB
{['.xlsx', '.xls', '.csv'].map(type => (
{type}
))}
)}
{/* 步骤2: 预览确认 */}
{importStep === 'preview' && (
{/* 数据统计卡片 */}
{importPreview.length}
待导入记录
0
? `linear-gradient(135deg, ${designTokens.colors.warning.main} 0%, ${designTokens.colors.error.main} 100%)`
: `linear-gradient(135deg, ${designTokens.colors.success.main} 0%, #52c41a 100%)`,
borderRadius: '12px',
padding: '16px 20px',
color: '#fff',
}}>
{importValidationErrors.length}
数据问题
{/* 错误提示 */}
{importValidationErrors.length > 0 && (
{importValidationErrors.slice(0, 5).map((err, idx) => (
行{err.row}
{err.field && `[${err.field}]`} {err.message}
))}
{importValidationErrors.length > 5 && (
...还有 {importValidationErrors.length - 5} 个问题
)}
}
type="warning"
showIcon
icon={}
style={{
marginBottom: '20px',
borderRadius: '12px',
border: 'none',
background: `${designTokens.colors.warning.main}15`,
}}
/>
)}
{/* 导入模式选择 */}
setImportMode(e.target.value)}
style={{ width: '100%' }}
>
仅新增模式
跳过已存在的耗材(根据耗材ID判断),仅创建新耗材
更新模式
如果耗材ID已存在则更新现有记录,不存在则创建新耗材
{/* 数据预览表格 */}
数据预览
{importPreview.length} 条
}
extra={
}
onClick={() => setImportStep('upload')}
style={{ borderRadius: '8px' }}
>
重新选择
}
style={{
borderRadius: '12px',
border: `1px solid ${designTokens.colors.neutral[200]}`,
}}
bodyStyle={{ padding: 0 }}
>
index}
pagination={{ pageSize: 5, showSizeChanger: false }}
size="small"
scroll={{ x: 900 }}
style={{ borderRadius: '12px' }}
/>
)}
{/* 步骤3: 完成 */}
{importStep === 'result' && importResult && (
{/* 结果状态 */}
{importResult.failed === 0 ? (
) : (
)}
{importResult.failed === 0 ? '导入成功!' : '导入完成(部分失败)'}
{importResult.success > 0 && `成功新增 ${importResult.success} 条,`}
{importResult.updated > 0 && `更新 ${importResult.updated} 条,`}
{importResult.skipped > 0 && `跳过 ${importResult.skipped} 条,`}
{importResult.failed > 0 && `失败 ${importResult.failed} 条`}
{/* 统计卡片 */}
{[
{ label: '新增成功', value: importResult.success, color: designTokens.colors.success.main, bg: `${designTokens.colors.success.main}15` },
{ label: '更新成功', value: importResult.updated, color: designTokens.colors.primary.main, bg: `${designTokens.colors.primary.main}15` },
{ label: '跳过', value: importResult.skipped, color: designTokens.colors.neutral[500], bg: designTokens.colors.neutral[100] },
{ label: '失败', value: importResult.failed, color: designTokens.colors.error.main, bg: `${designTokens.colors.error.main}15` },
].map((stat, idx) => (
{stat.value}
{stat.label}
))}
{/* 失败记录 */}
{importResult.failed > 0 && (
失败记录
{importResult.failed} 条
}
extra={
}
onClick={downloadFailedRecords}
style={{ borderRadius: '8px' }}
>
导出失败记录
}
style={{
borderRadius: '12px',
border: `1px solid ${designTokens.colors.error.main}30`,
}}
bodyStyle={{ padding: 0 }}
>
{importResult.details
.filter(d => d.status === 'failed')
.map((detail, idx) => (
d.status === 'failed').length - 1
? `1px solid ${designTokens.colors.neutral[100]}`
: 'none',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
{detail.row}
{detail.error}
))}
)}
)}
{/* 导入中状态 */}
{importing && (
正在导入数据...
{importPhase}
)}
{/* 底部按钮 */}
{!importing && importStep !== 'result' && (
{importStep === 'preview' && (
)}
)}
{/* 入库/出库弹窗 */}
{stockType === 'in' ?
:
}
{stockType === 'in' ? '耗材入库' : '耗材出库'}
}
open={stockModalVisible}
closeIcon={}
onCancel={handleStockCancel}
footer={null}
width={500}
>
{stockType === 'out' && stockRecord?.snList && stockRecord.snList.length > 0 && (
选择SN序列号
(已选 {selectedSnList.length} 个)
}>
}
allowClear
value={snSearchKeyword}
onChange={e => setSnSearchKeyword(e.target.value)}
style={{ ...inputStyles.search, marginBottom: '8px' }}
/>
{(() => {
const snListArray = Array.isArray(stockRecord.snList) ? stockRecord.snList : [];
const filteredSnList = snListArray.filter(sn =>
sn.toLowerCase().includes(snSearchKeyword.toLowerCase())
);
if (filteredSnList.length === 0) {
return 无匹配的 SN;
}
return filteredSnList.map((sn, index) => (
{
let newSelected;
if (checked) {
newSelected = [...selectedSnList, sn];
} else {
newSelected = selectedSnList.filter(s => s !== sn);
}
setSelectedSnList(newSelected);
stockForm.setFieldsValue({ quantity: newSelected.length });
}}
style={{ marginBottom: '4px' }}
>
{sn}
));
})()}
{snSearchKeyword ? `过滤结果: ${stockRecord.snList.filter(sn => sn.toLowerCase().includes(snSearchKeyword.toLowerCase())).length} 个SN` : `共 ${stockRecord.snList.length} 个SN`}
,点击SN进行选择
)}
{stockType === 'in' && (
)}
0 ? stockRecord.snList.length : undefined}
style={inputNumberStyles.base}
placeholder="请输入数量"
/>
{/* 扫码弹窗 */}
}
onCancel={handleScanCancel}
footer={null}
width={500}
>
setScanValue(e.target.value)}
onKeyDown={handleScanKeyDown}
placeholder={scanMode === 'add' ? '扫描SN后自动添加...' : '扫描SN后自动识别...'}
prefix={}
size="large"
autoFocus
disabled={scanChecking}
style={{ borderRadius: designTokens.borderRadius.md }}
/>
{scanMode === 'add' && snList.length > 0 && (
已添加SN列表 ({snList.length}个)
{(Array.isArray(snList) ? snList : []).map((sn, index) => (
{
const newSnList = snList.filter((_, i) => i !== index);
setSnList(newSnList);
form.setFieldsValue({ currentStock: newSnList.length });
}}
color="blue"
style={{ marginBottom: '4px' }}
>
{sn}
))}
)}
{scanMode === 'in' && scannedSnList.length > 0 && (
已扫描SN列表 ({scannedSnList.length}个)
{scannedSnList.map((sn, index) => (
setScannedSnList(prev => prev.filter((_, i) => i !== index))}
color="blue"
style={{ marginBottom: '4px' }}
>
{sn}
))}
选择入库耗材
)}
💡 提示:也可手动输入条码后按回车键确认
);
}
export default React.memo(ConsumableManagement);