import React, { useState, useEffect, useCallback } from 'react'; import { Table, Button, Modal, Form, Input, Select, InputNumber, DatePicker, message, Card, Space, Tag, Row, Col, Statistic, Progress, Descriptions, Empty, Badge, Tooltip, Tabs, } from 'antd'; import { CheckCircleOutlined, CloseCircleOutlined, ExclamationCircleOutlined, SearchOutlined, ReloadOutlined, InboxOutlined, ScanOutlined, CheckOutlined, CloseOutlined, PlusOutlined, } from '@ant-design/icons'; import api from '../api'; import dayjs from 'dayjs'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { designTokens } from '../config/theme'; import CloseButton from '../components/CloseButton'; const InventoryTaskExecution = () => { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const [plan, setPlan] = useState(null); const [tasks, setTasks] = useState([]); const [currentTask, setCurrentTask] = useState(null); const [records, setRecords] = useState([]); const [loading, setLoading] = useState(false); const [checkModalVisible, setCheckModalVisible] = useState(false); const [currentRecord, setCurrentRecord] = useState(null); const [form] = Form.useForm(); const [activeTab, setActiveTab] = useState('1'); const [statusFilter, setStatusFilter] = useState(null); const [searchKeyword, setSearchKeyword] = useState(''); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchLoading, setBatchLoading] = useState(false); const [scanInput, setScanInput] = useState(''); const [scanResult, setScanResult] = useState(null); const [scanModalVisible, setScanModalVisible] = useState(false); const [recordPagination, setRecordPagination] = useState({ current: 1, pageSize: 20, total: 0 }); const [quickAddModalVisible, setQuickAddModalVisible] = useState(false); const [quickAddForm] = Form.useForm(); const [quickAddLoading, setQuickAddLoading] = useState(false); const [rooms, setRooms] = useState([]); const [racks, setRacks] = useState([]); const [deviceFields, setDeviceFields] = useState(null); // 初始值改为 null,表示未加载 const [selectedRoomId, setSelectedRoomId] = useState(null); const planId = searchParams.get('planId'); const fetchPlan = useCallback(async () => { if (!planId) return; setLoading(true); try { const res = await api.get(`/inventory/plans/${planId}`); setPlan(res.data.plan); setTasks(res.data.tasks || []); } catch (error) { message.error('获取盘点计划失败'); } finally { setLoading(false); } }, [planId]); const fetchTaskRecords = async taskId => { try { const res = await api.get(`/inventory/tasks/${taskId}`); setCurrentTask(res.data.task); setRecords(res.data.records || []); setRecordPagination(prev => ({ ...prev, total: res.data.records?.length || 0, current: 1 })); setSelectedRowKeys([]); setActiveTab('2'); } catch (error) { message.error('获取盘点记录失败'); } }; const filteredRecords = records.filter(record => { if (statusFilter && record.status !== statusFilter) return false; if (searchKeyword) { const keyword = searchKeyword.toLowerCase(); return ( (record.deviceName && record.deviceName.toLowerCase().includes(keyword)) || (record.deviceId && record.deviceId.toLowerCase().includes(keyword)) || (record.serialNumber && record.serialNumber.toLowerCase().includes(keyword)) ); } return true; }); const handleBatchCheck = async status => { if (selectedRowKeys.length === 0) { message.warning('请先选择要盘点的记录'); return; } setBatchLoading(true); try { const promises = selectedRowKeys.map(recordId => api.post(`/inventory/records/${recordId}/check`, { status, checkedAt: new Date().toISOString(), }) ); await Promise.all(promises); message.success(`已批量标记 ${selectedRowKeys.length} 条记录`); if (currentTask) { fetchTaskRecords(currentTask.taskId); } } catch (error) { message.error('批量操作失败'); } finally { setBatchLoading(false); setSelectedRowKeys([]); } }; const handleQuickCheckAll = async status => { const pendingRecords = filteredRecords.filter(r => r.status === 'pending'); if (pendingRecords.length === 0) { message.info('没有待盘点的记录'); return; } setBatchLoading(true); try { const promises = pendingRecords.map(record => api.post(`/inventory/records/${record.recordId}/check`, { status, checkedAt: new Date().toISOString(), }) ); await Promise.all(promises); message.success(`已一键标记 ${pendingRecords.length} 条记录`); if (currentTask) { fetchTaskRecords(currentTask.taskId); } } catch (error) { message.error('操作失败'); } finally { setBatchLoading(false); } }; const handleScan = async sn => { if (!sn || sn.trim() === '') return; const trimmedSN = sn.trim().toUpperCase(); const matchedRecord = records.find( r => r.serialNumber && r.serialNumber.toUpperCase() === trimmedSN ); if (!matchedRecord) { setScanResult({ success: false, message: `未找到序列号为 "${sn}" 的设备`, sn: trimmedSN }); setScanModalVisible(true); return; } if (matchedRecord.status !== 'pending') { setScanResult({ success: false, message: `设备 "${matchedRecord.deviceName}" 已盘点`, record: matchedRecord, sn: trimmedSN, }); setScanModalVisible(true); return; } try { await api.post(`/inventory/records/${matchedRecord.recordId}/check`, { status: 'normal', actualSerialNumber: matchedRecord.serialNumber, checkedAt: new Date().toISOString(), }); setScanResult({ success: true, message: `设备 "${matchedRecord.deviceName}" 盘点成功!`, record: matchedRecord, sn: trimmedSN, }); setScanModalVisible(true); if (currentTask) { fetchTaskRecords(currentTask.taskId); } } catch (error) { message.error('盘点失败'); } }; const handleScanInput = e => { const value = e.target.value; setScanInput(value); if (value.includes('\n') || value.includes('\r')) { const sn = value.replace(/[\r\n]/g, '').trim(); if (sn) { handleScan(sn); setScanInput(''); } } }; const handleScanKeyPress = e => { if (e.key === 'Enter') { e.preventDefault(); const sn = e.target.value.trim(); if (sn) { handleScan(sn); setScanInput(''); } } }; const handleScanClick = () => { const sn = scanInput.trim(); if (sn) { handleScan(sn); setScanInput(''); } else { message.warning('请输入序列号'); } }; useEffect(() => { fetchPlan(); }, [fetchPlan]); const defaultDeviceFields = [ { fieldName: 'deviceName', displayName: '设备名称', fieldType: 'string', required: true, visible: true, order: 1, }, { fieldName: 'deviceType', displayName: '设备类型', fieldType: 'select', required: true, visible: true, order: 2, options: [ { value: 'server', label: '服务器' }, { value: 'switch', label: '交换机' }, { value: 'router', label: '路由器' }, { value: 'storage', label: '存储设备' }, { value: 'other', label: '其他' }, ], }, { fieldName: 'model', displayName: '型号', fieldType: 'string', required: false, visible: true, order: 3, }, { fieldName: 'serialNumber', displayName: '序列号', fieldType: 'string', required: true, visible: true, order: 4, }, { fieldName: 'roomId', displayName: '所属机房', fieldType: 'select', required: false, visible: true, order: 5, }, { fieldName: 'rackId', displayName: '所属机柜', fieldType: 'select', required: false, visible: true, order: 6, }, { fieldName: 'position', displayName: '位置(U)', fieldType: 'number', required: false, visible: true, order: 7, }, { fieldName: 'height', displayName: '高度(U)', fieldType: 'number', required: false, visible: true, order: 8, }, { fieldName: 'powerConsumption', displayName: '功率(W)', fieldType: 'number', required: false, visible: true, order: 9, }, { fieldName: 'purchaseDate', displayName: '购买日期', fieldType: 'date', required: false, visible: true, order: 10, }, { fieldName: 'warrantyExpiry', displayName: '保修到期', fieldType: 'date', required: false, visible: true, order: 11, }, { fieldName: 'ipAddress', displayName: 'IP地址', fieldType: 'string', required: false, visible: true, order: 12, }, { fieldName: 'brand', displayName: '品牌', fieldType: 'string', required: false, visible: true, order: 13, }, { fieldName: 'description', displayName: '描述', fieldType: 'textarea', required: false, visible: true, order: 14, }, { fieldName: 'remark', displayName: '备注', fieldType: 'textarea', required: false, visible: true, order: 15, }, ]; const fetchDeviceFields = async () => { try { const res = await api.get('/deviceFields'); const sortedFields = res.data.sort((a, b) => a.order - b.order); setDeviceFields(sortedFields); } catch (error) { console.error('获取字段配置失败:', error); setDeviceFields(defaultDeviceFields); } }; const fetchRooms = async () => { try { const res = await api.get('/rooms'); setRooms(res.data.rooms || res.data || []); } catch (error) { console.error('获取机房列表失败', error); } }; const fetchRacks = async () => { try { const res = await api.get('/racks', { params: { pageSize: 1000 } }); setRacks(res.data.racks || res.data || []); } catch (error) { console.error('获取机柜列表失败', error); } }; useEffect(() => { fetchRooms(); fetchRacks(); fetchDeviceFields(); }, []); const filteredRacks = selectedRoomId ? racks.filter(rack => rack.roomId === selectedRoomId) : racks; const renderQuickAddFormField = field => { const { fieldName, displayName, fieldType, required, options } = field; // 统一的表单项样式 const formItemStyle = { marginBottom: 16, }; // 统一的输入框样式 const inputStyle = { borderRadius: designTokens.borderRadius.medium, }; if (fieldName === 'roomId' || fieldName === 'rackId') { // 机房和机柜字段已在Modal中单独处理 return null; } if (fieldName === 'deviceType') { return ( ); } if (fieldType === 'select') { return ( ); } if (fieldType === 'number') { return ( ); } if (fieldType === 'date') { return ( ); } if (fieldType === 'text' || fieldType === 'textarea') { return ( ); } return ( ); }; const renderQuickAddFormFields = () => { // 如果字段配置未加载,使用默认配置 const fields = deviceFields || defaultDeviceFields; // 排除机房和机柜字段(已在Modal中单独渲染) const visibleFields = fields.filter( f => f.visible !== false && f.fieldName !== 'serialNumber' && f.fieldName !== 'roomId' && f.fieldName !== 'rackId' ); const sortedFields = visibleFields.sort((a, b) => a.order - b.order); // 将字段分成两列 const leftFields = []; const rightFields = []; const fullFields = []; sortedFields.forEach((field, index) => { // 文本域类型独占一行 if (field.fieldType === 'text') { fullFields.push(field); } else { // 其他字段交替排列 if (index % 2 === 0) { leftFields.push(field); } else { rightFields.push(field); } } }); return ( <> {/* 两列布局的字段 */} {leftFields.map(renderQuickAddFormField)} {rightFields.map(renderQuickAddFormField)} {/* 独占一行的字段(如备注) */} {fullFields.map(renderQuickAddFormField)} ); }; const handleQuickAddDevice = async values => { if (!plan) { message.error('盘点计划不存在'); return; } setQuickAddLoading(true); try { const { purchaseDate, warrantyExpiry, ...otherValues } = values; const payload = { taskId: currentTask?.taskId || null, planId: plan.planId, serialNumber: scanResult?.sn || values.serialNumber, ...otherValues, purchaseDate: purchaseDate ? dayjs(purchaseDate).toISOString() : null, warrantyExpiry: warrantyExpiry ? dayjs(warrantyExpiry).toISOString() : null, }; const res = await api.post('/inventory/quick-add-device', payload); message.success('设备已暂存!'); setQuickAddModalVisible(false); setSelectedRoomId(null); quickAddForm.resetFields(); setScanResult({ success: true, message: `设备 "${res.data.pendingDevice.deviceName}" 已暂存,请前往「暂存设备」页面完善信息后同步到设备管理`, pendingDevice: res.data.pendingDevice, sn: res.data.pendingDevice.serialNumber, }); } catch (error) { message.error(error.response?.data?.error || '暂存设备失败'); } finally { setQuickAddLoading(false); } }; const openQuickAddModal = () => { setSelectedRoomId(null); quickAddForm.setFieldsValue({ serialNumber: scanResult?.sn || '', deviceName: '', deviceType: 'other', roomId: undefined, rackId: undefined, position: undefined, remark: undefined, }); setQuickAddModalVisible(true); }; const handleCheck = record => { setCurrentRecord(record); form.setFieldsValue({ actualSerialNumber: record.serialNumber, actualRackId: record.rackId, actualPosition: record.position, status: record.status === 'pending' ? null : record.status, remark: record.remark, }); setCheckModalVisible(true); }; const handleQuickCheck = async (record, status) => { try { await api.post(`/inventory/records/${record.recordId}/check`, { status, checkedAt: new Date().toISOString(), }); message.success(status === 'normal' ? '标记正常' : '标记异常'); if (currentTask) { fetchTaskRecords(currentTask.taskId); } fetchPlan(); } catch (error) { message.error(error.response?.data?.error || '标记失败'); } }; const handleSubmitCheck = async values => { if (!currentRecord) return; try { await api.post(`/inventory/records/${currentRecord.recordId}/check`, { actualSerialNumber: values.actualSerialNumber, actualRackId: values.actualRackId, actualPosition: values.actualPosition, status: values.status, remark: values.remark, }); message.success('盘点提交成功'); setCheckModalVisible(false); if (currentTask) { fetchTaskRecords(currentTask.taskId); } fetchPlan(); } catch (error) { message.error(error.response?.data?.error || '提交失败'); } }; const getStatusTag = status => { const statusMap = { pending: { color: 'default', text: '待盘点', icon: }, normal: { color: 'success', text: '正常', icon: }, abnormal: { color: 'error', text: '异常', icon: }, missed: { color: 'warning', text: '漏盘', icon: }, }; const config = statusMap[status] || statusMap.pending; return ( {config.text} ); }; const getAbnormalTypeTag = type => { const typeMap = { serial_mismatch: '序列号不符', position_mismatch: '位置不符', device_missing: '设备缺失', extra_device: '多出设备', }; return typeMap[type] || type; }; const taskColumns = [ { title: '任务信息', key: 'taskInfo', render: (_, record) => (
{record.targetName}
{record.targetType === 'room' ? '机房' : record.targetType === 'rack' ? '机柜' : '全部设备'}
), }, { title: '状态', dataIndex: 'status', key: 'status', width: 100, render: status => { const statusMap = { pending: , in_progress: , completed: , skipped: , }; return statusMap[status] || status; }, }, { title: '设备进度', key: 'progress', width: 150, render: (_, record) => ( 0 ? Math.round((record.checkedDevices / record.totalDevices) * 100) : 0 } size="small" format={() => `${record.checkedDevices}/${record.totalDevices}`} /> ), }, { title: '异常', dataIndex: 'abnormalDevices', key: 'abnormalDevices', width: 80, render: count => (count > 0 ? {count} : '-'), }, { title: '操作', key: 'action', width: 100, render: (_, record) => ( ), }, ]; const recordColumns = [ { title: '设备信息', key: 'deviceInfo', width: 180, render: (_, record) => (
{record.deviceName}
{record.deviceId}
), }, { title: '类型', dataIndex: 'deviceType', key: 'deviceType', width: 80, render: type => { const typeMap = { server: '服务器', switch: '交换机', router: '路由器', storage: '存储设备', other: '其他', }; return typeMap[type] || type; }, }, { title: '系统序列号', dataIndex: 'serialNumber', key: 'serialNumber', width: 140, render: sn => {sn}, }, { title: '实际序列号', dataIndex: 'actualSerialNumber', key: 'actualSerialNumber', width: 140, render: (sn, record) => (
{sn ? ( {sn} ) : ( - )} {record.abnormalType === 'serial_mismatch' && ( 不符 )}
), }, { title: '系统位置', dataIndex: 'displayLocation', key: 'displayLocation', width: 180, render: (_, record) => ( {record.displayLocation || `${record.rackId} - U${record.position}`} ), }, { title: '实际位置', key: 'actualPosition', width: 100, render: (_, record) => (
{record.actualRackId ? (
{record.actualRackId} - U{record.actualPosition}
) : ( - )} {record.abnormalType === 'position_mismatch' && ( 不符 )}
), }, { title: '状态', dataIndex: 'status', key: 'status', width: 90, render: status => getStatusTag(status), }, { title: '盘点人', dataIndex: ['Checker', 'realName'], key: 'checker', width: 90, render: name => name || '-', }, { title: '盘点时间', dataIndex: 'checkedAt', key: 'checkedAt', width: 140, render: date => (date ? dayjs(date).format('MM-DD HH:mm') : '-'), }, { title: '操作', key: 'action', width: 120, fixed: 'right', render: (_, record) => ( {record.status === 'pending' ? ( <> ), }, ]; const pageContainerStyle = { padding: '24px', background: designTokens.colors.background.secondary, minHeight: '100vh', }; if (!planId) { return (
); } return (
{plan?.name || '加载中...'}
计划ID: {planId}
{plan?.totalDevices > 0 && ( 0 ? 'exception' : 'active'} style={{ marginTop: 16 }} /> )}
), }, { key: '2', label: ( 盘点记录 {records.length > 0 && ( {filteredRecords.length}/{records.length} )} ), children: (
} style={{ width: 250, height: 32 }} className="统一输入框" allowClear value={searchKeyword} onChange={e => { setSearchKeyword(e.target.value); setRecordPagination(prev => ({ ...prev, current: 1 })); }} />
{records.length > 0 && ( r.status !== 'pending').length / records.length) * 100 )} status="active" strokeColor={{ from: '#108ee9', to: '#87d068' }} style={{ marginBottom: 16 }} /> )} `共 ${total} 条`, onChange: (page, pageSize) => { setRecordPagination(prev => ({ ...prev, current: page, pageSize })); }, }} scroll={{ x: 1400 }} loading={batchLoading} locale={{ emptyText: ( ), }} /> ), }, ]} /> { setScanModalVisible(false); setScanInput(''); }} footer={null} width={560} centered closeIcon={null} styles={{ header: { display: 'none' }, body: { padding: 0 }, content: { borderRadius: 16, overflow: 'hidden' }, }} > {/* 自定义 Header */}
扫码盘点
setScanInput(e.target.value)} onKeyDown={handleScanKeyPress} variant="outlined" style={{ fontSize: 16, textAlign: 'center', height: 40, borderRadius: 8, boxShadow: '0 2px 8px rgba(24, 144, 255, 0.1)', width: 400, }} suffix={ } autoFocus />
扫码枪扫描或手动输入序列号后按回车 / 点击盘点
{scanResult && ( {/* 顶部状态条 */}
{scanResult.success ? ( ) : ( )}
{scanResult.success ? '盘点成功' : '未找到设备'}
{scanResult.message}
{/* 设备信息卡片 */} {scanResult.record && (
设备信息
设备ID
{scanResult.record.deviceId}
设备名称
{scanResult.record.deviceName}
序列号
{scanResult.record.serialNumber}
当前位置
{scanResult.record.displayLocation || `${scanResult.record.rackId}`} U{scanResult.record.position}
)} {/* 暂存设备信息 */} {scanResult.pendingDevice && (
暂存设备信息
暂存ID
{scanResult.pendingDevice.pendingId}
设备名称
{scanResult.pendingDevice.deviceName}
序列号
{scanResult.pendingDevice.serialNumber}
设备类型
{scanResult.pendingDevice.deviceType}
)} {/* 快速添加按钮 */} {!scanResult.success && scanResult.sn && (
)} )} {scanResult && (
)} {!scanResult && (
r.status !== 'pending').length} suffix={`/ ${records.length}`} valueStyle={{ color: '#52c41a', fontWeight: 'bold' }} /> 0 ? Math.round( (records.filter(r => r.status !== 'pending').length / records.length) * 100 ) : 0 } strokeColor={{ from: '#1890ff', to: '#52c41a' }} style={{ marginTop: 12 }} />
)} } onCancel={() => setCheckModalVisible(false)} footer={null} width={600} > {currentRecord && (
{currentRecord.deviceId} {currentRecord.deviceName} {currentRecord.serialNumber} {currentRecord.displayLocation || `${currentRecord.rackId} - U${currentRecord.position}`} )}
} onCancel={() => { setQuickAddModalVisible(false); setSelectedRoomId(null); }} footer={null} width={640} centered styles={{ body: { padding: 0 }, }} >
快速添加设备
将扫描到的设备添加到系统
{/* 序列号显示 - 突出显示 */} 设备序列号 } rules={[{ required: true, message: '请输入序列号' }]} > {/* 位置信息区域 */}
位置信息(可选)
{/* 设备详细信息 */}
设备详细信息
{renderQuickAddFormFields()}
{/* 操作按钮 */}
); }; export default InventoryTaskExecution;