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) => ( ) : ( 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 }} >
{/* 基本信息 */}
1
基本信息
} /> {/* 库存管理 */}
2
库存管理 设置库存预警和上限
{ setMaxStockUnlimited(e.target.checked); if (e.target.checked) { form.setFieldsValue({ maxStock: undefined }); } }} > 无限制
{!maxStockUnlimited && ( )}
{/* 价格与状态 */}
3
价格与状态
{/* 其他信息 */}
4
其他信息
{/* SN序列号管理 */}
5
SN序列号管理 已录入 {snList.length} 个
{/* 批量添加输入区 */} {snInputVisible && (