isClickable && onPortClick(port)}
+ onClick={() => {
+ console.log('Port clicked:', port);
+ if (isClickable) {
+ console.log('Port is clickable, calling onPortClick');
+ onPortClick(port);
+ }
+ }}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
- padding: '4px',
+ padding: '6px 4px',
cursor: isClickable ? 'pointer' : 'not-allowed',
- transition: 'all 0.2s ease',
+ transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative',
minWidth: '0',
+ pointerEvents: isClickable ? 'auto' : 'none',
+ transform: isSelected ? 'scale(1.08)' : 'scale(1)',
+ boxShadow: isSelected
+ ? '0 0 0 4px rgba(24,144,255,0.15), 0 8px 25px rgba(24,144,255,0.25)'
+ : isClickable
+ ? '0 0 0 0 rgba(24,144,255,0)'
+ : 'none',
}}
>
+ {/* 选中状态标记 - 更明显的视觉反馈 */}
+ {isSelected && (
+
+
+
+ )}
{/* LED 指示灯 - 在端口上方 */}
@@ -463,22 +509,28 @@ const PortPanel = ({
style={{
width: '100%',
aspectRatio: '1 / 1.2',
- background: 'linear-gradient(180deg, #2a3441 0%, #1e2530 100%)',
- border: `2px solid ${statusColor}`,
- borderRadius: '2px',
+ background: isSelected
+ ? 'linear-gradient(180deg, #e6f7ff 0%, #bae7ff 100%)'
+ : 'linear-gradient(180deg, #2a3441 0%, #1e2530 100%)',
+ border: `2px solid ${isSelected ? '#1890ff' : statusColor}`,
+ borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
- boxShadow: `inset 0 1px 0 rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.3)`,
+ boxShadow: isSelected
+ ? 'inset 0 2px 4px rgba(24,144,255,0.2), 0 4px 12px rgba(24,144,255,0.15)'
+ : 'inset 0 1px 0 rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.3)',
+ transition: 'all 0.2s ease',
}}
>
{/* 端口内部图标 */}
{getPortTypeIcon(port.portType)}
@@ -504,15 +556,16 @@ const PortPanel = ({
{/* 端口名称 - 在端口下方 */}
{getPortDisplayName(port.portName)}
diff --git a/frontend/src/pages/CableManagement.jsx b/frontend/src/pages/CableManagement.jsx
index b219def..bae3b9e 100644
--- a/frontend/src/pages/CableManagement.jsx
+++ b/frontend/src/pages/CableManagement.jsx
@@ -54,6 +54,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { designTokens } from '../config/theme';
import { debounce } from '../utils/common';
import CloseButton from '../components/CloseButton';
+import CableWizardModal from '../components/CableWizardModal';
const { Option } = Select;
const { Panel } = Collapse;
@@ -127,6 +128,9 @@ function CableManagement() {
const [editingCable, setEditingCable] = useState(null);
const [form] = Form.useForm();
+ const [wizardVisible, setWizardVisible] = useState(false);
+ const [wizardInitialSourceDevice, setWizardInitialSourceDevice] = useState(null);
+
const [importModalVisible, setImportModalVisible] = useState(false);
const [importFileList, setImportFileList] = useState([]);
const [importPreview, setImportPreview] = useState([]);
@@ -284,24 +288,14 @@ function CableManagement() {
};
const handleAdd = () => {
- setEditingCable(null);
- form.resetFields();
- setModalVisible(true);
+ setWizardInitialSourceDevice(null);
+ setWizardVisible(true);
};
const handleEdit = cable => {
setEditingCable(cable);
- form.setFieldsValue({
- sourceDeviceId: cable.sourceDeviceId,
- sourcePort: cable.sourcePort,
- targetDeviceId: cable.targetDeviceId,
- targetPort: cable.targetPort,
- cableType: cable.cableType,
- cableLength: cable.cableLength,
- status: cable.status,
- description: cable.description,
- });
- setModalVisible(true);
+ setWizardInitialSourceDevice(null);
+ setWizardVisible(true);
};
const handleDelete = async cableId => {
@@ -1243,9 +1237,8 @@ function CableManagement() {
type="text"
icon={
}
onClick={() => {
- setEditingCable(null);
- form.setFieldsValue({ sourceDeviceId: switchId });
- setModalVisible(true);
+ setWizardInitialSourceDevice(switchData.switch);
+ setWizardVisible(true);
}}
style={{ color: designTokens.colors.primary.main }}
/>
@@ -1510,6 +1503,22 @@ function CableManagement() {
+ {/* 向导式接线创建弹窗 */}
+
{
+ setWizardVisible(false);
+ setWizardInitialSourceDevice(null);
+ setEditingCable(null);
+ }}
+ onSuccess={() => {
+ setEditingCable(null);
+ fetchCables();
+ }}
+ initialSourceDevice={wizardInitialSourceDevice}
+ editingCable={editingCable}
+ />
+
{/* 批量导入弹窗 */}
{
@@ -373,7 +404,7 @@ function ConsumableManagement() {
const jsonData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 });
if (jsonData.length < 2) {
- resolve([]);
+ resolve({ data: [], headers: [] });
return;
}
@@ -391,7 +422,7 @@ function ConsumableManagement() {
}
}
- resolve(result);
+ resolve({ data: result, headers });
} catch (error) {
reject(error);
}
@@ -401,6 +432,31 @@ function ConsumableManagement() {
});
};
+ 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);
@@ -425,6 +481,78 @@ function ConsumableManagement() {
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) {
@@ -432,7 +560,7 @@ function ConsumableManagement() {
setImporting(true);
setImportPhase('正在解析文件...');
- const parsedData = await parseFile(file.originFileObj);
+ const { data: parsedData, headers: detectedHeadersList } = await parseFile(file.originFileObj);
if (parsedData.length === 0) {
message.warning('文件中没有有效数据');
@@ -440,6 +568,13 @@ function ConsumableManagement() {
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);
@@ -469,6 +604,17 @@ function ConsumableManagement() {
setImportMode('create');
setImportValidationErrors([]);
setImportResult(null);
+ setFieldMappings({});
+ setDetectedHeaders([]);
+ setImportJobId(null);
+ setImportJobStatus(null);
+ if (pollingInterval) {
+ clearInterval(pollingInterval);
+ setPollingInterval(null);
+ }
+ if (availableFields.length === 0) {
+ fetchFieldMappings();
+ }
};
const handleImportCancel = () => {
@@ -480,6 +626,14 @@ function ConsumableManagement() {
setImportResult(null);
setImportStep('upload');
setImportValidationErrors([]);
+ setFieldMappings({});
+ setDetectedHeaders([]);
+ setImportJobId(null);
+ setImportJobStatus(null);
+ if (pollingInterval) {
+ clearInterval(pollingInterval);
+ setPollingInterval(null);
+ }
};
const handleImport = async () => {
@@ -489,50 +643,74 @@ function ConsumableManagement() {
}
setImporting(true);
- setImportProgress(10);
+ setImportProgress(0);
setImportPhase('准备导入数据...');
setImportResult(null);
+ setImportStep('importing');
+
+ const useBackgroundMode = importPreview.length > 500;
try {
- setImportProgress(30);
- setImportPhase('正在提交到服务器...');
+ if (useBackgroundMode) {
+ 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: ,
+ const response = await axios.post('/api/consumables/background', {
+ items: importPreview,
+ mode: importMode,
+ fieldMapping: fieldMappings,
});
- }
- fetchConsumables();
+ 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,
+ });
+
+ 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);
- } finally {
setImporting(false);
+ setImportStep('preview');
}
};
const downloadTemplate = () => {
const template = [
{
- 耗材ID: '',
+ 耗材ID: 'CON001',
名称: '示例耗材-网络模块',
分类: '光模块',
单位: '个',
@@ -548,12 +726,117 @@ function ConsumableManagement() {
},
];
- const ws = XLSX.utils.json_to_sheet(template);
+ const fieldDescription = [
+ {
+ 字段名: '耗材ID',
+ 系统字段: 'consumableId',
+ 必填: '否',
+ 说明: '耗材唯一标识符,留空自动生成;填写后可识别并更新现有耗材',
+ 示例: 'CON001',
+ },
+ {
+ 字段名: '名称',
+ 系统字段: 'name',
+ 必填: '是',
+ 说明: '耗材名称',
+ 示例: '光纤跳线',
+ },
+ {
+ 字段名: '分类',
+ 系统字段: 'category',
+ 必填: '是',
+ 说明: '耗材分类,如"光模块"或"光纤跳线",需先在系统中创建该分类',
+ 示例: '光模块',
+ },
+ {
+ 字段名: '单位',
+ 系统字段: 'unit',
+ 必填: '否',
+ 说明: '计量单位,如"个"、"根"、"箱",默认"个"',
+ 示例: '个',
+ },
+ {
+ 字段名: '当前库存',
+ 系统字段: 'currentStock',
+ 必填: '否',
+ 说明: '当前库存数量,数字类型',
+ 示例: '100',
+ },
+ {
+ 字段名: '最小库存',
+ 系统字段: 'minStock',
+ 必填: '否',
+ 说明: '安全库存阈值,低于此值会触发预警',
+ 示例: '10',
+ },
+ {
+ 字段名: '最大库存',
+ 系统字段: 'maxStock',
+ 必填: '否',
+ 说明: '最大库存限制,0表示无限制',
+ 示例: '500',
+ },
+ {
+ 字段名: '单价',
+ 系统字段: 'unitPrice',
+ 必填: '否',
+ 说明: '耗材单价,数字类型',
+ 示例: '5.00',
+ },
+ {
+ 字段名: '供应商',
+ 系统字段: 'supplier',
+ 必填: '否',
+ 说明: '耗材供应商名称',
+ 示例: 'XX科技有限公司',
+ },
+ {
+ 字段名: '存放位置',
+ 系统字段: 'location',
+ 必填: '否',
+ 说明: '仓库内存放位置,如"A柜-01层"',
+ 示例: 'A柜-01层',
+ },
+ {
+ 字段名: '描述',
+ 系统字段: 'description',
+ 必填: '否',
+ 说明: '耗材的详细描述或备注',
+ 示例: '这是一条测试数据',
+ },
+ {
+ 字段名: 'SN序列号',
+ 系统字段: 'snList',
+ 必填: '否',
+ 说明: '多个SN用逗号、分号或换行分隔,如"SN001,SN002"或"SN001\\nSN002"',
+ 示例: 'SN001,SN002,SN003',
+ },
+ {
+ 字段名: '状态',
+ 系统字段: 'status',
+ 必填: '否',
+ 说明: '"active"启用,"inactive"停用,默认启用',
+ 示例: 'active',
+ },
+ ];
+
const wb = XLSX.utils.book_new();
- XLSX.utils.book_append_sheet(wb, ws, '耗材导入模板');
+
+ 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('模板下载成功');
+ message.success({
+ content: '模板下载成功(包含3个工作表)',
+ icon: ,
+ });
};
const downloadFailedRecords = () => {
@@ -655,6 +938,37 @@ function ConsumableManagement() {
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()) {
@@ -727,10 +1041,38 @@ function ConsumableManagement() {
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 });
+ 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('');
@@ -754,6 +1096,8 @@ function ConsumableManagement() {
form,
showStockModal,
stockForm,
+ pendingOutItems,
+ addToPendingOut,
]
);
@@ -786,6 +1130,178 @@ function ConsumableManagement() {
[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(
() => [
{
@@ -2344,7 +2860,7 @@ function ConsumableManagement() {
点击或拖拽文件到此处上传
- 支持 .xlsx、.xls、.csv 格式,文件大小不超过 10MB
+ 支持 Excel (.xlsx/.xls)、CSV (.csv) 格式,文件大小不超过 10MB
)}
+ {/* 导入中状态 */}
+ {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 && (
)}
+ {scanMode === 'out' && pendingOutItems.length > 0 && (
+
+
+ 待出库列表 ({pendingOutItems.length} 项)
+
+
+ {pendingOutItems.map(item => (
+ removeFromPendingOut(item.consumable.consumableId)}
+ color="red"
+ style={{ marginBottom: '4px', marginRight: '8px' }}
+ >
+ {item.consumable.name} × {item.quantity}
+ {item.snList.length > 0 && ` (${item.snList.length} SN)`}
+
+ ))}
+
+
+
+ )}
+
💡 提示:也可手动输入条码后按回车键确认
+
+ {/* 扫码快速出库到设备弹窗 */}
+
+
+ 扫码出库
+
+ }
+ open={quickOutModalVisible}
+ closeIcon={}
+ onCancel={() => setQuickOutModalVisible(false)}
+ footer={null}
+ width={520}
+ destroyOnClose
+ >
+ {quickOutConsumable && (
+
+
+
+
+
+ {quickOutConsumable.category?.charAt(0) || '耗'}
+
+
+
+
+ {quickOutConsumable.name}
+
+
+ {quickOutConsumable.category} · 库存: {quickOutConsumable.currentStock} {quickOutConsumable.unit}
+
+ {quickOutConsumable.location && (
+
+ 📍 {quickOutConsumable.location}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
({
+ value: d.name,
+ label: (
+
+
{d.name}
+
+ {d.type} · {d.location ? `${d.location.rackName || ''} ${d.location.roomName || ''}` : '未绑定机柜'}
+
+
+ ),
+ }))}
+ onSearch={handleQuickOutDeviceSearch}
+ onSelect={(value, option) => {
+ const device = quickOutDeviceList.find(d => d.name === value);
+ setQuickOutDevice(device);
+ setQuickOutDeviceSearch(device?.name || '');
+ }}
+ onChange={value => {
+ setQuickOutDeviceSearch(value);
+ if (!value) {
+ setQuickOutDevice(null);
+ }
+ }}
+ placeholder="搜索设备名称/ID/序列号"
+ style={{ width: '100%' }}
+ loading={quickOutDeviceLoading}
+ />
+ {quickOutDevice && (
+
+
+
+
+ 已选择: {quickOutDevice.name}
+
+
+ {quickOutDevice.location && (
+
+ 📍 {quickOutDevice.location.roomName} · {quickOutDevice.location.rackName}
+
+ )}
+
+ )}
+
+
+
+
+ setQuickOutReason(e.target.value)}
+ placeholder="请输入出库原因"
+ autoSize={{ minRows: 2, maxRows: 4 }}
+ style={{ borderRadius: '8px' }}
+ />
+
+
+ {quickOutSnList.length > 0 && (
+
+
+
+ {quickOutSnList.map((sn, index) => (
+
+ {sn}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* 批量出库确认弹窗 */}
+
+
+ 批量出库确认 ({pendingOutItems.length} 项)
+
+ }
+ open={batchOutModalVisible}
+ closeIcon={}
+ onCancel={() => setBatchOutModalVisible(false)}
+ footer={null}
+ width={600}
+ destroyOnClose
+ >
+
+
+
+
+
+
+ {pendingOutItems.map((item, index) => (
+
+
+
+ {item.consumable.name}
+
+ {item.consumable.category} · 库存: {item.consumable.currentStock}
+ {item.snList.length > 0 && ` · SN: ${item.snList.length}个`}
+
+ {item.snList.length > 0 && (
+
+ {item.snList.slice(0, 5).map((sn, i) => (
+
+ {sn}
+
+ ))}
+ {item.snList.length > 5 && (
+ +{item.snList.length - 5} 更多
+ )}
+
+ )}
+
+
+
+ × {item.quantity}
+
+
+
+ }
+ onClick={() => removeFromPendingOut(item.consumable.consumableId)}
+ />
+
+
+
+ ))}
+
+
+
+
+
+
({
+ value: d.name,
+ label: (
+
+
{d.name}
+
+ {d.type} · {d.location ? `${d.location.rackName || ''} ${d.location.roomName || ''}` : '未绑定机柜'}
+
+
+ ),
+ }))}
+ onSearch={handleBatchOutDeviceSearch}
+ onSelect={(value, option) => {
+ const device = batchOutDeviceList.find(d => d.name === value);
+ setBatchOutDevice(device);
+ setBatchOutDeviceSearch(device?.name || '');
+ }}
+ onChange={value => {
+ setBatchOutDeviceSearch(value);
+ if (!value) {
+ setBatchOutDevice(null);
+ }
+ }}
+ placeholder="搜索设备名称/ID/序列号"
+ style={{ width: '100%' }}
+ loading={batchOutDeviceLoading}
+ />
+ {batchOutDevice && (
+
+
+
+
+ 已选择: {batchOutDevice.name}
+
+
+ {batchOutDevice.location && (
+
+ 📍 {batchOutDevice.location.roomName} · {batchOutDevice.location.rackName}
+
+ )}
+
+ )}
+
+
+
+
+ setBatchOutReason(e.target.value)}
+ placeholder="请输入出库原因"
+ autoSize={{ minRows: 2, maxRows: 4 }}
+ style={{ borderRadius: '8px' }}
+ />
+
+
+
+
+
+
+
+
+ sum + item.quantity, 0)}
+ valueStyle={{ fontSize: '20px', color: designTokens.colors.error.main }}
+ />
+
+
+ sum + item.snList.length, 0)}
+ valueStyle={{ fontSize: '20px', color: designTokens.colors.primary.main }}
+ />
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/package-lock.json b/package-lock.json
index 602f5a5..0cfbc0e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,12 +9,67 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
+ "@ant-design/cssinjs": "^2.1.2",
"papaparse": "^5.5.3"
},
"devDependencies": {
"concurrently": "^9.2.1"
}
},
+ "node_modules/@ant-design/cssinjs": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz",
+ "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.11.1",
+ "@emotion/hash": "^0.8.0",
+ "@emotion/unitless": "^0.7.5",
+ "@rc-component/util": "^1.4.0",
+ "clsx": "^2.1.1",
+ "csstype": "^3.1.3",
+ "stylis": "^4.3.4"
+ },
+ "peerDependencies": {
+ "react": ">=16.0.0",
+ "react-dom": ">=16.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
+ "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/unitless": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
+ "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
+ "license": "MIT"
+ },
+ "node_modules/@rc-component/util": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.0.tgz",
+ "integrity": "sha512-aY9GLBuiUdpyfIUpAWSYer4Tu3mVaZCo5A0q9NtXcazT3MRiI3/WNHCR+DUn5VAtR6iRRf0ynCqQUcHli5UdYw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-mobile": "^5.0.0",
+ "react-is": "^18.2.0"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0",
+ "react-dom": ">=18.0.0"
+ }
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -86,6 +141,15 @@
"node": ">=12"
}
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -131,6 +195,12 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -178,12 +248,47 @@
"node": ">=8"
}
},
+ "node_modules/is-mobile": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz",
+ "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==",
+ "license": "MIT"
+ },
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
+ "node_modules/react": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.4"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -204,6 +309,12 @@
"tslib": "^2.1.0"
}
},
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
@@ -245,6 +356,12 @@
"node": ">=8"
}
},
+ "node_modules/stylis": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
+ "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
+ "license": "MIT"
+ },
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
diff --git a/package.json b/package.json
index 48ba552..4fc426e 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"concurrently": "^9.2.1"
},
"dependencies": {
+ "@ant-design/cssinjs": "^2.1.2",
"papaparse": "^5.5.3"
}
}