feat: 添加设备位置冲突检查功能并优化操作日志

feat(api): 在设备API中添加checkPosition接口用于检查U位冲突
feat(frontend): 在设备表单和空闲设备管理中实现U位冲突检查
refactor(backend): 重构操作日志功能,添加设备描述生成和元数据构建工具
fix(backend): 修复批量导入机柜时的ID验证规则
feat(backend): 为机柜导入添加创建和跳过机柜的详细返回信息
fix(backend): 修复设备删除时未检查关联接线的问题
feat(backend): 添加机柜导入模板生成脚本
perf(frontend): 优化机柜管理页面的导入结果展示
fix(frontend): 修复设备端口删除时的关联接线检查
This commit is contained in:
zhang1106
2026-03-26 14:35:08 +08:00
parent 0b7732e427
commit d9c46245df
14 changed files with 2152 additions and 384 deletions
+1
View File
@@ -126,6 +126,7 @@ export const deviceAPI = {
update: (deviceId, data) => api.put(`/devices/${deviceId}`, data),
delete: deviceId => api.delete(`/devices/${deviceId}`),
getTickets: (deviceId, params) => api.get(`/devices/${deviceId}/tickets`, { params }),
checkPosition: (rackId, params) => api.get(`/devices/check-position/${rackId}`, { params }),
};
export const ticketAPI = {
+413 -170
View File
@@ -9,62 +9,89 @@ import {
Card,
Tooltip,
Button,
Popconfirm,
Table,
Badge,
Row,
Col,
Divider,
Collapse,
Spin,
Pagination,
} from 'antd';
import {
ApiOutlined,
CloudServerOutlined,
EnvironmentOutlined,
EditOutlined,
PlusCircleOutlined,
DeleteOutlined,
FileTextOutlined,
ToolOutlined,
EyeOutlined,
DesktopOutlined,
FieldTimeOutlined,
InfoCircleOutlined,
LinkOutlined,
FolderOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons';
import NetworkCardPanel from './NetworkCardPanel';
import { deviceAPI } from '../api';
import { designTokens } from '../config/theme';
import dayjs from 'dayjs';
import api from '../api';
const { Text, Title } = Typography;
const { Panel } = Collapse;
const PAGE_SIZE = 3;
function DeviceDetailDrawer({
device,
visible,
onClose,
cables,
onRefreshCables,
onEdit,
onAddNic,
onAddPort,
onAddCable,
onDeleteCable,
tooltipFields,
refreshTrigger,
onViewTicket,
onCreateTicket,
}) {
const [activeTab, setActiveTab] = useState('ports');
const [tickets, setTickets] = useState([]);
const [ticketsLoading, setTicketsLoading] = useState(false);
const [ticketsPagination, setTicketsPagination] = useState({
current: 1,
pageSize: 5,
pageSize: PAGE_SIZE,
total: 0,
});
// 获取设备关联的工单列表
const fetchDeviceTickets = useCallback(async (page = 1, pageSize = 5) => {
const [networkCards, setNetworkCards] = useState([]);
const [networkCardsLoading, setNetworkCardsLoading] = useState(false);
const [expandedCards, setExpandedCards] = useState([]);
const [portsPage, setPortsPage] = useState(1);
const [cablesPage, setCablesPage] = useState(1);
const fetchNetworkCards = useCallback(async () => {
if (!device?.deviceId) return;
setNetworkCardsLoading(true);
try {
const response = await api.get(`/network-cards/device/${device.deviceId}/with-ports`);
const cardsData = response.data || response || [];
setNetworkCards(cardsData);
const initialExpanded = cardsData
.filter(card => card.ports && card.ports.length > 0)
.map(card => card.nicId);
setExpandedCards(initialExpanded);
setPortsPage(1);
} catch (error) {
console.error('获取网卡数据失败:', error);
setNetworkCards([]);
} finally {
setNetworkCardsLoading(false);
}
}, [device?.deviceId]);
useEffect(() => {
if (visible && device?.deviceId) {
fetchNetworkCards();
}
}, [visible, device?.deviceId, fetchNetworkCards, refreshTrigger]);
const fetchDeviceTickets = useCallback(async (page = 1, pageSize = PAGE_SIZE) => {
if (!device?.deviceId) return;
setTicketsLoading(true);
try {
@@ -75,7 +102,7 @@ function DeviceDetailDrawer({
setTickets(response.data || []);
setTicketsPagination({
current: response.page || 1,
pageSize: response.pageSize || 5,
pageSize: response.pageSize || PAGE_SIZE,
total: response.total || 0,
});
} catch (error) {
@@ -85,14 +112,12 @@ function DeviceDetailDrawer({
}
}, [device?.deviceId]);
// 当设备变化或标签页切换到工单时,加载工单数据
useEffect(() => {
if (visible && device?.deviceId && activeTab === 'tickets') {
fetchDeviceTickets(1, 5);
fetchDeviceTickets(1, PAGE_SIZE);
}
}, [visible, device?.deviceId, activeTab, fetchDeviceTickets]);
// 工单表格列定义
const ticketColumns = useMemo(() => [
{
title: '工单编号',
@@ -149,22 +174,7 @@ function DeviceDetailDrawer({
return dayjs(date).format('YYYY-MM-DD HH:mm');
},
},
{
title: '操作',
key: 'action',
width: 80,
render: (_, record) => (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => onViewTicket?.(record)}
>
查看
</Button>
),
},
], [onViewTicket]);
], []);
const deviceCables = useMemo(() => {
if (!device || !cables) return [];
@@ -173,6 +183,18 @@ function DeviceDetailDrawer({
);
}, [device, cables]);
const paginatedCables = useMemo(() => {
const start = (cablesPage - 1) * PAGE_SIZE;
const end = start + PAGE_SIZE;
return deviceCables.slice(start, end);
}, [deviceCables, cablesPage]);
const cablesTotalPages = Math.ceil(deviceCables.length / PAGE_SIZE);
useEffect(() => {
setCablesPage(1);
}, [deviceCables.length]);
const getStatusTag = useCallback(status => {
const config = {
running: { color: 'success', text: '运行中' },
@@ -182,11 +204,26 @@ function DeviceDetailDrawer({
fault: { color: 'error', text: '故障' },
offline: { color: 'default', text: '离线' },
maintenance: { color: 'processing', text: '维护中' },
free: { color: 'success', text: '空闲' },
occupied: { color: 'processing', text: '占用' },
};
const { color, text } = config[status] || { color: 'default', text: status };
return <Tag color={color}>{text}</Tag>;
}, []);
const getPortTypeTag = useCallback(type => {
const config = {
RJ45: { color: 'blue', text: 'RJ45' },
SFP: { color: 'green', text: 'SFP' },
'SFP+': { color: 'cyan', text: 'SFP+' },
SFP28: { color: 'purple', text: 'SFP28' },
QSFP: { color: 'orange', text: 'QSFP' },
QSFP28: { color: 'red', text: 'QSFP28' },
};
const { color, text } = config[type] || { color: 'default', text: type };
return <Tag color={color}>{text}</Tag>;
}, []);
const getDeviceTypeName = useCallback(type => {
const typeMap = {
server: '服务器',
@@ -200,9 +237,331 @@ function DeviceDetailDrawer({
return typeMap[type?.toLowerCase()] || type || '未知设备';
}, []);
const renderPortTable = (ports) => {
const columns = [
{
title: '端口名称',
dataIndex: 'portName',
key: 'portName',
width: 120,
render: text => <span style={{ fontWeight: 500 }}>{text}</span>,
},
{
title: '类型',
dataIndex: 'portType',
key: 'portType',
width: 80,
render: type => getPortTypeTag(type),
},
{
title: '速率',
dataIndex: 'portSpeed',
key: 'portSpeed',
width: 70,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 70,
render: status => getStatusTag(status),
},
{
title: 'VLAN',
dataIndex: 'vlanId',
key: 'vlanId',
width: 60,
render: vlanId => vlanId || '-',
},
];
return (
<Table
columns={columns}
dataSource={ports}
rowKey="portId"
pagination={false}
size="small"
scroll={{ x: 400 }}
/>
);
};
const renderCardHeader = card => {
const stats = card.stats || { free: 0, occupied: 0, fault: 0, total: 0 };
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '8px',
background: card.isUngrouped
? 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)'
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>
{card.isUngrouped ? <FolderOutlined /> : <CloudServerOutlined />}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b' }}>
{card.name}
{card.slotNumber && (
<span style={{ color: '#94a3b8', marginLeft: 8 }}>插槽 {card.slotNumber}</span>
)}
</div>
<div style={{ fontSize: '12px', color: '#64748b' }}>
{card.description || (card.isUngrouped ? '未分配到网卡的端口' : '网卡')}
</div>
</div>
</div>
<Space size={12}>
<Badge count={stats.free} style={{ backgroundColor: designTokens.colors.success }} />
<span style={{ fontSize: '12px', color: '#64748b' }}>空闲</span>
<Badge count={stats.occupied} style={{ backgroundColor: '#1677ff' }} />
<span style={{ fontSize: '12px', color: '#64748b' }}>占用</span>
<Badge count={stats.fault} style={{ backgroundColor: designTokens.colors.error }} />
<span style={{ fontSize: '12px', color: '#64748b' }}>故障</span>
</Space>
</div>
);
};
const paginatedNetworkCards = useMemo(() => {
const start = (portsPage - 1) * PAGE_SIZE;
const end = start + PAGE_SIZE;
return networkCards.slice(start, end);
}, [networkCards, portsPage]);
const portsTotalPages = Math.ceil(networkCards.length / PAGE_SIZE);
const renderPortsTab = () => {
if (networkCardsLoading) {
return (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" tip="加载网卡数据中..." />
</div>
);
}
const totalStats = networkCards.reduce(
(acc, card) => {
const stats = card.stats || {};
acc.total += stats.total || 0;
acc.free += stats.free || 0;
acc.occupied += stats.occupied || 0;
acc.fault += stats.fault || 0;
return acc;
},
{ total: 0, free: 0, occupied: 0, fault: 0 }
);
if (networkCards.length === 0) {
return <Empty description="该设备暂无网卡和端口" />;
}
return (
<div className="network-card-panel">
<div
style={{
display: 'flex',
gap: '24px',
marginBottom: '16px',
padding: '12px 16px',
background: '#f8fafc',
borderRadius: '8px',
}}
>
<Space size={16}>
<Badge
count={networkCards.filter(c => !c.isUngrouped).length}
style={{ backgroundColor: designTokens.colors.primary.main }}
/>
<span style={{ color: '#64748b', fontSize: '13px' }}>个网卡</span>
<Badge count={totalStats.total} style={{ backgroundColor: '#667eea' }} />
<span style={{ color: '#64748b', fontSize: '13px' }}>个端口</span>
</Space>
</div>
<Collapse
activeKey={expandedCards.filter(id =>
paginatedNetworkCards.some(card => card.nicId === id)
)}
onChange={keys => setExpandedCards(keys)}
expandIconPosition="end"
style={{ background: 'transparent' }}
>
{paginatedNetworkCards.map(card => (
<Panel
key={card.nicId}
header={renderCardHeader(card)}
style={{
background: '#fff',
borderRadius: '8px',
marginBottom: '8px',
border: '1px solid #e2e8f0',
}}
>
{card.ports && card.ports.length > 0 ? (
renderPortTable(card.ports)
) : (
<div style={{ padding: '24px', textAlign: 'center', color: '#94a3b8' }}>
{card.isUngrouped ? '分组' : '网卡'}暂无端口
</div>
)}
</Panel>
))}
</Collapse>
{portsTotalPages > 1 && (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: 16,
gap: 12,
}}
>
<Button
icon={<LeftOutlined />}
disabled={portsPage === 1}
onClick={() => setPortsPage(prev => prev - 1)}
size="small"
/>
<span style={{ color: '#64748b', fontSize: '13px' }}>
{portsPage} / {portsTotalPages}
</span>
<Button
icon={<RightOutlined />}
disabled={portsPage === portsTotalPages}
onClick={() => setPortsPage(prev => prev + 1)}
size="small"
/>
</div>
)}
</div>
);
};
const renderCablesTab = () => {
if (deviceCables.length === 0) {
return <Empty description="该设备暂无接线" />;
}
return (
<div className="cable-panel">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{paginatedCables.map(cable => (
<Card
key={cable.cableId}
size="small"
style={{ borderRadius: '8px' }}
>
<Row gutter={[16, 8]}>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>源设备</div>
<div style={{ fontWeight: 500 }}>
{cable.sourceDevice?.name || '-'}
<Tag color="blue" style={{ marginLeft: '8px' }}>
{cable.sourcePort}
</Tag>
</div>
</Col>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>目标设备</div>
<div style={{ fontWeight: 500 }}>
{cable.targetDevice?.name || '-'}
<Tag color="green" style={{ marginLeft: '8px' }}>
{cable.targetPort}
</Tag>
</div>
</Col>
<Col span={24}>
<Space wrap style={{ marginTop: 8 }}>
<Tag
color={
cable.status === 'normal'
? 'success'
: cable.status === 'fault'
? 'error'
: 'default'
}
>
{cable.status === 'normal'
? '正常'
: cable.status === 'fault'
? '故障'
: '未连接'}
</Tag>
<Tag color="purple">
{cable.cableType === 'ethernet'
? '网线'
: cable.cableType === 'fiber'
? '光纤'
: '铜缆'}
</Tag>
{cable.cableLength && <Tag color="orange">{cable.cableLength}m</Tag>}
</Space>
</Col>
{cable.description && (
<Col span={24}>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
{cable.description}
</div>
</Col>
)}
</Row>
</Card>
))}
</Space>
{cablesTotalPages > 1 && (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: 16,
gap: 12,
}}
>
<Button
icon={<LeftOutlined />}
disabled={cablesPage === 1}
onClick={() => setCablesPage(prev => prev - 1)}
size="small"
/>
<span style={{ color: '#64748b', fontSize: '13px' }}>
{cablesPage} / {cablesTotalPages}
</span>
<Button
icon={<RightOutlined />}
disabled={cablesPage === cablesTotalPages}
onClick={() => setCablesPage(prev => prev + 1)}
size="small"
/>
</div>
)}
</div>
);
};
if (!device) return null;
// 解析自定义字段
const customFields = device.customFields || {};
const standardFields = ['deviceId', 'name', 'type', 'model', 'serialNumber', 'status', 'ipAddress', 'position', 'height', 'powerConsumption', 'purchaseDate', 'warrantyExpiry', 'description'];
const customFieldEntries = Object.entries(customFields).filter(([key]) => !standardFields.includes(key));
@@ -213,17 +572,10 @@ function DeviceDetailDrawer({
label: (
<span>
<ApiOutlined />
端口与网卡
端口与网卡 ({networkCards.length})
</span>
),
children: (
<NetworkCardPanel
deviceId={device?.deviceId}
deviceName={device?.name}
onRefresh={onRefreshCables}
refreshTrigger={refreshTrigger}
/>
),
children: renderPortsTab(),
},
{
key: 'cables',
@@ -233,88 +585,7 @@ function DeviceDetailDrawer({
接线 ({deviceCables.length})
</span>
),
children: (
<div className="cable-panel">
{deviceCables.length === 0 ? (
<Empty description="该设备暂无接线" />
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{deviceCables.map(cable => (
<Card
key={cable.cableId}
size="small"
style={{ borderRadius: '8px' }}
extra={
<Popconfirm
title="确定要删除这条接线吗?"
onConfirm={() => onDeleteCable?.(cable.cableId)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
}
>
<Row gutter={[16, 8]}>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>源设备</div>
<div style={{ fontWeight: 500 }}>
{cable.sourceDevice?.name || '-'}
<Tag color="blue" style={{ marginLeft: '8px' }}>
{cable.sourcePort}
</Tag>
</div>
</Col>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: 4 }}>目标设备</div>
<div style={{ fontWeight: 500 }}>
{cable.targetDevice?.name || '-'}
<Tag color="green" style={{ marginLeft: '8px' }}>
{cable.targetPort}
</Tag>
</div>
</Col>
<Col span={24}>
<Space wrap style={{ marginTop: 8 }}>
<Tag
color={
cable.status === 'normal'
? 'success'
: cable.status === 'fault'
? 'error'
: 'default'
}
>
{cable.status === 'normal'
? '正常'
: cable.status === 'fault'
? '故障'
: '未连接'}
</Tag>
<Tag color="purple">
{cable.cableType === 'ethernet'
? '网线'
: cable.cableType === 'fiber'
? '光纤'
: '铜缆'}
</Tag>
{cable.cableLength && <Tag color="orange">{cable.cableLength}m</Tag>}
</Space>
</Col>
{cable.description && (
<Col span={24}>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
{cable.description}
</div>
</Col>
)}
</Row>
</Card>
))}
</Space>
)}
</div>
),
children: renderCablesTab(),
},
{
key: 'tickets',
@@ -326,25 +597,21 @@ function DeviceDetailDrawer({
),
children: (
<div className="tickets-panel">
<div style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<ToolOutlined />}
onClick={() => onCreateTicket?.(device)}
>
创建工单
</Button>
</div>
<Table
columns={ticketColumns}
dataSource={tickets}
rowKey="ticketId"
loading={ticketsLoading}
pagination={{
...ticketsPagination,
current: ticketsPagination.current,
pageSize: ticketsPagination.pageSize,
total: ticketsPagination.total,
onChange: (page, pageSize) => fetchDeviceTickets(page, pageSize),
showSizeChanger: false,
size: 'small',
}}
size="small"
locale={{ emptyText: <Empty description="该设备暂无工单记录" /> }}
/>
</div>
),
@@ -364,32 +631,14 @@ function DeviceDetailDrawer({
open={visible}
onClose={onClose}
extra={
<Space>
<Tooltip title="编辑设备信息">
<Button icon={<EditOutlined />} onClick={() => onEdit?.(device)}>
编辑
</Button>
</Tooltip>
<Tooltip title="添加网卡">
<Button icon={<PlusCircleOutlined />} onClick={() => onAddNic?.(device)}>
加网卡
</Button>
</Tooltip>
<Tooltip title="添加端口">
<Button icon={<ApiOutlined />} onClick={() => onAddPort?.(device)}>
加端口
</Button>
</Tooltip>
<Tooltip title="添加接线">
<Button icon={<EnvironmentOutlined />} onClick={() => onAddCable?.(device)}>
加接线
</Button>
</Tooltip>
</Space>
<Tooltip title="编辑设备信息">
<Button icon={<EditOutlined />} onClick={() => onEdit?.(device)}>
编辑
</Button>
</Tooltip>
}
styles={{ body: { padding: '0', overflow: 'auto' } }}
>
{/* 设备头部信息 */}
<div style={{ padding: '20px 24px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: '#fff' }}>
<Row gutter={[16, 16]} align="middle">
<Col>
@@ -408,9 +657,7 @@ function DeviceDetailDrawer({
</Row>
</div>
{/* 设备信息内容 */}
<div style={{ padding: '20px 24px' }}>
{/* 基本信息卡片 */}
<Card title="基本信息" size="small" style={{ marginBottom: 16 }}>
<Row gutter={[24, 16]}>
<Col span={8}>
@@ -440,7 +687,6 @@ function DeviceDetailDrawer({
</Row>
</Card>
{/* 维保信息卡片 */}
<Card title="维保信息" size="small" style={{ marginBottom: 16 }}>
<Row gutter={[24, 16]}>
<Col span={12}>
@@ -458,14 +704,12 @@ function DeviceDetailDrawer({
</Row>
</Card>
{/* 描述信息 */}
{device.description && (
<Card title="描述" size="small" style={{ marginBottom: 16 }}>
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{device.description}</div>
</Card>
)}
{/* 自定义字段卡片 */}
{customFieldEntries.length > 0 && (
<Card title="自定义字段" size="small" style={{ marginBottom: 16 }}>
<Row gutter={[24, 16]}>
@@ -484,7 +728,6 @@ function DeviceDetailDrawer({
<Divider style={{ margin: '24px 0' }} />
{/* 标签页 */}
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</div>
</Drawer>
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
import { Modal, Form, Input, Select, InputNumber, DatePicker, Switch, Row, Col, Button, Space } from 'antd';
import { PlusOutlined, EditOutlined, DatabaseOutlined } from '@ant-design/icons';
import { Modal, Form, Input, Select, InputNumber, DatePicker, Switch, Row, Col, Button, Space, Alert } from 'antd';
import { PlusOutlined, EditOutlined, DatabaseOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { designTokens } from '../../config/theme';
import { getFormInitialValues, prepareDeviceFormData } from '../../utils/deviceUtils.jsx';
import { deviceAPI } from '../../api';
const { Option } = Select;
@@ -31,6 +32,9 @@ const DeviceFormModal = ({
}) => {
const [form] = Form.useForm();
const [selectedRoomId, setSelectedRoomId] = useState(null);
const [selectedRackId, setSelectedRackId] = useState(null);
const [positionConflict, setPositionConflict] = useState(null);
const [checkingPosition, setCheckingPosition] = useState(false);
useEffect(() => {
if (visible) {
@@ -47,22 +51,91 @@ const DeviceFormModal = ({
const rack = racks.find((r) => r.rackId === editingDevice.rackId);
if (rack) {
setSelectedRoomId(rack.roomId);
setSelectedRackId(editingDevice.rackId);
}
}
if (editingDevice.position) {
checkPositionConflict(editingDevice.rackId, editingDevice.position, editingDevice.height, editingDevice.deviceId);
}
} else {
form.resetFields();
setSelectedRoomId(null);
setSelectedRackId(null);
setPositionConflict(null);
}
}
}, [visible, editingDevice, racks, form]);
const checkPositionConflict = async (rackId, position, height, deviceId = null) => {
if (!rackId || !position) {
setPositionConflict(null);
return;
}
setCheckingPosition(true);
try {
const params = {
position,
height: height || 1,
};
if (deviceId) {
params.excludeDeviceId = deviceId;
}
const result = await deviceAPI.checkPosition(rackId, params);
if (!result.available) {
setPositionConflict(result.reason);
} else {
setPositionConflict(null);
}
} catch (error) {
console.error('检查U位冲突失败:', error);
setPositionConflict(null);
} finally {
setCheckingPosition(false);
}
};
const handleRackChange = (value) => {
setSelectedRackId(value);
const position = form.getFieldValue('position');
const height = form.getFieldValue('height');
if (position) {
checkPositionConflict(value, position, height, editingDevice?.deviceId);
} else {
setPositionConflict(null);
}
};
const handlePositionChange = (value) => {
const height = form.getFieldValue('height');
if (selectedRackId && value) {
checkPositionConflict(selectedRackId, value, height, editingDevice?.deviceId);
} else {
setPositionConflict(null);
}
};
const handleHeightChange = (value) => {
const position = form.getFieldValue('position');
if (selectedRackId && position) {
checkPositionConflict(selectedRackId, position, value, editingDevice?.deviceId);
} else {
setPositionConflict(null);
}
};
const handleSubmit = (values) => {
if (positionConflict) {
return;
}
const deviceData = prepareDeviceFormData(values, !!editingDevice);
onSubmit(deviceData);
};
const handleRoomChange = (value) => {
setSelectedRoomId(value);
setSelectedRackId(null);
setPositionConflict(null);
form.setFieldValue('rackId', undefined);
};
@@ -123,7 +196,7 @@ const DeviceFormModal = ({
};
const filteredFields = deviceFields.filter(
(field) => field.fieldName !== 'deviceId' && field.fieldName !== 'rackId'
(field) => field.fieldName !== 'deviceId' && field.fieldName !== 'rackId' && field.fieldName !== 'position' && field.fieldName !== 'height'
);
const formItems = [];
@@ -219,6 +292,7 @@ const DeviceFormModal = ({
disabled={!selectedRoomId}
showSearch
optionFilterProp="children"
onChange={handleRackChange}
>
{(selectedRoomId ? racks.filter((rack) => rack.roomId === selectedRoomId) : []).map(
(rack) => (
@@ -231,6 +305,61 @@ const DeviceFormModal = ({
</Form.Item>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: '16px' }}>
<Col span={12}>
<Form.Item
name="position"
label={
<span>
安装位置 (U位)
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入U位' }]}
style={{ marginBottom: '0' }}
>
<InputNumber
placeholder="如: 1"
min={1}
max={42}
style={{ width: '100%', borderRadius: '8px' }}
onChange={handlePositionChange}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="height"
label={
<span>
设备高度 (U)
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入设备高度' }]}
initialValue={1}
style={{ marginBottom: '0' }}
>
<InputNumber
placeholder="如: 2"
min={1}
max={10}
style={{ width: '100%', borderRadius: '8px' }}
onChange={handleHeightChange}
/>
</Form.Item>
</Col>
</Row>
{positionConflict && (
<div style={{ marginTop: '12px' }}>
<Alert
message={positionConflict}
type="error"
showIcon
icon={<ExclamationCircleOutlined />}
/>
</div>
)}
</div>
</Col>
</React.Fragment>
+84 -1
View File
@@ -29,8 +29,10 @@ import {
InboxOutlined,
ClockCircleOutlined,
UploadOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { deviceAPI } from '../api';
const { Title, Text, Paragraph } = Typography;
const { Option } = Select;
@@ -52,6 +54,8 @@ const IdleDeviceManagement = () => {
const [rooms, setRooms] = useState([]);
const [selectedRoomId, setSelectedRoomId] = useState(null);
const [selectedShelveRoomId, setSelectedShelveRoomId] = useState(null);
const [shelvePositionConflict, setShelvePositionConflict] = useState(null);
const [shelveSelectedRackId, setShelveSelectedRackId] = useState(null);
const fetchIdleDevices = useCallback(async () => {
setLoading(true);
@@ -144,6 +148,8 @@ const IdleDeviceManagement = () => {
const handleShelve = (record) => {
setShelvingDevice(record);
setShelvePositionConflict(null);
setShelveSelectedRackId(null);
let roomId = null;
if (record.rackId) {
const rack = racks.find(r => r.rackId === record.rackId);
@@ -164,12 +170,69 @@ const IdleDeviceManagement = () => {
position: record.position,
description: record.description,
});
if (record.rackId) {
setShelveSelectedRackId(record.rackId);
}
setIsShelveModalVisible(true);
};
const checkShelvePositionConflict = async (rackId, position, height) => {
if (!rackId || !position) {
setShelvePositionConflict(null);
return;
}
try {
const result = await deviceAPI.checkPosition(rackId, { position, height: height || 1 });
if (!result.available) {
setShelvePositionConflict(result.reason);
} else {
setShelvePositionConflict(null);
}
} catch (error) {
console.error('检查U位冲突失败:', error);
setShelvePositionConflict(null);
}
};
const handleShelveRackChange = (value) => {
setShelveSelectedRackId(value);
const position = shelveForm.getFieldValue('position');
const height = shelveForm.getFieldValue('height');
if (position) {
checkShelvePositionConflict(value, position, height);
} else {
setShelvePositionConflict(null);
}
};
const handleShelvePositionChange = (e) => {
const value = e.target.value ? parseInt(e.target.value) : null;
const height = shelveForm.getFieldValue('height');
if (shelveSelectedRackId && value) {
checkShelvePositionConflict(shelveSelectedRackId, value, height);
} else {
setShelvePositionConflict(null);
}
};
const handleShelveHeightChange = (e) => {
const value = e.target.value ? parseInt(e.target.value) : null;
const position = shelveForm.getFieldValue('position');
if (shelveSelectedRackId && position) {
checkShelvePositionConflict(shelveSelectedRackId, position, value);
} else {
setShelvePositionConflict(null);
}
};
const handleShelveSubmit = async () => {
try {
const values = await shelveForm.validateFields();
if (shelvePositionConflict) {
message.error('存在U位冲突,请重新选择上架位置');
return;
}
const submitData = {
name: values.name,
type: values.type,
@@ -866,6 +929,7 @@ const IdleDeviceManagement = () => {
placeholder={selectedShelveRoomId ? "请选择机柜" : "请先选择机房"}
disabled={!selectedShelveRoomId}
style={{ borderRadius: '8px' }}
onChange={handleShelveRackChange}
>
{racks
.filter((rack) => rack.roomId === selectedShelveRoomId)
@@ -879,10 +943,29 @@ const IdleDeviceManagement = () => {
</Col>
<Col span={8}>
<Form.Item name="position" label="U位">
<Input type="number" placeholder="请输入U位" min={1} style={{ borderRadius: '8px' }} />
<Input type="number" placeholder="请输入U位" min={1} style={{ borderRadius: '8px' }} onChange={handleShelvePositionChange} />
</Form.Item>
</Col>
</Row>
{shelvePositionConflict && (
<div style={{ marginTop: '12px' }}>
<div style={{
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '12px 16px',
display: 'flex',
alignItems: 'flex-start',
gap: '10px'
}}>
<ExclamationCircleOutlined style={{ color: '#ef4444', fontSize: '18px', marginTop: '2px' }} />
<div>
<div style={{ color: '#dc2626', fontWeight: 600, marginBottom: '4px' }}>U位冲突</div>
<div style={{ color: '#991b1b', fontSize: '13px' }}>{shelvePositionConflict}</div>
</div>
</div>
</div>
)}
</div>
<div style={{
File diff suppressed because it is too large Load Diff
@@ -1093,15 +1093,8 @@ const Rack3DVisualization = () => {
device={selectedDevice}
onClose={() => setSelectedDevice(null)}
onEdit={handleEditDevice}
onAddNic={handleAddNic}
onAddPort={handleAddPort}
onAddCable={handleAddCable}
tooltipFields={tooltipFields}
cables={deviceCables}
onRefreshCables={() =>
selectedDevice && fetchDeviceCables(selectedDevice.deviceId || selectedDevice.id)
}
onDeleteCable={handleDeleteCable}
refreshTrigger={refreshTrigger}
/>
</Content>
+104 -9
View File
@@ -417,7 +417,8 @@ function RackManagement() {
message.success('机柜删除成功');
fetchRacks();
} catch (error) {
message.error('机柜删除失败');
const errorMsg = error.response?.data?.error || '机柜删除失败';
message.error(errorMsg);
console.error('机柜删除失败:', error);
}
},
@@ -438,8 +439,16 @@ function RackManagement() {
cancelText: '取消',
onOk: async () => {
try {
await Promise.all(selectedRackIds.map(id => axios.delete(`/api/racks/${id}`)));
message.success(`成功删除 ${selectedRackIds.length} 个机柜`);
const results = await Promise.allSettled(selectedRackIds.map(id => axios.delete(`/api/racks/${id}`)));
const succeeded = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected');
if (succeeded > 0) {
message.success(`成功删除 ${succeeded} 个机柜`);
}
if (failed.length > 0) {
const firstError = failed[0].reason.response?.data?.error || '部分机柜删除失败';
message.error(`${firstError}${failed.length} 个失败)`);
}
setSelectedRackIds([]);
fetchRacks();
} catch (error) {
@@ -519,13 +528,33 @@ function RackManagement() {
setImportProgress(100);
setImportPhase('导入完成');
setImportResult(response.data);
const resData = response.data;
const importResult = {
total: resData.total || 0,
successCount: resData.imported || 0,
duplicates: resData.duplicates || 0,
failedCount: 0,
errors: [],
createdRacks: resData.createdRacks || [],
skippedRacks: resData.skippedRacks || []
};
if (resData.details && Array.isArray(resData.details)) {
importResult.failedCount = resData.details.length;
importResult.errors = resData.details.map(item => ({
row: item.row,
error: item.errors.join('')
}));
}
setImportResult(importResult);
setIsImporting(false);
if (response.data.success) {
if (resData.success) {
message.success('机柜导入成功');
} else {
message.warning(response.data.message || '部分记录导入失败');
message.warning(resData.message || '部分记录导入失败');
}
fetchRacks();
@@ -533,8 +562,24 @@ function RackManagement() {
} catch (error) {
setIsImporting(false);
setImportProgress(0);
message.error('机柜导入失败');
console.error('机柜导入失败:', error);
const errorData = error.response?.data;
if (errorData?.details && Array.isArray(errorData.details)) {
const importResult = {
total: errorData.total || 0,
successCount: 0,
failedCount: errorData.details.length,
errors: errorData.details.map(item => ({
row: item.row,
error: item.errors.join('')
}))
};
setImportResult(importResult);
setImportPhase('导入失败');
} else {
message.error(errorData?.error || errorData?.message || '机柜导入失败');
console.error('机柜导入失败:', error);
}
return false;
}
},
@@ -1380,10 +1425,60 @@ function RackManagement() {
导入失败{importResult.failedCount}
</p>
)}
{importResult.duplicates > 0 && (
<p style={{ margin: '8px 0', color: '#faad14' }}>
跳过已存在{importResult.duplicates}
</p>
)}
</div>
{importResult.createdRacks && importResult.createdRacks.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#52c41a' }}>
本次新增机柜{importResult.createdRacks.length}
</p>
<div style={{
maxHeight: '150px',
overflow: 'auto',
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '8px',
padding: '12px'
}}>
{importResult.createdRacks.map((rack, idx) => (
<div key={idx} style={{ fontSize: '13px', marginBottom: '4px' }}>
{rack.rackId} - {rack.name}
</div>
))}
</div>
</div>
)}
{importResult.skippedRacks && importResult.skippedRacks.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#faad14' }}>
已跳过机柜{importResult.skippedRacks.length}
</p>
<div style={{
maxHeight: '150px',
overflow: 'auto',
background: '#fffbe6',
border: '1px solid #ffe58f',
borderRadius: '8px',
padding: '12px'
}}>
{importResult.skippedRacks.map((rack, idx) => (
<div key={idx} style={{ fontSize: '13px', marginBottom: '4px' }}>
{rack.rackId} - {rack.name}
</div>
))}
</div>
</div>
)}
{importResult.errors && importResult.errors.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<p style={{ fontWeight: '600', marginBottom: '8px' }}>错误详情</p>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#ff4d4f' }}>错误详情</p>
{importResult.errors.slice(0, 5).map((err, idx) => (
<div
key={idx}