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) => ( ) : ( fetchConsumables(pagination.current, pagination.pageSize)} scroll={{ x: 1400 }} style={{ borderRadius: designTokens.borderRadius.md, overflow: 'hidden', }} /> )} {/* 添加/编辑耗材弹窗 */}
{editingConsumable ? '编辑耗材' : '添加耗材'} } open={modalVisible} onCancel={handleCancel} footer={null} width={700} >
{ setMaxStockUnlimited(e.target.checked); if (e.target.checked) { form.setFieldsValue({ maxStock: undefined }); } }} > 无限制 {!maxStockUnlimited && ( )}