Files
yunrui_asset/frontend/src/components/CableWizardModal.jsx
T
zhang1106 06e3b56469 feat(线缆管理): 新增向导式接线创建功能
- 新增四步向导流程,简化接线创建过程
- 添加线缆标签、颜色、安装信息等新字段
- 实现端口可视化面板和冲突检测功能
- 新增耗材日志设备关联字段
- 添加耗材导入后台任务管理
- 更新线缆管理文档和使用指南
2026-03-31 14:34:34 +08:00

1499 lines
52 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Modal,
Steps,
Button,
Space,
Spin,
Card,
Tag,
Tooltip,
message,
Typography,
Divider,
Badge,
Empty,
Input,
} from 'antd';
import {
SwapOutlined,
CloudServerOutlined,
DatabaseOutlined,
SettingOutlined,
CheckCircleOutlined,
ArrowRightOutlined,
PlusOutlined,
SearchOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { motion, AnimatePresence } from 'framer-motion';
import axios from 'axios';
import CloseButton from './CloseButton';
import PortPanel from './PortPanel';
import { designTokens } from '../config/theme';
const { Title, Text } = Typography;
const CABLE_TYPES = [
{ value: 'ethernet', label: '以太网线', color: '#52c41a', icon: '🌐' },
{ value: 'fiber', label: '光纤', color: '#1890ff', icon: '🔦' },
{ value: 'copper', label: '铜缆', color: '#faad14', icon: '🔌' },
];
const CABLE_LENGTHS = [1, 2, 3, 5, 7, 10, 15, 20, 30, 50];
const getStatusTag = (status) => {
const config = {
running: { color: 'success', text: '运行中' },
normal: { color: 'success', text: '正常' },
warning: { color: 'warning', text: '警告' },
error: { color: 'error', text: '故障' },
fault: { color: 'error', text: '故障' },
offline: { color: 'default', text: '离线' },
maintenance: { color: 'processing', text: '维护中' },
};
const { color, text } = config[status] || { color: 'default', text: status };
return <Tag color={color} size="small">{text}</Tag>;
};
const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, editingCable }) => {
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
const [devices, setDevices] = useState([]);
const [sourceDevice, setSourceDevice] = useState(null);
const [targetDevice, setTargetDevice] = useState(null);
const [sourcePort, setSourcePort] = useState(null);
const [targetPort, setTargetPort] = useState(null);
const [selectedCableType, setSelectedCableType] = useState('ethernet');
const [selectedCableLength, setSelectedCableLength] = useState(3);
const [cableLabel, setCableLabel] = useState('');
const [cableDescription, setCableDescription] = useState('');
const [fetchingDevices, setFetchingDevices] = useState(false);
const [sourcePorts, setSourcePorts] = useState([]);
const [targetPorts, setTargetPorts] = useState([]);
const [conflicts, setConflicts] = useState([]);
const [cablesData, setCablesData] = useState([]);
// 初始化编辑模式的数据
useEffect(() => {
if (editingCable && visible) {
setSourceDevice({ deviceId: editingCable.sourceDeviceId, name: editingCable.sourceDeviceId });
setTargetDevice({ deviceId: editingCable.targetDeviceId, name: editingCable.targetDeviceId });
setSourcePort({ portName: editingCable.sourcePort });
setTargetPort({ portName: editingCable.targetPort });
setSelectedCableType(editingCable.cableType || 'ethernet');
setSelectedCableLength(editingCable.cableLength || 3);
setCableLabel(editingCable.cableLabel || '');
setCableDescription(editingCable.description || '');
setCurrentStep(3); // 直接跳转到预览步骤
}
}, [editingCable, visible]);
const fetchDevices = useCallback(async (keyword = '', type = '') => {
try {
setFetchingDevices(true);
const params = { pageSize: 100 };
if (keyword && keyword.trim()) {
params.keyword = keyword.trim();
}
if (type && type.trim()) {
params.type = type.trim();
}
const response = await axios.get('/api/devices', { params });
const deviceList = response.data.devices || [];
setDevices(deviceList);
return deviceList;
} catch (error) {
console.error('获取设备列表失败:', error);
message.error('获取设备列表失败');
return [];
} finally {
setFetchingDevices(false);
}
}, []);
const fetchDevicePorts = useCallback(async (deviceId, type) => {
console.log('fetchDevicePorts called for:', deviceId, type);
if (!deviceId) return;
try {
const response = await axios.get(`/api/device-ports/device/${deviceId}`);
const ports = response.data || [];
console.log('Fetched ports:', ports);
if (type === 'source') {
setSourcePorts(ports);
} else {
setTargetPorts(ports);
}
} catch (error) {
console.error(`获取端口列表失败: ${deviceId}`, error);
message.error('获取端口列表失败');
}
}, []);
const fetchCables = useCallback(async () => {
try {
const response = await axios.get('/api/cables');
setCablesData(response.data.cables || []);
} catch (error) {
console.error('获取接线数据失败:', error);
}
}, []);
const checkPortConflict = useCallback(async (deviceId, portName, excludeCableId = null) => {
try {
const response = await axios.post('/api/cables/check-conflict', {
sourceDeviceId: deviceId,
sourcePort: portName,
excludeCableId,
});
return response.data;
} catch (error) {
console.error('检查端口冲突失败:', error);
return { hasConflict: false, conflicts: [] };
}
}, []);
const debounce = (fn, delay) => {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
};
const handleDeviceSearch = useCallback(
debounce((value) => {
fetchDevices(value, 'switch');
}, 300),
[fetchDevices]
);
useEffect(() => {
if (visible) {
setCurrentStep(0);
setSourceDevice(null);
setTargetDevice(null);
setSourcePort(null);
setTargetPort(null);
setSelectedCableType('ethernet');
setSelectedCableLength(3);
setCableLabel('');
setCableDescription('');
setSourcePorts([]);
setTargetPorts([]);
setConflicts([]);
setDevices([]);
setCablesData([]);
fetchDevices('', 'switch').then(deviceList => {
if (initialSourceDevice?.deviceId) {
const device = deviceList.find(d => d.deviceId === initialSourceDevice.deviceId);
if (device) {
setSourceDevice(device);
fetchDevicePorts(device.deviceId, 'source');
}
}
});
fetchCables();
}
}, [visible, initialSourceDevice, fetchDevices, fetchDevicePorts, fetchCables]);
const handleSourceDeviceSelect = useCallback(async device => {
setSourceDevice(device);
setSourcePort(null);
await fetchDevicePorts(device.deviceId, 'source');
}, [fetchDevicePorts]);
const handleTargetDeviceSelect = useCallback(async device => {
setTargetDevice(device);
setTargetPort(null);
setConflicts([]);
await fetchDevicePorts(device.deviceId, 'target');
}, [fetchDevicePorts]);
const handleSourcePortSelect = useCallback(async port => {
console.log('handleSourcePortSelect called with port:', port);
// 如果点击的是已选中的端口,则取消选择
const isAlreadySelected =
sourcePort?.portId === port.portId || sourcePort?.portName === port.portName;
if (isAlreadySelected) {
console.log('Deselecting source port:', port);
setSourcePort(null);
setConflicts([]);
} else {
// 只有在选择新端口时才进行设备相同性检查
if (sourceDevice?.deviceId === targetDevice?.deviceId) {
message.warning('源设备和目标设备不能相同');
return;
}
setSourcePort(port);
console.log('Source port set to:', port);
if (targetDevice) {
await checkPortConflict(targetDevice.deviceId, port.portName);
}
}
}, [sourceDevice, targetDevice, sourcePort, checkPortConflict]);
const handleTargetPortSelect = useCallback(async port => {
console.log('handleTargetPortSelect called with port:', port);
// 如果点击的是已选中的端口,则取消选择
const isAlreadySelected =
targetPort?.portId === port.portId || targetPort?.portName === port.portName;
if (isAlreadySelected) {
console.log('Deselecting target port:', port);
setTargetPort(null);
setConflicts([]);
} else {
// 只有在选择新端口时才进行设备相同性检查
if (sourceDevice?.deviceId === targetDevice?.deviceId) {
message.warning('源设备和目标设备不能相同');
return;
}
setTargetPort(port);
console.log('Target port set to:', port);
await checkPortConflict(sourceDevice.deviceId, port.portName);
}
}, [sourceDevice, targetDevice, targetPort, checkPortConflict]);
const handleNextStep = useCallback(async () => {
if (currentStep === 0) {
if (!sourceDevice) {
message.warning('请先选择源设备');
return;
}
if (!sourcePort) {
message.warning('请先选择源设备端口');
return;
}
if (sourcePorts.length === 0) {
message.warning('源设备没有可用端口');
return;
}
} else if (currentStep === 1) {
if (!targetDevice) {
message.warning('请先选择目标设备');
return;
}
if (!targetPort) {
message.warning('请先选择目标设备端口');
return;
}
if (targetPorts.length === 0) {
message.warning('目标设备没有可用端口');
return;
}
} else if (currentStep === 2) {
if (!sourcePort || !targetPort) {
message.warning('请先选择源端口和目标端口');
return;
}
if (sourcePort.portName === targetPort.portName && sourceDevice?.deviceId === targetDevice?.deviceId) {
message.warning('源端口和目标端口不能相同');
return;
}
}
setCurrentStep(prev => prev + 1);
}, [currentStep, sourceDevice, targetDevice, sourcePort, targetPort, sourcePorts, targetPorts]);
const handlePrevStep = useCallback(() => {
setCurrentStep(prev => Math.max(0, prev - 1));
}, []);
const handleSubmit = useCallback(async () => {
try {
setLoading(true);
const payload = {
cableId: cableLabel || `CABLE-${Date.now()}`,
sourceDeviceId: sourceDevice.deviceId,
sourcePort: sourcePort.portName,
targetDeviceId: targetDevice.deviceId,
targetPort: targetPort.portName,
cableType: selectedCableType,
cableLength: selectedCableLength,
description: cableDescription,
};
// 如果是编辑模式,使用 PUT 请求
if (editingCable) {
await axios.put(`/api/cables/${editingCable.cableId}`, payload);
message.success('接线更新成功');
} else {
await axios.post('/api/cables', payload);
message.success('接线创建成功');
}
onSuccess?.();
onClose();
} catch (error) {
console.error('操作接线失败:', error);
if (error.response?.data?.conflict) {
message.error('端口已被占用,请选择其他端口');
} else {
message.error(editingCable ? '接线更新失败' : '接线创建失败');
}
} finally {
setLoading(false);
}
}, [
sourceDevice,
sourcePort,
targetDevice,
targetPort,
selectedCableType,
selectedCableLength,
cableLabel,
cableDescription,
editingCable,
onSuccess,
onClose,
]);
const getAvailablePorts = useCallback(
(ports, type) => {
return ports.filter(port => {
if (port.status !== 'free') return false;
if (type === 'source') {
return !conflicts.some(c => c.type === 'target' && c.port === port.portName);
} else {
return !conflicts.some(c => c.type === 'source' && c.port === port.portName);
}
});
},
[conflicts]
);
const getRecommendedPorts = useCallback(
(availablePorts, type) => {
if (availablePorts.length === 0) return [];
const sortedPorts = [...availablePorts].sort((a, b) => {
const extractNumbers = str => {
const matches = str.match(/\d+/g);
return matches ? matches.map(Number) : [];
};
const numsA = extractNumbers(a.portName);
const numsB = extractNumbers(b.portName);
for (let i = 0; i < Math.min(numsA.length, numsB.length); i++) {
if (numsA[i] !== numsB[i]) {
return numsA[i] - numsB[i];
}
}
return a.portName.localeCompare(b.portName);
});
return sortedPorts.slice(0, 5);
},
[]
);
const renderStepContent = () => {
switch (currentStep) {
case 0:
return (
<Step1SourceDevice
devices={devices}
fetchingDevices={fetchingDevices}
onDeviceSearch={handleDeviceSearch}
onDeviceSelect={handleSourceDeviceSelect}
sourceDevice={sourceDevice}
sourcePorts={sourcePorts}
sourcePort={sourcePort}
onPortSelect={handleSourcePortSelect}
/>
);
case 1:
return (
<Step2TargetDevice
devices={devices}
fetchingDevices={fetchingDevices}
onDeviceSearch={handleDeviceSearch}
onDeviceSelect={handleTargetDeviceSelect}
targetDevice={targetDevice}
targetPorts={targetPorts}
targetPort={targetPort}
onPortSelect={handleTargetPortSelect}
conflicts={conflicts}
/>
);
case 2:
return (
<Step3CableConfig
sourceDevice={sourceDevice}
sourcePort={sourcePort}
targetDevice={targetDevice}
targetPort={targetPort}
cableTypes={CABLE_TYPES}
cableLengths={CABLE_LENGTHS}
selectedCableType={selectedCableType}
selectedCableLength={selectedCableLength}
onCableTypeChange={setSelectedCableType}
onCableLengthChange={setSelectedCableLength}
cableLabel={cableLabel}
cableDescription={cableDescription}
setCableLabel={setCableLabel}
setCableDescription={setCableDescription}
/>
);
case 3:
return (
<Step4Preview
sourceDevice={sourceDevice}
sourcePort={sourcePort}
targetDevice={targetDevice}
targetPort={targetPort}
cableType={CABLE_TYPES.find(c => c.value === selectedCableType)}
cableLength={selectedCableLength}
cableLabel={cableLabel}
cableDescription={cableDescription}
/>
);
default:
return null;
}
};
const steps = [
{
title: '源设备',
icon: <CloudServerOutlined />,
description: '选择连接起点',
},
{
title: '目标设备',
icon: <DatabaseOutlined />,
description: '选择连接终点',
},
{
title: '线缆配置',
icon: <SettingOutlined />,
description: '设置线缆参数',
},
{
title: '预览确认',
icon: <CheckCircleOutlined />,
description: '确认创建接线',
},
];
return (
<Modal
title={
<Space>
<SwapOutlined style={{ color: '#1890ff', fontSize: '24px' }} />
<div>
<Title level={3} style={{ margin: 0, lineHeight: 1.2 }}>
新增接线
</Title>
<Text type="secondary" style={{ fontSize: '12px' }}>
向导式创建流程
</Text>
</div>
</Space>
}
open={visible}
closeIcon={<CloseButton />}
onCancel={onClose}
width={1000}
maskClosable={false}
footer={null}
>
<div style={{ padding: '20px 0' }}>
<Steps
current={currentStep}
items={steps}
size="small"
responsive
onChange={setCurrentStep}
/>
</div>
<div style={{ minHeight: 400 }}>
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
>
{renderStepContent()}
</motion.div>
</AnimatePresence>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: '24px',
paddingTop: '20px',
borderTop: `1px solid ${designTokens.colors.border}`,
}}
>
<Button
onClick={onClose}
disabled={currentStep === 0}
style={{ width: 100 }}
>
取消
</Button>
<Space>
{currentStep > 0 && (
<Button onClick={handlePrevStep} style={{ width: 100 }}>
上一步
</Button>
)}
{currentStep < steps.length - 1 ? (
<Button
type="primary"
onClick={handleNextStep}
style={{
width: 100,
background: designTokens.colors.primary.gradient,
border: 'none',
}}
>
下一步
</Button>
) : (
<Button
type="primary"
onClick={handleSubmit}
loading={loading}
style={{
width: 120,
background: designTokens.colors.primary.gradient,
border: 'none',
}}
>
创建接线
</Button>
)}
</Space>
</div>
</Modal>
);
};
const Step1SourceDevice = ({
devices,
fetchingDevices,
onDeviceSearch,
onDeviceSelect,
sourceDevice,
sourcePorts,
sourcePort,
onPortSelect,
}) => {
const [searchKeyword, setSearchKeyword] = useState('');
const handleSearch = (e) => {
const value = e.target.value;
setSearchKeyword(value);
onDeviceSearch?.(value);
};
return (
<div style={{ padding: '20px 0' }}>
<Card
title={
<Space>
<CloudServerOutlined style={{ color: '#1890ff' }} />
<span>步骤 1: 选择源设备</span>
</Space>
}
size="small"
style={{ marginBottom: '20px' }}
>
<div style={{ marginBottom: '16px' }}>
<Typography.Text type="secondary" style={{ fontSize: '13px' }}>
选择接线的起点设备
</Typography.Text>
</div>
<div style={{ marginBottom: '16px' }}>
<Input
placeholder="搜索设备名称或ID..."
prefix={<SearchOutlined />}
value={searchKeyword}
onChange={handleSearch}
style={{ width: '100%' }}
/>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '12px',
maxHeight: 300,
overflowY: 'auto',
padding: '8px',
background: designTokens.colors.background,
borderRadius: '8px',
}}
>
{fetchingDevices ? (
<div style={{ textAlign: 'center', padding: '40px', gridColumn: '1 / -1' }}>
<Spin size="large" />
</div>
) : (
devices.map(device => (
<motion.div
key={device.deviceId}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card
onClick={() => onDeviceSelect(device)}
hoverable
style={{
border: sourceDevice?.deviceId === device.deviceId
? `2px solid ${designTokens.colors.primary}`
: '1px solid #d9d9d9',
background: sourceDevice?.deviceId === device.deviceId
? 'rgba(24,144,255,0.08)'
: '#fff',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '10px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
flexShrink: 0,
}}
>
{device.type === 'server' ? '🖥️' : device.type === 'switch' ? '📡' : device.type === 'router' ? '🔀' : device.type === 'storage' ? '💾' : '📦'}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
<div style={{ fontWeight: 600, color: '#262626', fontSize: '15px' }}>
{device.name || device.deviceId}
</div>
{device.status && getStatusTag(device.status)}
</div>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>ID:</span>
{device.deviceId}
</span>
<span>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>类型:</span>
{device.type === 'server' ? '服务器' : device.type === 'switch' ? '交换机' : device.type === 'router' ? '路由器' : device.type === 'storage' ? '存储设备' : device.type}
</span>
</div>
<div style={{ fontSize: '11px', color: '#8c8c8c', lineHeight: '1.5' }}>
{device.Rack?.Room?.name && (
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机房:</span>
{device.Rack.Room.name}
</span>
)}
{device.Rack?.name && (
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机柜:</span>
{device.Rack.name}
</span>
)}
{device.position && (
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>U位:</span>
U{device.position}
</span>
)}
{device.ipAddress && (
<span>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>IP:</span>
{device.ipAddress}
</span>
)}
</div>
{device.model && (
<div style={{ fontSize: '11px', color: '#bfbfbf', marginTop: '4px' }}>
<span style={{ color: '#8c8c8c', marginRight: '4px' }}>型号:</span>
{device.model}
</div>
)}
</div>
{sourceDevice?.deviceId === device.deviceId && (
<CheckCircleOutlined style={{ color: designTokens.colors.primary, fontSize: '22px', flexShrink: 0 }} />
)}
</div>
</Card>
</motion.div>
))
)}
</div>
{sourceDevice && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
style={{ marginTop: '20px' }}
>
<div style={{ marginBottom: '12px' }}>
<Typography.Text style={{ fontWeight: 600, color: '#262626' }}>
源设备已选择: {sourceDevice.name}
</Typography.Text>
</div>
<div
style={{
background: '#f5f5f5',
borderRadius: '12px',
padding: '16px',
border: '1px solid #d9d9d9',
}}
>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<DatabaseOutlined style={{ color: '#1890ff' }} />
<Typography.Text style={{ fontWeight: 600 }}>端口选择</Typography.Text>
<Tag color="blue" style={{ marginLeft: 'auto' }}>
{sourcePorts.filter(p => p.status === 'free').length} 个空闲端口
</Tag>
</div>
{sourcePorts.length === 0 ? (
<Empty description="该设备暂无端口数据" />
) : (
<>
<div style={{ marginBottom: '8px', fontSize: '12px', color: '#8c8c8c' }}>
端口总数: {sourcePorts.length} | 空闲端口: {sourcePorts.filter(p => p.status === 'free').length}
</div>
<div style={{ marginBottom: '8px', fontSize: '11px', color: '#bfbfbf' }}>
端口状态分布: {sourcePorts.map(p => p.status).filter((v, i, a) => a.indexOf(v) === i).join(', ')}
</div>
{sourcePort && (
<div
style={{
marginBottom: '16px',
padding: '16px',
background: 'linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%)',
borderRadius: '12px',
border: '2px solid #1890ff',
display: 'flex',
alignItems: 'center',
gap: '12px',
boxShadow: '0 4px 12px rgba(24,144,255,0.15)',
}}
>
<CheckCircleOutlined
style={{
color: '#1890ff',
fontSize: '24px',
}}
/>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '15px', fontWeight: 700, color: '#096dd9', marginBottom: '4px' }}>
已选择端口: {sourcePort.portName}
</div>
{sourcePort.portType && (
<div style={{ fontSize: '12px', color: '#1890ff', fontWeight: 500 }}>
端口类型: {sourcePort.portType}
</div>
)}
{sourcePort.portSpeed && (
<div style={{ fontSize: '11px', color: '#40a9ff', marginTop: '2px' }}>
端口速率: {sourcePort.portSpeed}
</div>
)}
</div>
</div>
)}
<PortPanel
ports={sourcePorts}
deviceName={sourceDevice.name}
deviceId={sourceDevice.deviceId}
onPortClick={onPortSelect}
selectedPort={sourcePort}
compact
/>
</>
)}
</div>
</motion.div>
)}
</Card>
</div>
);
};
const Step2TargetDevice = ({
devices,
fetchingDevices,
onDeviceSearch,
onDeviceSelect,
targetDevice,
targetPorts,
targetPort,
onPortSelect,
conflicts,
}) => {
const [searchKeyword, setSearchKeyword] = useState('');
const handleSearch = (e) => {
const value = e.target.value;
setSearchKeyword(value);
onDeviceSearch?.(value);
};
return (
<div style={{ padding: '20px 0' }}>
<Card
title={
<Space>
<DatabaseOutlined style={{ color: '#1890ff' }} />
<span>步骤 2: 选择目标设备</span>
</Space>
}
size="small"
style={{ marginBottom: '20px' }}
>
<div style={{ marginBottom: '16px' }}>
<Typography.Text type="secondary" style={{ fontSize: '13px' }}>
选择接线的终点设备
</Typography.Text>
</div>
<div style={{ marginBottom: '16px' }}>
<Input
placeholder="搜索设备名称或ID..."
prefix={<SearchOutlined />}
value={searchKeyword}
onChange={handleSearch}
style={{ width: '100%' }}
/>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '12px',
maxHeight: 300,
overflowY: 'auto',
padding: '8px',
background: designTokens.colors.background,
borderRadius: '8px',
}}
>
{fetchingDevices ? (
<div style={{ textAlign: 'center', padding: '40px', gridColumn: '1 / -1' }}>
<Spin size="large" />
</div>
) : (
devices.map(device => (
<motion.div
key={device.deviceId}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card
onClick={() => onDeviceSelect(device)}
hoverable
style={{
border: targetDevice?.deviceId === device.deviceId
? `2px solid ${designTokens.colors.primary}`
: '1px solid #d9d9d9',
background: targetDevice?.deviceId === device.deviceId
? 'rgba(24,144,255,0.08)'
: '#fff',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '10px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
flexShrink: 0,
}}
>
{device.type === 'server' ? '🖥️' : device.type === 'switch' ? '📡' : device.type === 'router' ? '🔀' : device.type === 'storage' ? '💾' : '📦'}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
<div style={{ fontWeight: 600, color: '#262626', fontSize: '15px' }}>
{device.name || device.deviceId}
</div>
{device.status && getStatusTag(device.status)}
</div>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>ID:</span>
{device.deviceId}
</span>
<span>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>类型:</span>
{device.type === 'server' ? '服务器' : device.type === 'switch' ? '交换机' : device.type === 'router' ? '路由器' : device.type === 'storage' ? '存储设备' : device.type}
</span>
</div>
<div style={{ fontSize: '11px', color: '#8c8c8c', lineHeight: '1.5' }}>
{device.Rack?.Room?.name && (
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机房:</span>
{device.Rack.Room.name}
</span>
)}
{device.Rack?.name && (
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机柜:</span>
{device.Rack.name}
</span>
)}
{device.position && (
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>U位:</span>
U{device.position}
</span>
)}
{device.ipAddress && (
<span>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>IP:</span>
{device.ipAddress}
</span>
)}
</div>
{device.model && (
<div style={{ fontSize: '11px', color: '#bfbfbf', marginTop: '4px' }}>
<span style={{ color: '#8c8c8c', marginRight: '4px' }}>型号:</span>
{device.model}
</div>
)}
</div>
{targetDevice?.deviceId === device.deviceId && (
<CheckCircleOutlined style={{ color: designTokens.colors.primary, fontSize: '22px', flexShrink: 0 }} />
)}
</div>
</Card>
</motion.div>
))
)}
</div>
{targetDevice && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
style={{ marginTop: '20px' }}
>
<div style={{ marginBottom: '12px' }}>
<Typography.Text style={{ fontWeight: 600, color: '#262626' }}>
目标设备已选择: {targetDevice.name}
</Typography.Text>
</div>
<div
style={{
background: '#f5f5f5',
borderRadius: '12px',
padding: '16px',
border: '1px solid #d9d9d9',
}}
>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<DatabaseOutlined style={{ color: '#1890ff' }} />
<Typography.Text style={{ fontWeight: 600 }}>端口选择</Typography.Text>
<Tag color="blue" style={{ marginLeft: 'auto' }}>
{targetPorts.filter(p => p.status === 'free').length} 个空闲端口
</Tag>
</div>
{targetPorts.length === 0 ? (
<Empty description="该设备暂无端口数据" />
) : (
<>
<div style={{ marginBottom: '8px', fontSize: '12px', color: '#8c8c8c' }}>
端口总数: {targetPorts.length} | 空闲端口: {targetPorts.filter(p => p.status === 'free').length}
</div>
<div style={{ marginBottom: '8px', fontSize: '11px', color: '#bfbfbf' }}>
端口状态分布: {targetPorts.map(p => p.status).filter((v, i, a) => a.indexOf(v) === i).join(', ')}
</div>
{targetPort && (
<div
style={{
marginBottom: '16px',
padding: '16px',
background: 'linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%)',
borderRadius: '12px',
border: '2px solid #1890ff',
display: 'flex',
alignItems: 'center',
gap: '12px',
boxShadow: '0 4px 12px rgba(24,144,255,0.15)',
}}
>
<CheckCircleOutlined
style={{
color: '#1890ff',
fontSize: '24px',
}}
/>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '15px', fontWeight: 700, color: '#096dd9', marginBottom: '4px' }}>
已选择端口: {targetPort.portName}
</div>
{targetPort.portType && (
<div style={{ fontSize: '12px', color: '#1890ff', fontWeight: 500 }}>
端口类型: {targetPort.portType}
</div>
)}
{targetPort.portSpeed && (
<div style={{ fontSize: '11px', color: '#40a9ff', marginTop: '2px' }}>
端口速率: {targetPort.portSpeed}
</div>
)}
</div>
</div>
)}
<PortPanel
ports={targetPorts}
deviceName={targetDevice.name}
deviceId={targetDevice.deviceId}
onPortClick={onPortSelect}
selectedPort={targetPort}
compact
/>
</>
)}
{conflicts.length > 0 && (
<div
style={{
marginTop: '16px',
padding: '12px',
background: 'rgba(239,68,68,0.1)',
borderRadius: '8px',
border: '1px solid rgba(239,68,68,0.3)',
}}
>
<Typography.Text type="danger" style={{ fontWeight: 600 }}>
⚠️ 端口冲突检测
</Typography.Text>
<div style={{ marginTop: '8px', fontSize: '12px' }}>
{conflicts.map((conflict, index) => (
<div key={index} style={{ marginBottom: '4px' }}>
<Tag color="error" style={{ marginRight: '8px' }}>
{conflict.type === 'source' ? '源端口' : '目标端口'}
</Tag>
<Text code>{conflict.port}</Text>
<Text type="secondary" style={{ marginLeft: '8px' }}>
已被 {conflict.existingCable?.sourceDevice?.name} {' '}
{conflict.existingCable?.targetDevice?.name} 占用
</Text>
</div>
))}
</div>
</div>
)}
</div>
</motion.div>
)}
</Card>
</div>
);
};
const Step3CableConfig = ({
sourceDevice,
sourcePort,
targetDevice,
targetPort,
cableTypes,
cableLengths,
selectedCableType,
selectedCableLength,
onCableTypeChange,
onCableLengthChange,
cableLabel,
cableDescription,
setCableLabel,
setCableDescription,
}) => {
const estimatedLength = useMemo(() => {
if (!sourceDevice?.rackId || !targetDevice?.rackId) return 3;
const distance = Math.abs(sourceDevice.rackId - targetDevice.rackId) * 0.5;
return Math.max(3, Math.min(50, Math.round(distance + 3)));
}, [sourceDevice, targetDevice]);
return (
<div style={{ padding: '20px 0' }}>
<Card
title={
<Space>
<SettingOutlined style={{ color: '#1890ff' }} />
<span>步骤 3: 线缆配置</span>
</Space>
}
size="small"
style={{ marginBottom: '20px' }}
>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '24px',
}}
>
<div>
<Typography.Text style={{ fontWeight: 600, marginBottom: '12px', display: 'block' }}>
连接信息
</Typography.Text>
<div
style={{
background: '#f5f5f5',
borderRadius: '12px',
padding: '16px',
marginBottom: '16px',
border: '1px solid #d9d9d9',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '12px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>源设备</div>
<div style={{ fontWeight: 600, color: '#262626' }}>{sourceDevice?.name}</div>
<div style={{ fontSize: '11px', color: '#bfbfbf' }}>
端口: {sourcePort?.portName}
</div>
</div>
<ArrowRightOutlined style={{ color: '#1890ff' }} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>目标设备</div>
<div style={{ fontWeight: 600, color: '#262626' }}>{targetDevice?.name}</div>
<div style={{ fontSize: '11px', color: '#bfbfbf' }}>
端口: {targetPort?.portName}
</div>
</div>
</div>
<Divider style={{ margin: '8px 0' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>线缆类型</div>
<div style={{ marginTop: '8px' }}>
{cableTypes.map(type => (
<Tag
key={type.value}
color={type.color}
style={{
cursor: 'pointer',
marginRight: '8px',
marginBottom: '8px',
border: selectedCableType === type.value
? `2px solid ${type.color}`
: '1px solid #d9d9d9',
}}
onClick={() => onCableTypeChange(type.value)}
>
{type.icon} {type.label}
</Tag>
))}
</div>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>线缆长度</div>
<div style={{ marginTop: '8px' }}>
{cableLengths.map(length => (
<Tag
key={length}
color={selectedCableLength === length ? 'blue' : 'default'}
style={{
cursor: 'pointer',
marginRight: '8px',
marginBottom: '8px',
border: selectedCableLength === length
? '2px solid #1890ff'
: '1px solid #d9d9d9',
}}
onClick={() => onCableLengthChange(length)}
>
{length}m
</Tag>
))}
</div>
</div>
</div>
</div>
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
<InfoCircleOutlined style={{ marginRight: '4px' }} />
建议长度: <Text code>{estimatedLength}m</Text>
</div>
</div>
<div>
<Typography.Text style={{ fontWeight: 600, marginBottom: '12px', display: 'block' }}>
线缆属性
</Typography.Text>
<div
style={{
background: '#f5f5f5',
borderRadius: '12px',
padding: '16px',
border: '1px solid #d9d9d9',
}}
>
<div style={{ marginBottom: '16px' }}>
<Typography.Text style={{ fontSize: '13px', display: 'block', marginBottom: '8px' }}>
线缆标签
</Typography.Text>
<Input
placeholder="自动生成或手动输入标签"
value={cableLabel}
onChange={e => setCableLabel(e.target.value)}
style={{ width: '100%' }}
/>
<div style={{ fontSize: '11px', color: '#bfbfbf', marginTop: '4px' }}>
示例: CABLE-{sourceDevice?.deviceId.slice(-4)}-{targetDevice?.deviceId.slice(-4)}-{Date.now().toString().slice(-4)}
</div>
</div>
<div>
<Typography.Text style={{ fontSize: '13px', display: 'block', marginBottom: '8px' }}>
备注说明
</Typography.Text>
<Input.TextArea
placeholder="添加接线说明..."
value={cableDescription}
onChange={e => setCableDescription(e.target.value)}
rows={6}
style={{ width: '100%' }}
/>
</div>
</div>
<div
style={{
marginTop: '16px',
padding: '12px',
background: 'rgba(24,144,255,0.1)',
borderRadius: '8px',
border: '1px solid rgba(24,144,255,0.3)',
}}
>
<Typography.Text style={{ fontWeight: 600, color: '#1890ff' }}>
💡 提示
</Typography.Text>
<div style={{ fontSize: '12px', marginTop: '8px', lineHeight: '1.6' }}>
以太网线Cat6: 适用于1G/10G短距离连接
<br />
光纤SMF/MMF: 适用于长距离或高带宽需求
<br />
铜缆: 适用于电源或特殊设备连接
</div>
</div>
</div>
</div>
</Card>
</div>
);
};
const Step4Preview = ({
sourceDevice,
sourcePort,
targetDevice,
targetPort,
cableType,
cableLength,
cableLabel,
cableDescription,
}) => {
return (
<div style={{ padding: '20px 0' }}>
<Card
title={
<Space>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<span>步骤 4: 预览确认</span>
</Space>
}
size="small"
style={{ marginBottom: '20px' }}
>
<div
style={{
background: '#f5f5f5',
borderRadius: '16px',
padding: '24px',
border: '1px solid #d9d9d9',
}}
>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '24px',
marginBottom: '24px',
}}
>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '16px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '32px',
boxShadow: '0 10px 30px rgba(0,0,0,0.3)',
}}
>
{sourceDevice?.type === 'server' ? '🖥️' : sourceDevice?.type === 'switch' ? '📡' : '💾'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: cableType?.color + '30',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
}}
>
{cableType?.icon}
</div>
<div>
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
{cableType?.label} {cableLength}m
</div>
<div style={{ fontSize: '11px', color: '#bfbfbf' }}>
{cableLabel || '自动生成'}
</div>
</div>
</div>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '16px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '32px',
boxShadow: '0 10px 30px rgba(0,0,0,0.3)',
}}
>
{targetDevice?.type === 'server' ? '🖥️' : targetDevice?.type === 'switch' ? '📡' : '💾'}
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '16px',
}}
>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>源端口</div>
<div style={{ fontWeight: 600, color: '#52c41a' }}>{sourcePort?.portName}</div>
<div style={{ fontSize: '11px', color: '#bfbfbf' }}>
{sourceDevice?.name}
</div>
</div>
<ArrowRightOutlined style={{ color: '#1890ff' }} />
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>目标端口</div>
<div style={{ fontWeight: 600, color: '#1890ff' }}>{targetPort?.portName}</div>
<div style={{ fontSize: '11px', color: '#bfbfbf' }}>
{targetDevice?.name}
</div>
</div>
</div>
</div>
{cableDescription && (
<div
style={{
padding: '16px',
background: '#fff',
borderRadius: '8px',
marginBottom: '16px',
border: '1px solid #d9d9d9',
}}
>
<Typography.Text style={{ fontSize: '13px', display: 'block', marginBottom: '8px', color: '#262626' }}>
备注说明
</Typography.Text>
<div style={{ fontSize: '14px', color: '#595959' }}>
{cableDescription}
</div>
</div>
)}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px',
background: 'rgba(82,196,26,0.1)',
borderRadius: '8px',
border: '1px solid rgba(82,196,26,0.3)',
}}
>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<Typography.Text style={{ fontWeight: 600, color: '#52c41a' }}>
所有信息已确认可以创建接线
</Typography.Text>
</div>
</div>
</Card>
</div>
);
};
export default CableWizardModal;