import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useDebounce, useDebouncedCallback } 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, AutoComplete, Avatar, Statistic, Timeline, Dropdown, Menu, } 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, QrcodeOutlined, DesktopOutlined, HistoryOutlined, EyeOutlined, CloseCircleOutlined, DownOutlined, CheckSquareOutlined, SettingOutlined, } 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 ConsumableTimelineModal from '../components/ConsumableTimelineModal'; 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 [allConsumablesForScan, setAllConsumablesForScan] = 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 [importStockMode, setImportStockMode] = useState('basic'); 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 [createWithInbound, setCreateWithInbound] = useState(false); const [inboundData, setInboundData] = useState({ quantity: 1, operator: '', reason: '', }); const [inboundSnList, setInboundSnList] = 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 [quickOutModalVisible, setQuickOutModalVisible] = useState(false); const [quickOutConsumable, setQuickOutConsumable] = useState(null); const [quickOutDevice, setQuickOutDevice] = useState(null); const [quickOutDeviceSearch, setQuickOutDeviceSearch] = useState(''); const [quickOutDeviceList, setQuickOutDeviceList] = useState([]); const [quickOutDeviceLoading, setQuickOutDeviceLoading] = useState(false); const [quickOutQuantity, setQuickOutQuantity] = useState(1); const [quickOutReason, setQuickOutReason] = useState(''); const [quickOutSnList, setQuickOutSnList] = useState([]); const [quickOutSubmitting, setQuickOutSubmitting] = useState(false); const [pendingOutItems, setPendingOutItems] = useState([]); const [batchOutModalVisible, setBatchOutModalVisible] = useState(false); const [batchOutDevice, setBatchOutDevice] = useState(null); const [batchOutDeviceSearch, setBatchOutDeviceSearch] = useState(''); const [batchOutDeviceList, setBatchOutDeviceList] = useState([]); const [batchOutDeviceLoading, setBatchOutDeviceLoading] = useState(false); const [batchOutReason, setBatchOutReason] = useState(''); const [batchOutSubmitting, setBatchOutSubmitting] = useState(false); const [importJobId, setImportJobId] = useState(null); const [importJobStatus, setImportJobStatus] = useState(null); const [fieldMappings, setFieldMappings] = useState({}); const [availableFields, setAvailableFields] = useState([]); const [detectedHeaders, setDetectedHeaders] = useState([]); const [showFieldMapping, setShowFieldMapping] = useState(false); const [pollingInterval, setPollingInterval] = useState(null); const importProgressRef = React.useRef(null); const [timelineModalVisible, setTimelineModalVisible] = useState(false); const [selectedConsumable, setSelectedConsumable] = useState(null); const [exportModalVisible, setExportModalVisible] = useState(false); const [exportMode, setExportMode] = useState('all'); // all/filtered/selected/warning const [exportFields, setExportFields] = useState([ 'consumableId', 'name', 'category', 'unit', 'currentStock', 'minStock', 'maxStock', 'unitPrice', 'supplier', 'location', 'description', 'status', 'snList' ]); const [selectedRowKeys, setSelectedRowKeys] = useState([]); // 导出字段定义 const exportableFields = [ { key: 'consumableId', label: '耗材ID' }, { key: 'name', label: '名称', required: true }, { key: 'category', label: '分类' }, { key: 'unit', label: '单位' }, { key: 'currentStock', label: '当前库存' }, { key: 'minStock', label: '最小库存' }, { key: 'maxStock', label: '最大库存' }, { key: 'unitPrice', label: '单价' }, { key: 'supplier', label: '供应商' }, { key: 'location', label: '存放位置' }, { key: 'description', label: '描述' }, { key: 'status', label: '状态' }, { key: 'snList', label: 'SN序列号' }, ]; // 获取全部耗材(用于扫码入库下拉框,不受分页限制) const fetchAllConsumablesForScan = useCallback(async () => { try { const response = await axios.get('/api/consumables', { params: { page: 1, pageSize: 9999 }, }); setAllConsumablesForScan(response.data.consumables || []); } catch (error) { console.error('获取全部耗材失败:', error); } }, []); 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 })); // 同步刷新扫码入库用的全量列表 fetchAllConsumablesForScan(); } catch (error) { message.error('获取耗材列表失败'); console.error('获取耗材列表失败:', error); } finally { setLoading(false); } }, [debouncedKeyword, category, status, fetchAllConsumablesForScan] ); 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([]); setCreateWithInbound(false); setInboundData({ quantity: 1, operator: '', reason: '' }); setInboundSnList([]); form.resetFields(); form.setFieldsValue({ unit: '个', minStock: 0, status: 'active', unitPrice: 0, }); } setModalVisible(true); }, [form] ); const handleCancel = useCallback(() => { setModalVisible(false); setEditingConsumable(null); setSnList([]); setSnInputVisible(false); setSnInputValue(''); setCreateWithInbound(false); setInboundData({ quantity: 1, operator: '', reason: '' }); }, []); const handleSubmit = useCallback( async values => { try { const submitData = { ...values, maxStock: maxStockUnlimited ? 0 : values.maxStock, unitPrice: values.unitPrice || 0, }; if (editingConsumable) { await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData); message.success({ content: '耗材更新成功', icon: , }); } else { if (createWithInbound) { // 同时入库模式,调用创建并入库接口 await axios.post('/api/consumables/create-with-inbound', { ...submitData, consumableId: `CON${Date.now()}`, inboundQuantity: inboundData.quantity, inboundOperator: inboundData.operator, inboundReason: inboundData.reason, inboundSnList: inboundSnList, }); 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([]); setCreateWithInbound(false); setInboundData({ quantity: 1, operator: '', reason: '' }); setInboundSnList([]); } catch (error) { message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败'); console.error('提交失败:', error); } }, [editingConsumable, fetchConsumables, maxStockUnlimited, createWithInbound, inboundData, inboundSnList] ); 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 params = { fields: exportFields.join(',') }; if (exportMode === 'all') { // 导出全部,不加筛选条件 } else if (exportMode === 'filtered') { // 导出筛选结果 params.keyword = keyword; params.category = category; params.status = status; } else if (exportMode === 'selected') { // 导出选中项 params.ids = selectedRowKeys.join(','); } else if (exportMode === 'warning') { // 导出预警库存 params.stockStatus = 'warning'; params.keyword = keyword; params.category = category; } const response = await axios.get('/api/consumables/export', { params }); const consumables = response.data.consumables; exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`); message.success({ content: '导出成功', icon: , }); setExportModalVisible(false); } 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({ data: [], headers: [] }); 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({ data: result, headers }); } catch (error) { reject(error); } }; reader.onerror = () => reject(new Error('文件读取失败')); reader.readAsArrayBuffer(file); }); }; const detectFieldMappings = (headers, availableFields) => { const mappings = {}; const normalizedAvailableFields = availableFields.map(f => ({ source: f.source, target: f.target, normalizedSource: f.source.toLowerCase(), normalizedTarget: f.target.toLowerCase(), })); headers.forEach(header => { const normalizedHeader = header.toLowerCase().replace(/[_\s]/g, ''); const match = normalizedAvailableFields.find( f => f.normalizedSource.replace(/[_\s]/g, '') === normalizedHeader || f.normalizedTarget.replace(/[_\s]/g, '') === normalizedHeader || f.source === header ); if (match) { mappings[header] = match.target; } }); return mappings; }; 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 fetchFieldMappings = async () => { try { const response = await axios.get('/api/consumables/field-mappings'); if (response.data && response.data.aliases) { setAvailableFields(response.data.aliases); } } catch (error) { console.error('获取字段映射信息失败:', error); } }; const pollImportProgress = async jobId => { try { const response = await axios.get(`/api/consumables/progress/${jobId}`); const progress = response.data; setImportJobStatus(progress); setImportProgress(progress.progressPercent || 0); if (progress.status === 'completed') { if (pollingInterval) { clearInterval(pollingInterval); setPollingInterval(null); } setImportPhase('导入完成'); fetchImportResult(jobId); } else if (progress.status === 'failed') { if (pollingInterval) { clearInterval(pollingInterval); setPollingInterval(null); } setImportPhase('导入失败'); setImporting(false); message.error(progress.error || '导入失败'); } else if (progress.status === 'cancelled') { if (pollingInterval) { clearInterval(pollingInterval); setPollingInterval(null); } setImportPhase('导入已取消'); setImporting(false); } } catch (error) { console.error('获取导入进度失败:', error); } }; const fetchImportResult = async jobId => { try { const response = await axios.get(`/api/consumables/result/${jobId}`); if (response.data && response.data.result) { setImportResult(response.data.result); setImportStep('result'); } setImporting(false); fetchConsumables(); } catch (error) { console.error('获取导入结果失败:', error); setImporting(false); } }; const handleCancelImport = async () => { if (!importJobId) return; try { await axios.post(`/api/consumables/cancel/${importJobId}`); message.info('正在取消导入任务...'); } catch (error) { console.error('取消导入失败:', error); message.error('取消导入失败'); } }; const handleFileChange = async info => { const file = info.fileList[info.fileList.length - 1]; if (file && file.originFileObj) { try { setImporting(true); setImportPhase('正在解析文件...'); const { data: parsedData, headers: detectedHeadersList } = await parseFile(file.originFileObj); if (parsedData.length === 0) { message.warning('文件中没有有效数据'); setImporting(false); return; } setDetectedHeaders(detectedHeadersList || []); if (availableFields.length > 0 && detectedHeadersList.length > 0) { const autoMappings = detectFieldMappings(detectedHeadersList, availableFields); setFieldMappings(autoMappings); } 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); setFieldMappings({}); setDetectedHeaders([]); setImportJobId(null); setImportJobStatus(null); if (pollingInterval) { clearInterval(pollingInterval); setPollingInterval(null); } if (availableFields.length === 0) { fetchFieldMappings(); } }; const handleImportCancel = () => { setImportModalVisible(false); setImportPreview([]); setImportFile(null); setImportProgress(0); setImportPhase(''); setImportResult(null); setImportStep('upload'); setImportValidationErrors([]); setFieldMappings({}); setDetectedHeaders([]); setImportJobId(null); setImportJobStatus(null); if (pollingInterval) { clearInterval(pollingInterval); setPollingInterval(null); } }; const handleImport = async () => { if (!importFile || importPreview.length === 0) { message.warning('请先选择并解析文件'); return; } setImporting(true); setImportProgress(0); setImportPhase('准备导入数据...'); setImportResult(null); setImportStep('importing'); const useBackgroundMode = importPreview.length > 500; try { if (useBackgroundMode) { setImportPhase('正在创建后台任务...'); const response = await axios.post('/api/consumables/background', { items: importPreview, mode: importMode, fieldMapping: fieldMappings, }); const { jobId } = response.data; setImportJobId(jobId); setImportProgress(5); setImportPhase('后台任务已创建,正在导入...'); const interval = setInterval(() => { pollImportProgress(jobId); }, 1000); setPollingInterval(interval); } else { setImportProgress(30); setImportPhase('正在提交到服务器...'); const response = await axios.post('/api/consumables/import', { items: importPreview, mode: importMode, stockMode: importStockMode, }); 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(); setImporting(false); } } catch (error) { message.error('导入失败,请检查网络连接或服务器状态'); console.error('导入耗材失败:', error); setImporting(false); setImportStep('preview'); } }; const downloadTemplate = (stockMode = 'basic') => { // 根据 stockMode 生成不同的模板数据 const basicTemplate = { 耗材ID: 'CON001', 名称: '示例耗材-网络模块', 分类: '光模块', 单位: '个', 最小库存: 10, 最大库存: 500, 单价: 5.0, 供应商: 'XX公司', 存放位置: 'A柜-01层', 描述: '测试数据', 状态: 'active', }; const inboundTemplate = { ...basicTemplate, 入库数量: 100, 操作人: '系统', 原因: '批量导入入库', SN序列号: 'SN001,SN002,SN003', }; const templateData = stockMode === 'basic' ? basicTemplate : inboundTemplate; const basicFieldDescription = [ { 字段名: '耗材ID', 系统字段: 'consumableId', 必填: '否', 说明: '耗材唯一标识符,留空自动生成;填写后可识别并更新现有耗材', 示例: 'CON001', }, { 字段名: '名称', 系统字段: 'name', 必填: '是', 说明: '耗材名称', 示例: '光纤跳线', }, { 字段名: '分类', 系统字段: 'category', 必填: '是', 说明: '耗材分类,如"光模块"或"光纤跳线",需先在系统中创建该分类', 示例: '光模块', }, { 字段名: '单位', 系统字段: 'unit', 必填: '否', 说明: '计量单位,如"个"、"根"、"箱",默认"个"', 示例: '个', }, { 字段名: '最小库存', 系统字段: 'minStock', 必填: '否', 说明: '安全库存阈值,低于此值会触发预警', 示例: '10', }, { 字段名: '最大库存', 系统字段: 'maxStock', 必填: '否', 说明: '最大库存限制,0表示无限制', 示例: '500', }, { 字段名: '单价', 系统字段: 'unitPrice', 必填: '否', 说明: '耗材单价,数字类型', 示例: '5.00', }, { 字段名: '供应商', 系统字段: 'supplier', 必填: '否', 说明: '耗材供应商名称', 示例: 'XX科技有限公司', }, { 字段名: '存放位置', 系统字段: 'location', 必填: '否', 说明: '仓库内存放位置,如"A柜-01层"', 示例: 'A柜-01层', }, { 字段名: '描述', 系统字段: 'description', 必填: '否', 说明: '耗材的详细描述或备注', 示例: '这是一条测试数据', }, { 字段名: '状态', 系统字段: 'status', 必填: '否', 说明: '"active"启用,"inactive"停用,默认启用', 示例: 'active', }, ]; const inboundFieldDescription = [ ...basicFieldDescription, { 字段名: '入库数量', 系统字段: 'quantity', 必填: '是', 说明: '入库操作的数量,数字类型', 示例: '100', }, { 字段名: '操作人', 系统字段: 'operator', 必填: '否', 说明: '入库操作人,默认"系统"', 示例: '系统', }, { 字段名: '原因', 系统字段: 'reason', 必填: '否', 说明: '入库原因,默认"批量导入入库"', 示例: '批量导入入库', }, { 字段名: 'SN序列号', 系统字段: 'snList', 必填: '否', 说明: '多个SN用逗号、分号或换行分隔,如"SN001,SN002"或"SN001\\nSN002"', 示例: 'SN001,SN002,SN003', }, ]; const fieldDescription = stockMode === 'basic' ? basicFieldDescription : inboundFieldDescription; const template = [templateData]; const wb = XLSX.utils.book_new(); const ws1 = XLSX.utils.json_to_sheet(template); XLSX.utils.book_append_sheet(wb, ws1, '耗材导入模板'); const ws2 = XLSX.utils.json_to_sheet(fieldDescription); XLSX.utils.book_append_sheet(wb, ws2, '字段说明'); const ws3 = XLSX.utils.json_to_sheet([{ 注意: '请删除示例数据后填写您的实际数据' }]); XLSX.utils.book_append_sheet(wb, ws3, '使用说明'); XLSX.writeFile(wb, '耗材导入模板.xlsx'); message.success({ content: '模板下载成功(包含3个工作表)', icon: , }); }; 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 addToPendingOut = (consumable, snList = []) => { const existingIndex = pendingOutItems.findIndex( item => item.consumable.consumableId === consumable.consumableId ); if (existingIndex >= 0) { const updated = [...pendingOutItems]; const existing = updated[existingIndex]; const newSnList = [...new Set([...existing.snList, ...snList])]; updated[existingIndex] = { ...existing, quantity: newSnList.length > 0 ? newSnList.length : existing.quantity + 1, snList: newSnList, }; setPendingOutItems(updated); } else { setPendingOutItems([ ...pendingOutItems, { consumable, quantity: snList.length > 0 ? snList.length : 1, snList, }, ]); } }; const removeFromPendingOut = consumableId => { setPendingOutItems(pendingOutItems.filter(item => item.consumable.consumableId !== consumableId)); }; 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 { // 区分添加到哪个列表 const targetList = createWithInbound ? inboundSnList : snList; const setTargetList = createWithInbound ? setInboundSnList : setSnList; if (!targetList.includes(code)) { setTargetList(prev => [...prev, code]); // 如果是同时入库模式,自动同步入库数量 if (createWithInbound) { setInboundData(prev => ({ ...prev, quantity: targetList.length + 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) { const consumable = res.data.consumable; const existingItem = pendingOutItems.find( item => item.consumable.consumableId === consumable.consumableId ); if (existingItem) { if (!existingItem.snList.includes(code)) { const updated = [...pendingOutItems]; const index = updated.findIndex( item => item.consumable.consumableId === consumable.consumableId ); updated[index] = { ...updated[index], quantity: updated[index].quantity + 1, snList: [...updated[index].snList, code], }; setPendingOutItems(updated); message.success({ content: `已添加 ${consumable.name} (SN: ${code}) 到出库列表`, icon: , }); } else { message.warning(`SN已在列表中: ${code}`); } } else { addToPendingOut(consumable, [code]); message.success({ content: `已添加 ${consumable.name} (SN: ${code}) 到出库列表`, icon: , }); } setScanValue(''); scanInputRef.current?.focus(); } else { message.warning('未找到该SN对应的耗材'); setScanValue(''); scanInputRef.current?.focus(); } } catch (error) { message.error('查询SN失败'); console.error('查询SN失败:', error); } finally { setScanChecking(false); } } } }, [ scanMode, scanValue, scannedSnList, snList, setSnList, createWithInbound, inboundSnList, setInboundSnList, setInboundData, handleScanCancel, showModal, form, showStockModal, stockForm, pendingOutItems, addToPendingOut, ] ); 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 searchDevices = useCallback(async keyword => { if (!keyword || keyword.length < 1) { setQuickOutDeviceList([]); return; } setQuickOutDeviceLoading(true); try { const response = await axios.get('/api/consumables/devices/search', { params: { keyword, limit: 20 }, }); setQuickOutDeviceList(response.data.devices || []); } catch (error) { console.error('搜索设备失败:', error); setQuickOutDeviceList([]); } finally { setQuickOutDeviceLoading(false); } }, []); const debouncedDeviceSearch = useDebouncedCallback(searchDevices, 300); const handleQuickOutDeviceSearch = value => { setQuickOutDeviceSearch(value); debouncedDeviceSearch(value); }; const searchBatchDevices = useCallback(async keyword => { if (!keyword || keyword.length < 1) { setBatchOutDeviceList([]); return; } setBatchOutDeviceLoading(true); try { const response = await axios.get('/api/consumables/devices/search', { params: { keyword, limit: 20 }, }); setBatchOutDeviceList(response.data.devices || []); } catch (error) { console.error('搜索设备失败:', error); setBatchOutDeviceList([]); } finally { setBatchOutDeviceLoading(false); } }, []); const debouncedBatchDeviceSearch = useDebouncedCallback(searchBatchDevices, 300); const handleBatchOutDeviceSearch = value => { setBatchOutDeviceSearch(value); debouncedBatchDeviceSearch(value); }; const openQuickOutModal = (consumable, sn = null) => { setQuickOutConsumable(consumable); setQuickOutDevice(null); setQuickOutDeviceSearch(''); setQuickOutDeviceList([]); setQuickOutQuantity(1); setQuickOutReason(''); setQuickOutSnList(sn ? [sn] : []); setQuickOutModalVisible(true); }; const handleQuickOutSubmit = async () => { if (!quickOutConsumable) { message.warning('请选择耗材'); return; } if (quickOutQuantity < 1) { message.warning('出库数量必须大于0'); return; } if (quickOutQuantity > quickOutConsumable.currentStock) { message.warning('出库数量不能超过当前库存'); return; } setQuickOutSubmitting(true); try { const response = await axios.post('/api/consumables/quick-inout', { consumableId: quickOutConsumable.consumableId, type: 'out', quantity: quickOutQuantity, operator: '系统管理员', reason: quickOutReason, notes: quickOutDevice ? `出库至设备: ${quickOutDevice.name}` : '', snList: quickOutSnList, deviceId: quickOutDevice?.deviceId || null, }); message.success({ content: `成功出库 ${quickOutQuantity} 个${quickOutDevice ? `至设备 ${quickOutDevice.name}` : ''}`, icon: , }); setQuickOutModalVisible(false); fetchConsumables(); } catch (error) { message.error(error.response?.data?.error || '出库操作失败'); console.error('出库失败:', error); } finally { setQuickOutSubmitting(false); } }; const openBatchOutModal = () => { if (pendingOutItems.length === 0) { message.warning('没有待出库的耗材'); return; } setBatchOutDevice(null); setBatchOutDeviceSearch(''); setBatchOutDeviceList([]); setBatchOutReason(''); setBatchOutModalVisible(true); }; const handleBatchOutSubmit = async () => { if (pendingOutItems.length === 0) { message.warning('没有待出库的耗材'); return; } setBatchOutSubmitting(true); let successCount = 0; let failCount = 0; const errors = []; for (const item of pendingOutItems) { try { await axios.post('/api/consumables/quick-inout', { consumableId: item.consumable.consumableId, type: 'out', quantity: item.quantity, operator: '系统管理员', reason: batchOutReason || '批量出库', notes: batchOutDevice ? `出库至设备: ${batchOutDevice.name}` : '', snList: item.snList, deviceId: batchOutDevice?.deviceId || null, }); successCount++; } catch (error) { failCount++; errors.push(`${item.consumable.name}: ${error.response?.data?.error || error.message}`); } } setBatchOutSubmitting(false); setBatchOutModalVisible(false); setPendingOutItems([]); setBatchOutDevice(null); setBatchOutDeviceSearch(''); setBatchOutReason(''); if (failCount === 0) { message.success({ content: `成功出库 ${successCount} 项${batchOutDevice ? `至设备 ${batchOutDevice.name}` : ''}`, icon: , }); } else { message.warning({ content: `出库完成: 成功 ${successCount} 项, 失败 ${failCount} 项`, icon: , }); if (errors.length > 0) { console.error('出库失败详情:', errors); } } 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: 240, fixed: 'right', render: (_, record) => ( { if (key === 'fields') { setExportModalVisible(true); } else { setExportMode(key); // 直接导出 setTimeout(() => { const params = { fields: exportFields.join(',') }; if (key === 'filtered') { params.keyword = keyword; params.category = category; params.status = status; } else if (key === 'selected') { params.ids = selectedRowKeys.join(','); } else if (key === 'warning') { params.stockStatus = 'warning'; params.keyword = keyword; params.category = category; } axios.get('/api/consumables/export', { params }) .then(res => { exportToCSV(res.data.consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`); message.success('导出成功'); }) .catch(() => message.error('导出失败')); }, 0); } }} > 导出全部 导出选中项 ({selectedRowKeys.length}) 字段选择... } trigger={['click']} > ) : ( 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
基本信息
} /> {/* 库存预警设置 */} {!editingConsumable && (
2
库存预警
{ setMaxStockUnlimited(e.target.checked); if (e.target.checked) { form.setFieldsValue({ maxStock: undefined }); } }} > 无限制
{!maxStockUnlimited && ( )}
)} {/* 初始入库(添加模式专用) */} {!editingConsumable && (
setCreateWithInbound(e.target.checked)} style={{ marginTop: '4px' }} />
同时进行初始入库 推荐
创建耗材后立即录入初始库存数量,生成入库记录 {createWithInbound && (
setInboundData({ ...inboundData, quantity: value || 0 }) } style={{ ...inputNumberStyles.base, width: '100%' }} placeholder="输入数量" /> setInboundData({ ...inboundData, operator: e.target.value }) } placeholder="输入操作人" style={inputStyles.form} /> setInboundData({ ...inboundData, reason: e.target.value }) } placeholder="输入入库原因" style={inputStyles.form} /> {/* SN序列号管理 */}
SN序列号(可选)
{inboundSnList.length > 0 ? (
{inboundSnList.map((sn, index) => ( { const newList = inboundSnList.filter((_, i) => i !== index); setInboundSnList(newList); setInboundData(prev => ({ ...prev, quantity: newList.length })); }} color="blue" style={{ marginBottom: '4px', marginRight: '4px' }} > {sn} ))}
) : ( 暂未添加SN序列号 )}
)} )} {/* 编辑模式下的库存预警设置 */} {editingConsumable && (
2
库存预警
{ setMaxStockUnlimited(e.target.checked); if (e.target.checked) { form.setFieldsValue({ maxStock: undefined }); } }} > 无限制
{!maxStockUnlimited && ( )}
)} {/* 编辑模式下的当前库存信息展示 */} {editingConsumable && (
当前库存信息 只读
如需调整库存,请使用「调整库存」功能
{editingConsumable.currentStock || 0}
当前库存 ({editingConsumable.unit})
{editingConsumable.snList?.length || 0}
已录入SN
{editingConsumable.currentStock <= editingConsumable.minStock ? '⚠️ 低' : '✓ 正常'}
库存状态
)} {/* 价格与状态 */}
3
价格与状态
{/* 其他信息 */}
4
其他信息
{/* 操作按钮 */}
{/* 导入耗材弹窗 - 全新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 (
{step.label}
{index < 2 && (
)} ); })}
{/* 步骤1: 上传文件 */} {importStep === 'upload' && ( {/* 拖拽上传区域 */} false} onChange={handleFileChange} showUploadList={false} style={{ borderRadius: '12px', overflow: 'hidden', marginBottom: '20px', }} >
点击或拖拽文件到此处上传 支持 .xlsx/.xls/.csv 格式
{/* 模板下载与配置区域 */} 模板下载与配置
} style={{ borderRadius: '16px', marginBottom: '20px', border: `1px solid ${designTokens.colors.neutral[200]}`, }} bodyStyle={{ padding: '24px' }} > {/* 库存处理方式选择 */}
库存处理方式 {importStockMode === 'basic' ? '仅基础信息' : '导入并入库'}
{importStockMode === 'basic' ? '模板仅包含基础信息字段,不含库存和SN' : '模板包含基础信息及入库相关字段(入库数量、SN等)'}
{/* 库存处理单选选项 */} setImportStockMode(e.target.value)} >
仅导入基础信息
库存初始化为 0
导入并入库
执行入库操作
{/* 字段说明 */} 模板字段说明 必填 可选
} style={{ borderRadius: '16px', border: `1px solid ${designTokens.colors.neutral[200]}`, }} bodyStyle={{ padding: '20px' }} >
{[ { field: '耗材 ID', desc: '留空自动生成;填写后可识别并更新现有耗材', required: false, icon: '🔑', }, { field: '名称', desc: '耗材名称,必填项', required: true, icon: '📝' }, { field: '分类', desc: '耗材分类,如"光模块"或"光纤跳线",必填', required: true, icon: '📂', }, { field: '单位', desc: '计量单位,如"个"、"根"、"箱",默认"个"', required: false, icon: '📏', }, ...(importStockMode === 'inbound' ? [ { field: '入库数量', desc: '入库的耗材数量,数字类型', required: true, icon: '📦', }, { field: '操作人', desc: '执行入库操作的人员,默认"系统"', required: false, icon: '👤', }, { field: '原因', desc: '入库原因,默认"批量导入入库"', required: false, icon: '📝', }, { field: 'SN 序列号', desc: '多个 SN 用逗号分隔,如"SN001,SN002"', 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: '状态', desc: '"active"启用,"inactive"停用,默认启用', required: false, icon: '✅', }, ].map((item, idx) => (
{item.icon}
{item.field} {item.required && ( 必填 )}
{item.desc}
))}
)} {/* 步骤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={ } 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' }} /> )} {/* 导入中状态 */} {importStep === 'importing' && (
{importJobStatus?.status === 'processing' ? '正在导入中...' : '准备导入...'} {importPhase} {importJobStatus && (
{importJobStatus.successCount || 0}
成功
{importJobStatus.skippedCount || 0}
跳过
{importJobStatus.failedCount || 0}
失败
已处理 {importJobStatus.processedItems || 0} / {importJobStatus.totalItems || 0} 条
)} )} {/* 步骤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={ } 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' && ( )}
)} {/* 字段选择弹窗 */} setExportModalVisible(false)} onOk={() => { setExportModalVisible(false); handleExport(); }} width={500} >
{exportableFields.map(field => ( {field.label} {field.required && 必选} ))}
{/* 入库/出库弹窗 */}
{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' && (