refactor: 移除调试日志并优化代码结构

feat(components): 新增设备管理相关组件和仪表盘组件
feat(hooks): 添加自定义hooks用于API调用和数据管理
style: 优化滚动条样式和模态框布局
chore: 清理无用脚本和调试文件
docs: 更新组件导出文件
This commit is contained in:
zhang1106
2026-03-10 14:34:39 +08:00
parent 98f705de8b
commit 196dc4ae41
43 changed files with 3082 additions and 3736 deletions
+40 -163
View File
@@ -50,6 +50,7 @@ import { ConfigProvider, useConfig } from './context/ConfigContext';
import { Scene3DProvider } from './context/Scene3DContext';
import { useDesignTokens } from './hooks/useDesignTokens';
import useIdleTimeout from './hooks/useIdleTimeout';
import { SWRConfig, swrConfig } from './hooks/useSWR';
import axios from 'axios';
import { Spin } from 'antd';
@@ -561,55 +562,50 @@ const AppLayout = ({ children }) => {
);
};
const routeConfig = [
{ path: '/', component: Dashboard },
{ path: '/devices', component: DeviceManagement },
{ path: '/racks', component: RackManagement },
{ path: '/rooms', component: RoomManagement },
{ path: '/fields', component: DeviceFieldManagement },
{ path: '/consumables', component: ConsumableManagement },
{ path: '/consumables-categories', component: CategoryManagement },
{ path: '/consumables-stats', component: ConsumableStatistics },
{ path: '/consumables-logs', component: ConsumableLogs },
{ path: '/users', component: UserManagement },
{ path: '/tickets', component: TicketManagement },
{ path: '/ticket-categories', component: TicketCategoryManagement },
{ path: '/ticket-statistics', component: TicketStatistics },
{ path: '/ticket-fields', component: TicketFieldManagement },
{ path: '/settings', component: SystemSettings },
{ path: '/cables', component: CableManagement },
{ path: '/inventory', component: InventoryManagement },
{ path: '/inventory/execution', component: InventoryTaskExecution },
{ path: '/pending-devices', component: PendingDeviceManagement },
{ path: '/ports', component: PortManagement },
];
const ThemeConfig = () => {
const designTokens = useDesignTokens();
return (
<AntdConfigProvider theme={{ token: designTokens }}>
<Router>
<Suspense fallback={<PageLoading />}>
<Routes>
<SWRConfig value={swrConfig}>
<Router>
<Suspense fallback={<PageLoading />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route
path="/devices"
element={
<PrivateRoute>
<DeviceManagement />
</PrivateRoute>
}
/>
<Route
path="/racks"
element={
<PrivateRoute>
<RackManagement />
</PrivateRoute>
}
/>
<Route
path="/rooms"
element={
<PrivateRoute>
<RoomManagement />
</PrivateRoute>
}
/>
<Route
path="/fields"
element={
<PrivateRoute>
<DeviceFieldManagement />
</PrivateRoute>
}
/>
{routeConfig.map(({ path, component: Component }) => (
<Route
key={path}
path={path}
element={
<PrivateRoute>
<Component />
</PrivateRoute>
}
/>
))}
<Route
path="/visualization-3d"
element={
@@ -620,130 +616,11 @@ const ThemeConfig = () => {
</PrivateRoute>
}
/>
<Route
path="/consumables"
element={
<PrivateRoute>
<ConsumableManagement />
</PrivateRoute>
}
/>
<Route
path="/consumables-categories"
element={
<PrivateRoute>
<CategoryManagement />
</PrivateRoute>
}
/>
<Route
path="/consumables-stats"
element={
<PrivateRoute>
<ConsumableStatistics />
</PrivateRoute>
}
/>
<Route
path="/consumables-logs"
element={
<PrivateRoute>
<ConsumableLogs />
</PrivateRoute>
}
/>
<Route
path="/users"
element={
<PrivateRoute>
<UserManagement />
</PrivateRoute>
}
/>
<Route
path="/tickets"
element={
<PrivateRoute>
<TicketManagement />
</PrivateRoute>
}
/>
<Route
path="/ticket-categories"
element={
<PrivateRoute>
<TicketCategoryManagement />
</PrivateRoute>
}
/>
<Route
path="/ticket-statistics"
element={
<PrivateRoute>
<TicketStatistics />
</PrivateRoute>
}
/>
<Route
path="/ticket-fields"
element={
<PrivateRoute>
<TicketFieldManagement />
</PrivateRoute>
}
/>
<Route
path="/settings"
element={
<PrivateRoute>
<SystemSettings />
</PrivateRoute>
}
/>
<Route
path="/cables"
element={
<PrivateRoute>
<CableManagement />
</PrivateRoute>
}
/>
<Route
path="/inventory"
element={
<PrivateRoute>
<InventoryManagement />
</PrivateRoute>
}
/>
<Route
path="/inventory/execution"
element={
<PrivateRoute>
<InventoryTaskExecution />
</PrivateRoute>
}
/>
<Route
path="/pending-devices"
element={
<PrivateRoute>
<PendingDeviceManagement />
</PrivateRoute>
}
/>
<Route
path="/ports"
element={
<PrivateRoute>
<PortManagement />
</PrivateRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</Router>
</SWRConfig>
</AntdConfigProvider>
);
};
-5
View File
@@ -45,13 +45,8 @@ api.interceptors.response.use(
if (status === 401) {
const currentPath = window.location.pathname;
console.log('[API] 401 error, current path:', currentPath);
if (!currentPath.startsWith('/login')) {
const savedToken = localStorage.getItem('token');
if (savedToken) {
console.log('[API] Token exists but got 401, might be expired');
}
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
@@ -116,7 +116,6 @@ const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => {
targetDeviceId: values.sourceDeviceId,
targetPort: values.sourcePort,
};
console.log('Swapped source/target to ensure Switch is Source');
}
await axios.post('/api/cables', payload);
@@ -0,0 +1,38 @@
import React, { useState, useEffect, useRef } from 'react';
const AnimatedCounter = ({ value, duration = 1500 }) => {
const [displayValue, setDisplayValue] = useState(0);
const animationRef = useRef(null);
const startTimeRef = useRef(null);
useEffect(() => {
const animate = (currentTime) => {
if (!startTimeRef.current) {
startTimeRef.current = currentTime;
}
const elapsed = currentTime - startTimeRef.current;
const progress = Math.min(elapsed / duration, 1);
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
const currentValue = Math.floor(easeOutQuart * value);
setDisplayValue(currentValue);
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate);
}
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [value, duration]);
return <span>{displayValue}</span>;
};
export default React.memo(AnimatedCounter);
@@ -0,0 +1,61 @@
import React from 'react';
import { designTokens } from '../../config/theme';
const CircularProgress = ({ percentage, size = 120, strokeWidth = 10, color, label }) => {
const circumference = 2 * Math.PI * ((size - strokeWidth) / 2);
const offset = circumference - (percentage / 100) * circumference;
return (
<div style={{ position: 'relative', width: size, height: size }}>
<svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
<circle
cx={size / 2}
cy={size / 2}
r={(size - strokeWidth) / 2}
fill="none"
stroke="#f0f0f0"
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={(size - strokeWidth) / 2}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
style={{
transition: 'stroke-dashoffset 1s ease-out',
filter: `drop-shadow(0 0 6px ${color}40)`,
}}
/>
</svg>
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
}}
>
<div
style={{
fontSize: '1.5rem',
fontWeight: '700',
color: designTokens.colors.text.primary,
}}
>
{percentage}%
</div>
<div style={{ fontSize: '0.75rem', color: designTokens.colors.text.secondary }}>
{label}
</div>
</div>
</div>
);
};
export default React.memo(CircularProgress);
@@ -0,0 +1,53 @@
import React from 'react';
import { designTokens } from '../../config/theme';
const DeviceTrendChart = ({ data }) => {
const maxValue = Math.max(...data.map((d) => d.value));
const chartHeight = 120;
return (
<div style={{ marginTop: '16px' }}>
<div
style={{
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between',
height: chartHeight,
gap: '8px',
padding: '0 8px',
}}
>
{data.map((item, index) => (
<div
key={index}
style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}
>
<div
style={{
width: '100%',
maxWidth: '40px',
height: `${(item.value / maxValue) * chartHeight}px`,
borderRadius: '4px 4px 0 0',
background: `linear-gradient(180deg, ${item.color} 0%, ${item.color}80 100%)`,
transition: `height ${designTokens.transitions.slow}`,
boxShadow: `0 -2px 8px ${item.color}30`,
}}
/>
<span
style={{
fontSize: '0.7rem',
color: designTokens.colors.text.tertiary,
marginTop: '4px',
whiteSpace: 'nowrap',
}}
>
{item.label}
</span>
</div>
))}
</div>
</div>
);
};
export default React.memo(DeviceTrendChart);
@@ -0,0 +1,139 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
CloudServerOutlined,
DatabaseOutlined,
WarningOutlined,
BarChartOutlined,
AppstoreOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { designTokens } from '../../config/theme';
const NAV_BUTTONS_DATA = [
{
key: 'devices',
icon: CloudServerOutlined,
text: '设备管理',
path: '/devices',
color: designTokens.colors.primary.main,
},
{
key: 'racks',
icon: DatabaseOutlined,
text: '资源规划',
path: '/racks',
color: '#722ed1',
},
{
key: 'faults',
icon: WarningOutlined,
text: '故障监控',
path: '/faults',
color: designTokens.colors.warning.main,
},
{
key: 'tickets',
icon: BarChartOutlined,
text: '工单管理',
path: '/tickets',
color: '#13c2c2',
},
{
key: 'consumables',
icon: AppstoreOutlined,
text: '耗材管理',
path: '/consumables',
color: '#fa8c16',
},
{
key: 'settings',
icon: SettingOutlined,
text: '系统配置',
path: '/settings',
color: designTokens.colors.success.main,
},
];
const createNavButtonStyle = (color, isHovered) => ({
height: 'auto',
padding: 'clamp(16px, 4vw, 24px) clamp(12px, 3vw, 20px)',
borderRadius: designTokens.borderRadius.medium,
border: `2px solid ${isHovered ? color : '#f0f0f0'}`,
background: '#fff',
transition: `all ${designTokens.transitions.normal}`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 'clamp(8px, 2vw, 12px)',
cursor: 'pointer',
boxShadow: isHovered ? designTokens.shadows.large : designTokens.shadows.small,
transform: isHovered ? 'translateY(-4px)' : 'none',
minWidth: 0,
});
const createNavIconContainer = (color) => ({
width: 'clamp(44px, 10vw, 60px)',
height: 'clamp(44px, 10vw, 60px)',
borderRadius: designTokens.borderRadius.medium,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: `linear-gradient(135deg, ${color}20 0%, ${color}10 100%)`,
fontSize: 'clamp(20px, 5vw, 28px)',
transition: `all ${designTokens.transitions.normal}`,
flexShrink: 0,
});
const navTextStyle = {
fontSize: 'clamp(0.75rem, 2vw, 0.9rem)',
fontWeight: '600',
color: designTokens.colors.text.primary,
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
};
const NavigationGrid = ({ hoveredCard, onHover }) => {
const navigate = useNavigate();
return (
<div
className="nav-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
gap: 'clamp(8px, 2vw, 16px)',
marginBottom: '24px',
}}
>
{NAV_BUTTONS_DATA.map(({ key, icon: Icon, text, color }) => {
const isHovered = hoveredCard === `nav-${key}`;
return (
<div
key={key}
className="nav-button"
style={{
...createNavButtonStyle(color, isHovered),
animationDelay: `${NAV_BUTTONS_DATA.findIndex((b) => b.key === key) * 0.1}s`,
}}
onMouseEnter={() => onHover(`nav-${key}`)}
onMouseLeave={() => onHover(null)}
onClick={() => navigate(`/${key}`)}
>
<div className="nav-icon" style={createNavIconContainer(color)}>
<Icon style={{ color, fontSize: 'clamp(20px, 5vw, 28px)' }} />
</div>
<span className="nav-text" style={navTextStyle}>
{text}
</span>
</div>
);
})}
</div>
);
};
export default React.memo(NavigationGrid);
@@ -0,0 +1,60 @@
import React from 'react';
import { Typography } from 'antd';
import { designTokens } from '../../config/theme';
const { Text } = Typography;
const PowerGauge = ({ value, maxValue }) => {
const percentage = Math.min((value / maxValue) * 100, 100);
const getColor = () => {
if (percentage >= 80) return designTokens.colors.error.main;
if (percentage >= 60) return designTokens.colors.warning.main;
return designTokens.colors.success.main;
};
return (
<div style={{ marginTop: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<Text style={{ fontSize: '0.85rem', color: designTokens.colors.text.secondary }}>
功率使用率
</Text>
<Text style={{ fontSize: '0.85rem', fontWeight: '600', color: getColor() }}>
{percentage.toFixed(1)}%
</Text>
</div>
<div
style={{
height: '8px',
borderRadius: '4px',
background: '#f0f0f0',
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
borderRadius: '4px',
background: `linear-gradient(90deg, ${getColor()}, ${getColor()}80)`,
width: `${percentage}%`,
transition: 'width 0.8s ease-out',
boxShadow: `0 0 8px ${getColor()}40`,
}}
/>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: '4px',
fontSize: '0.75rem',
color: designTokens.colors.text.tertiary,
}}
>
<span>{value}W</span>
<span>{maxValue}W</span>
</div>
</div>
);
};
export default React.memo(PowerGauge);
@@ -0,0 +1,89 @@
import React from 'react';
import { Card, Typography } from 'antd';
import { LineChartOutlined, SafetyOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
const { Text } = Typography;
const quickStatItemStyle = {
background: 'linear-gradient(135deg, #fff 0%, #fafafa 100%)',
borderRadius: designTokens.borderRadius.medium,
padding: '20px',
display: 'flex',
alignItems: 'center',
gap: '16px',
border: '1px solid #f0f0f0',
boxShadow: designTokens.shadows.small,
};
const QuickStats = ({ onlineRate, powerUsage }) => {
const quickStats = [
{
icon: LineChartOutlined,
label: '在线率',
value: `${onlineRate}%`,
color: designTokens.colors.success.main,
},
{
icon: SafetyOutlined,
label: '安全等级',
value: 'A级',
color: designTokens.colors.primary.main,
},
{
icon: ThunderboltOutlined,
label: '功率使用',
value: `${powerUsage}W`,
color: designTokens.colors.warning.main,
},
];
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: '16px',
marginBottom: '0',
animation: 'fadeInUp 0.6s ease-out 0.5s backwards',
}}
>
{quickStats.map((stat, index) => (
<div key={index} style={quickStatItemStyle}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: designTokens.borderRadius.medium,
background: `linear-gradient(135deg, ${stat.color}20 0%, ${stat.color}10 100%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
color: stat.color,
boxShadow: `0 4px 12px ${stat.color}20`,
}}
>
<stat.icon />
</div>
<div>
<Text style={{ color: designTokens.colors.text.secondary, fontSize: '0.85rem' }}>
{stat.label}
</Text>
<div
style={{
fontSize: '1.2rem',
fontWeight: '700',
color: designTokens.colors.text.primary,
}}
>
{stat.value}
</div>
</div>
</div>
))}
</div>
);
};
export default React.memo(QuickStats);
@@ -0,0 +1,176 @@
import React from 'react';
import { Card, Col, Tag, Spin } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
import AnimatedCounter from './AnimatedCounter';
const createStatCardStyle = (color) => ({
borderRadius: designTokens.borderRadius.large,
border: 'none',
boxShadow: designTokens.shadows.medium,
background: '#fff',
transition: `all ${designTokens.transitions.normal}`,
overflow: 'hidden',
cursor: 'pointer',
height: '100%',
animation: 'fadeInUp 0.6s ease-out backwards',
borderLeft: `4px solid ${color}`,
});
const createStatIconContainer = (color) => ({
width: 'clamp(40px, 8vw, 64px)',
height: 'clamp(40px, 8vw, 64px)',
borderRadius: designTokens.borderRadius.medium,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 'clamp(20px, 4vw, 32px)',
transition: `all ${designTokens.transitions.normal}`,
flexShrink: 0,
background: `linear-gradient(135deg, ${color}20 0%, ${color}10 100%)`,
});
const StatCard = ({
config,
stats,
loading,
animatedKey,
hoveredCard,
onHover,
}) => {
const {
icon: Icon,
color,
statKey,
title,
trend,
tagColor,
customStatus,
xs,
sm,
lg,
xl,
delay,
} = config;
const colProps = { xs, sm, lg, xl };
const cardStyle = {
...createStatCardStyle(color),
...(hoveredCard === statKey
? { transform: 'translateY(-6px)', boxShadow: designTokens.shadows.xl }
: {}),
animationDelay: `${delay * 0.1}s`,
};
return (
<Col key={statKey} {...colProps}>
<Card
style={cardStyle}
onMouseEnter={() => onHover(statKey)}
onMouseLeave={() => onHover(null)}
styles={{ body: { padding: 'clamp(16px, 3vw, 24px)' } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
}}
>
<span
style={{
fontSize: 'clamp(0.75rem, 2vw, 0.9rem)',
fontWeight: '600',
color: designTokens.colors.text.secondary,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
}}
>
{title}
</span>
<div style={createStatIconContainer(color)}>
<Icon style={{ color }} />
</div>
</div>
<div
className="stat-value"
style={{
fontSize: 'clamp(1.6rem, 4vw, 2.2rem)',
fontWeight: '700',
color: color,
lineHeight: 1,
whiteSpace: 'nowrap',
}}
>
{loading ? (
<Spin size="small" />
) : (
<AnimatedCounter key={`${animatedKey}-${statKey}`} value={stats[statKey]} />
)}
</div>
{customStatus ? (
<div
style={{
display: 'flex',
alignItems: 'center',
fontSize: 'clamp(0.7rem, 1.8vw, 0.85rem)',
color: designTokens.colors.success.main,
whiteSpace: 'nowrap',
}}
>
<span
style={{
width: '6px',
height: '6px',
background: designTokens.colors.success.main,
borderRadius: '50%',
marginRight: '6px',
boxShadow: '0 0 6px rgba(82, 196, 26, 0.5)',
flexShrink: 0,
}}
/>
<span>{statKey === 'totalRacks' ? '正常运行中' : '全部在线'}</span>
</div>
) : (
<div
style={{
display: 'flex',
alignItems: 'center',
fontSize: 'clamp(0.7rem, 1.8vw, 0.875rem)',
fontWeight: '500',
color: trend > 0 ? designTokens.colors.success.main : designTokens.colors.error.main,
flexWrap: 'wrap',
gap: '4px',
}}
>
{trend > 0 ? (
<ArrowUpOutlined style={{ fontSize: '0.75rem' }} />
) : (
<ArrowDownOutlined style={{ fontSize: '0.75rem' }} />
)}
<span>{Math.abs(trend)}%</span>
<Tag
color={tagColor}
style={{
fontSize: 'clamp(0.6rem, 1.5vw, 0.75rem)',
borderRadius: '4px',
margin: 0,
padding: '0 4px',
lineHeight: '1.4',
}}
>
环比
</Tag>
</div>
)}
</div>
</Card>
</Col>
);
};
export default React.memo(StatCard);
@@ -0,0 +1,57 @@
import React from 'react';
import { Typography } from 'antd';
import { designTokens } from '../../config/theme';
const { Text } = Typography;
const StatusLegend = () => {
const legends = [
{ color: designTokens.colors.success.main, label: '运行中', percent: 60 },
{ color: designTokens.colors.warning.main, label: '维护中', percent: 20 },
{ color: designTokens.colors.error.main, label: '故障', percent: 10 },
{ color: designTokens.colors.primary.main, label: '离线', percent: 10 },
];
return (
<div style={{ marginTop: '16px' }}>
{legends.map((item, index) => (
<div
key={index}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: index < legends.length - 1 ? '1px solid #f5f5f5' : 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '12px',
height: '12px',
borderRadius: '3px',
background: item.color,
boxShadow: `0 0 6px ${item.color}40`,
}}
/>
<Text style={{ fontSize: '0.85rem', color: designTokens.colors.text.secondary }}>
{item.label}
</Text>
</div>
<Text
style={{
fontSize: '0.85rem',
fontWeight: '600',
color: designTokens.colors.text.primary,
}}
>
{item.percent}%
</Text>
</div>
))}
</div>
);
};
export default React.memo(StatusLegend);
@@ -0,0 +1,69 @@
import React from 'react';
import { Button } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
const systemInfoStyle = {
background: 'linear-gradient(135deg, #f0f7ff 0%, #e6f7ff 100%)',
borderRadius: designTokens.borderRadius.medium,
padding: '20px',
border: '1px solid #91d5ff',
};
const SystemInfo = ({ onRefresh, isRefreshing }) => {
return (
<div style={{ animation: 'fadeInUp 0.6s ease-out 0.5s backwards' }}>
<div style={systemInfoStyle}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '16px',
}}
>
<div>
<p
style={{
margin: '0',
fontSize: '0.9rem',
color: designTokens.colors.text.primary,
fontWeight: '600',
}}
>
<strong>系统版本</strong> v1.0.0
</p>
<p
style={{
margin: '4px 0 0 0',
fontSize: '0.85rem',
color: designTokens.colors.text.secondary,
}}
>
<strong>最后更新</strong>
{new Date().toLocaleDateString()}
</p>
</div>
<Button
type="primary"
icon={<ReloadOutlined spin={isRefreshing} />}
size="small"
onClick={onRefresh}
loading={isRefreshing}
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.3)',
}}
>
刷新数据
</Button>
</div>
</div>
</div>
);
};
export default React.memo(SystemInfo);
@@ -0,0 +1,9 @@
export { default as AnimatedCounter } from './AnimatedCounter';
export { default as CircularProgress } from './CircularProgress';
export { default as PowerGauge } from './PowerGauge';
export { default as DeviceTrendChart } from './DeviceTrendChart';
export { default as StatusLegend } from './StatusLegend';
export { default as StatCard } from './StatCard';
export { default as NavigationGrid } from './NavigationGrid';
export { default as QuickStats } from './QuickStats';
export { default as SystemInfo } from './SystemInfo';
@@ -0,0 +1,110 @@
import React from 'react';
import { Modal, Form, Select, Button } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
const { Option } = Select;
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const secondaryActionStyle = {
height: '40px',
borderRadius: designTokens.borderRadius.small,
border: `1px solid ${designTokens.colors.border.light}`,
fontWeight: '500',
};
const BatchStatusModal = ({
visible,
selectedCount,
loading,
onSubmit,
onCancel,
}) => {
const [form] = Form.useForm();
const handleSubmit = async () => {
try {
const values = await form.validateFields();
await onSubmit(values.status);
} catch (error) {
if (!error.errorFields) {
console.error('批量状态变更失败:', error);
}
}
};
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<ReloadOutlined style={{ color: '#52c41a' }} />
批量状态变更
</div>
}
open={visible}
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel} style={secondaryActionStyle}>
取消
</Button>,
<Button
key="submit"
type="primary"
loading={loading}
onClick={handleSubmit}
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
确定
</Button>,
]}
destroyOnHidden
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '24px' },
}}
>
<Form form={form} layout="vertical">
<Form.Item
name="status"
label="选择新状态"
rules={[{ required: true, message: '请选择设备状态' }]}
>
<Select placeholder="请选择设备状态" style={{ width: '100%' }}>
<Option value="running">运行中</Option>
<Option value="maintenance">维护中</Option>
<Option value="offline">离线</Option>
<Option value="fault">故障</Option>
</Select>
</Form.Item>
<div style={{ color: '#666', fontSize: '13px' }}>
已选择{' '}
<span style={{ color: '#1890ff', fontWeight: 600 }}>{selectedCount}</span> 个设备
</div>
</Form>
</Modal>
);
};
export default React.memo(BatchStatusModal);
@@ -0,0 +1,256 @@
import React from 'react';
import { Modal, Button, Card, Row, Col, Tag } from 'antd';
import { AppstoreOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
import { getStatusConfig, getTypeLabel, getDeviceTypeIcon } from '../../utils/deviceUtils.jsx';
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const secondaryActionStyle = {
height: '40px',
borderRadius: designTokens.borderRadius.small,
border: `1px solid ${designTokens.colors.border.light}`,
fontWeight: '500',
};
const DeviceDetailModal = ({
visible,
device,
deviceFields,
onClose,
onEdit,
onViewTickets,
onCreateTicket,
}) => {
if (!device) return null;
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<AppstoreOutlined style={{ color: '#667eea' }} />
设备详情
</div>
}
open={visible}
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose} style={secondaryActionStyle}>
关闭
</Button>,
<Button key="viewTickets" onClick={() => onViewTickets(device)} style={secondaryActionStyle}>
查看工单
</Button>,
<Button key="createTicket" onClick={() => onCreateTicket(device)} style={secondaryActionStyle}>
创建工单
</Button>,
<Button
key="edit"
type="primary"
onClick={() => {
onClose();
onEdit(device);
}}
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
编辑
</Button>,
]}
width={700}
destroyOnHidden
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '0', overflow: 'auto' },
}}
>
<div>
<div
style={{
padding: '24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#fff',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div
style={{
width: '64px',
height: '64px',
borderRadius: '12px',
backgroundColor: 'rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{getDeviceTypeIcon(device.type)}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '24px', fontWeight: 600, marginBottom: '8px' }}>
{device.name}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', opacity: 0.9 }}>
<span>{getTypeLabel(device.type)}</span>
<span>|</span>
<span>{device.deviceId}</span>
<span>|</span>
<Tag
color={device.status ? getStatusConfig(device.status).badgeColor : 'default'}
style={{ margin: 0 }}
>
{device.status ? getStatusConfig(device.status).text : '-'}
</Tag>
</div>
</div>
</div>
</div>
<div style={{ padding: '20px 24px' }}>
<Card
size="small"
title={<span style={{ fontWeight: 600 }}>基本信息</span>}
style={{ marginBottom: '16px', borderRadius: '8px' }}
>
<Row gutter={[24, 16]}>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>设备型号</div>
<div style={{ fontWeight: 500 }}>{device.model || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>序列号</div>
<div style={{ fontWeight: 500 }}>{device.serialNumber || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>IP地址</div>
<div style={{ fontWeight: 500 }}>{device.ipAddress || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>所在机房</div>
<div style={{ fontWeight: 500 }}>{device.Rack?.Room?.name || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>所在机柜</div>
<div style={{ fontWeight: 500 }}>{device.Rack?.name || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>位置(U)</div>
<div style={{ fontWeight: 500 }}>U{device.position || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>高度</div>
<div style={{ fontWeight: 500 }}>{device.height ? `${device.height}U` : '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>功率</div>
<div style={{ fontWeight: 500 }}>{device.power ? `${device.power}W` : '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>状态</div>
<div
style={{
fontWeight: 500,
color: device.status ? getStatusConfig(device.status).color : '#666',
}}
>
{device.status ? getStatusConfig(device.status).text : '-'}
</div>
</Col>
</Row>
</Card>
<Card
size="small"
title={<span style={{ fontWeight: 600 }}>维保信息</span>}
style={{ marginBottom: '16px', borderRadius: '8px' }}
>
<Row gutter={[24, 16]}>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>购买日期</div>
<div style={{ fontWeight: 500 }}>
{device.purchaseDate
? new Date(device.purchaseDate).toLocaleDateString('zh-CN')
: '-'}
</div>
</Col>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>保修到期</div>
<div
style={{
fontWeight:
device.warrantyExpiry && new Date(device.warrantyExpiry) < new Date()
? 600
: 500,
color:
device.warrantyExpiry && new Date(device.warrantyExpiry) < new Date()
? '#d93025'
: '#333',
}}
>
{device.warrantyExpiry
? new Date(device.warrantyExpiry).toLocaleDateString('zh-CN')
: '-'}
</div>
</Col>
</Row>
</Card>
{device.description && (
<Card
size="small"
title={<span style={{ fontWeight: 600 }}>描述</span>}
style={{ marginBottom: '16px', borderRadius: '8px' }}
>
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{device.description}</div>
</Card>
)}
{device.customFields && Object.keys(device.customFields).length > 0 && (
<Card
size="small"
title={<span style={{ fontWeight: 600 }}>自定义字段</span>}
style={{ borderRadius: '8px' }}
>
<Row gutter={[24, 16]}>
{Object.entries(device.customFields).map(([key, value]) => {
const fieldConfig = deviceFields.find((f) => f.fieldName === key);
const displayName = fieldConfig?.displayName || key;
return (
<Col span={8} key={key}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>
{displayName}
</div>
<div style={{ fontWeight: 500 }}>{String(value)}</div>
</Col>
);
})}
</Row>
</Card>
)}
</div>
</div>
</Modal>
);
};
export default React.memo(DeviceDetailModal);
@@ -0,0 +1,361 @@
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 dayjs from 'dayjs';
import { designTokens } from '../../config/theme';
import { getFormInitialValues, prepareDeviceFormData } from '../../utils/deviceUtils.jsx';
const { Option } = Select;
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const inputStyle = {
borderRadius: '8px',
transition: 'all 0.3s ease',
};
const DeviceFormModal = ({
visible,
editingDevice,
deviceFields,
racks,
rooms,
onCancel,
onSubmit,
}) => {
const [form] = Form.useForm();
const [selectedRoomId, setSelectedRoomId] = useState(null);
useEffect(() => {
if (visible) {
if (editingDevice) {
const initialValues = getFormInitialValues(editingDevice, racks);
if (initialValues.purchaseDate) {
initialValues.purchaseDate = dayjs(initialValues.purchaseDate);
}
if (initialValues.warrantyExpiry) {
initialValues.warrantyExpiry = dayjs(initialValues.warrantyExpiry);
}
form.setFieldsValue(initialValues);
if (editingDevice.rackId) {
const rack = racks.find((r) => r.rackId === editingDevice.rackId);
if (rack) {
setSelectedRoomId(rack.roomId);
}
}
} else {
form.resetFields();
setSelectedRoomId(null);
}
}
}, [visible, editingDevice, racks, form]);
const handleSubmit = (values) => {
const deviceData = prepareDeviceFormData(values, !!editingDevice);
onSubmit(deviceData);
};
const handleRoomChange = (value) => {
setSelectedRoomId(value);
form.setFieldValue('rackId', undefined);
};
const renderFieldControl = (field) => {
switch (field.fieldType) {
case 'number':
return (
<InputNumber
placeholder={`请输入${field.displayName}`}
min={0}
style={{ width: '100%', ...inputStyle }}
className="form-input-enhanced"
/>
);
case 'boolean':
return <Switch />;
case 'date':
return (
<DatePicker
style={{ width: '100%', ...inputStyle }}
placeholder={`请选择${field.displayName}`}
className="form-input-enhanced"
/>
);
case 'textarea':
return (
<Input.TextArea
placeholder={`请输入${field.displayName}`}
rows={3}
style={inputStyle}
className="form-input-enhanced"
/>
);
case 'select':
return (
<Select
placeholder={`请选择${field.displayName}`}
style={inputStyle}
className="form-input-enhanced"
>
{field.options &&
field.options.map((option) => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
);
default:
return (
<Input
placeholder={`请输入${field.displayName}`}
style={inputStyle}
className="form-input-enhanced"
/>
);
}
};
const filteredFields = deviceFields.filter(
(field) => field.fieldName !== 'deviceId' && field.fieldName !== 'rackId'
);
const formItems = [];
filteredFields.forEach((field) => {
if (field.fieldName === 'serialNumber') {
formItems.push(
<React.Fragment key={field.fieldName}>
<Col span={12} key={`${field.fieldName}-col`}>
<Form.Item
name={field.fieldName}
label={
<span>
{field.displayName}
{field.required && (
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
)}
</span>
}
rules={
field.required ? [{ required: true, message: `请输入${field.displayName}` }] : []
}
>
{renderFieldControl(field)}
</Form.Item>
</Col>
<Col span={24} key="room-rack-section">
<div
style={{
background: 'linear-gradient(135deg, #f0f5ff 0%, #e6f7ff 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
border: '2px solid #d6e4ff',
boxShadow: '0 2px 8px rgba(24, 144, 255, 0.1)',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: '600',
color: '#1890ff',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
}}
>
<DatabaseOutlined style={{ marginRight: '8px' }} />
设备位置选择
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="roomId"
label={
<span>
机房
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择机房' }]}
style={{ marginBottom: '0' }}
>
<Select
placeholder="请选择机房"
style={{ borderRadius: '8px' }}
showSearch
optionFilterProp="children"
onChange={handleRoomChange}
>
{rooms.map((room) => (
<Option key={room.roomId} value={room.roomId}>
{room.name}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="rackId"
label={
<span>
机柜
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择机柜' }]}
style={{ marginBottom: '0' }}
>
<Select
placeholder={selectedRoomId ? '请选择机柜' : '请先选择机房'}
style={{ borderRadius: '8px' }}
disabled={!selectedRoomId}
showSearch
optionFilterProp="children"
>
{(selectedRoomId ? racks.filter((rack) => rack.roomId === selectedRoomId) : []).map(
(rack) => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name} ({rack.rackId})
</Option>
)
)}
</Select>
</Form.Item>
</Col>
</Row>
</div>
</Col>
</React.Fragment>
);
} else if (field.fieldType === 'textarea') {
formItems.push(
<Col span={24} key={field.fieldName}>
<Form.Item
name={field.fieldName}
label={
<span>
{field.displayName}
{field.required && (
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
)}
</span>
}
rules={
field.required ? [{ required: true, message: `请输入${field.displayName}` }] : []
}
>
{renderFieldControl(field)}
</Form.Item>
</Col>
);
} else {
formItems.push(
<Col span={12} key={field.fieldName}>
<Form.Item
name={field.fieldName}
label={
<span>
{field.displayName}
{field.required && (
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
)}
</span>
}
rules={
field.required ? [{ required: true, message: `请输入${field.displayName}` }] : []
}
>
{renderFieldControl(field)}
</Form.Item>
</Col>
);
}
});
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
{editingDevice ? (
<EditOutlined style={{ color: '#667eea' }} />
) : (
<PlusOutlined style={{ color: '#667eea' }} />
)}
{editingDevice ? '编辑设备' : '添加设备'}
</div>
}
open={visible}
onCancel={onCancel}
footer={null}
width={700}
style={{ borderRadius: '16px' }}
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '24px' },
}}
className="device-modal"
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}>{formItems}</Row>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
marginTop: '32px',
paddingTop: '24px',
borderTop: '1px solid #f0f0f0',
}}
>
<Button
onClick={onCancel}
style={{
height: '40px',
borderRadius: '8px',
padding: '0 24px',
fontWeight: '500',
transition: 'all 0.3s ease',
}}
>
取消
</Button>
<Button
type="primary"
htmlType="submit"
style={{
height: '40px',
borderRadius: '8px',
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 32px',
transition: 'all 0.3s ease',
}}
>
确定
</Button>
</div>
</Form>
</Modal>
);
};
export default React.memo(DeviceFormModal);
@@ -0,0 +1,179 @@
import React, { useState, useMemo } from 'react';
import { Modal, Form, Select, Checkbox, Button, message } from 'antd';
import { ExportOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
const { Option } = Select;
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const ExportModal = ({
visible,
deviceFields,
selectedDevices,
currentPageDevices,
allDevices,
onExport,
onCancel,
}) => {
const [exportFormat, setExportFormat] = useState('csv');
const [exportScope, setExportScope] = useState('selected');
const [exportFields, setExportFields] = useState([]);
const [exportLoading, setExportLoading] = useState(false);
const visibleFields = useMemo(() => {
return deviceFields.filter((f) => f.visible && f.fieldName !== 'rackId');
}, [deviceFields]);
React.useEffect(() => {
if (visible) {
setExportFields(visibleFields.map((f) => f.fieldName));
}
}, [visible, visibleFields]);
const handleExport = async () => {
if (exportFields.length === 0) {
message.warning('请至少选择一个导出字段');
return;
}
setExportLoading(true);
try {
await onExport({
format: exportFormat,
scope: exportScope,
fields: exportFields,
});
onCancel();
} finally {
setExportLoading(false);
}
};
const getScopeLabel = () => {
switch (exportScope) {
case 'selected':
return `选择的行 (${selectedDevices.length} 个)`;
case 'currentPage':
return `当前页 (${currentPageDevices.length} 个)`;
case 'all':
return `全部设备 (${allDevices.length} 个)`;
default:
return '';
}
};
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<ExportOutlined style={{ color: '#fa8c16' }} />
导出设备数据
</div>
}
open={visible}
onCancel={onCancel}
footer={[
<Button
key="cancel"
onClick={onCancel}
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
border: `1px solid ${designTokens.colors.border.light}`,
}}
>
取消
</Button>,
<Button
key="submit"
type="primary"
loading={exportLoading}
onClick={handleExport}
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
导出
</Button>,
]}
destroyOnHidden
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '24px' },
}}
width={600}
>
<Form layout="vertical">
<Form.Item label="导出格式">
<Select value={exportFormat} onChange={setExportFormat} style={{ width: '100%' }}>
<Option value="csv">CSV 格式</Option>
<Option value="json">JSON 格式</Option>
</Select>
</Form.Item>
<Form.Item label="导出范围">
<Select value={exportScope} onChange={setExportScope} style={{ width: '100%' }}>
<Option value="selected">{getScopeLabel()}</Option>
<Option value="currentPage">当前页 ({currentPageDevices.length} )</Option>
<Option value="all">全部设备 ({allDevices.length} )</Option>
</Select>
</Form.Item>
<Form.Item label="选择导出字段">
<div
style={{
maxHeight: '300px',
overflow: 'auto',
border: '1px solid #f0f0f0',
borderRadius: '8px',
padding: '12px',
}}
>
{visibleFields.map((field) => (
<div key={field.fieldName} style={{ marginBottom: '8px' }}>
<Checkbox
checked={exportFields.includes(field.fieldName)}
onChange={(e) => {
if (e.target.checked) {
setExportFields([...exportFields, field.fieldName]);
} else {
setExportFields(exportFields.filter((f) => f !== field.fieldName));
}
}}
>
{field.displayName}
</Checkbox>
</div>
))}
</div>
</Form.Item>
<div style={{ color: '#666', fontSize: '13px' }}>
已选择{' '}
<span style={{ color: '#1890ff', fontWeight: 600 }}>{selectedDevices.length}</span> 个设备
将导出{' '}
<span style={{ color: '#52c41a', fontWeight: 600 }}>{exportFields.length}</span> 个字段
</div>
</Form>
</Modal>
);
};
export default React.memo(ExportModal);
@@ -0,0 +1,150 @@
import React from 'react';
import { Modal, Form, Switch, Button, Space, message } from 'antd';
import { SettingOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const secondaryActionStyle = {
height: '40px',
borderRadius: designTokens.borderRadius.small,
border: `1px solid ${designTokens.colors.border.light}`,
fontWeight: '500',
};
const FieldConfigModal = ({
visible,
deviceFields,
defaultDeviceFields,
onSave,
onReset,
onCancel,
}) => {
const [form] = Form.useForm();
const getInitialValues = () => {
return deviceFields.reduce(
(acc, field) => ({
...acc,
[`visible_${field.fieldName}`]: field.visible,
[`required_${field.fieldName}`]: field.required,
}),
{}
);
};
const handleSubmit = async (values) => {
const updatedFields = deviceFields.map((field) => ({
fieldId: field.fieldId,
fieldName: field.fieldName,
displayName: field.displayName,
visible: values[`visible_${field.fieldName}`] ?? field.visible,
required: values[`required_${field.fieldName}`] ?? field.required,
}));
await onSave(updatedFields);
};
const handleReset = () => {
onReset(defaultDeviceFields);
message.success('字段配置已重置为默认值');
};
const filteredFields = deviceFields.filter((field) => field.fieldName !== 'deviceId');
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<SettingOutlined style={{ color: '#667eea' }} />
字段配置
</div>
}
open={visible}
onCancel={onCancel}
footer={null}
width={600}
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '24px' },
}}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={getInitialValues()}
>
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #f0f0f0' }}>
<th style={{ padding: '8px', textAlign: 'left', width: '40%' }}>字段名称</th>
<th style={{ padding: '8px', textAlign: 'center', width: '30%' }}>可见</th>
<th style={{ padding: '8px', textAlign: 'center', width: '30%' }}>必填</th>
</tr>
</thead>
<tbody>
{filteredFields.map((field) => (
<tr key={field.fieldName} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '8px' }}>{field.displayName}</td>
<td style={{ padding: '8px', textAlign: 'center' }}>
<Form.Item name={`visible_${field.fieldName}`} valuePropName="checked" noStyle>
<Switch size="small" />
</Form.Item>
</td>
<td style={{ padding: '8px', textAlign: 'center' }}>
<Form.Item name={`required_${field.fieldName}`} valuePropName="checked" noStyle>
<Switch size="small" />
</Form.Item>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Form.Item style={{ textAlign: 'right', marginTop: '20px' }}>
<Space>
<Button onClick={onCancel} style={secondaryActionStyle}>
取消
</Button>
<Button onClick={handleReset} style={secondaryActionStyle}>
重置默认
</Button>
<Button
type="primary"
htmlType="submit"
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
保存
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
);
};
export default React.memo(FieldConfigModal);
@@ -0,0 +1,385 @@
import React, { useState } from 'react';
import { Modal, Upload, Button, Progress, message } from 'antd';
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const ImportModal = ({
visible,
deviceFields,
onImport,
onCancel,
}) => {
const [isImporting, setIsImporting] = useState(false);
const [importProgress, setImportProgress] = useState(0);
const [importPhase, setImportPhase] = useState('');
const [importResult, setImportResult] = useState(null);
const handleImport = async (file) => {
try {
setIsImporting(true);
setImportProgress(0);
setImportPhase('正在上传文件...');
setImportResult(null);
await onImport(file, {
onProgress: (progress, phase) => {
setImportProgress(progress);
setImportPhase(phase);
},
onSuccess: (result) => {
setImportResult(result);
setImportProgress(100);
setImportPhase('导入完成');
setIsImporting(false);
},
onError: (error) => {
setImportResult({
success: false,
statistics: {
total: 0,
success: 0,
failed: 1,
errors: [{ row: 0, error: error.message || '导入失败' }],
},
});
setIsImporting(false);
},
});
} catch (error) {
setIsImporting(false);
setImportProgress(0);
message.error('导入失败');
}
return false;
};
const handleClose = () => {
setImportProgress(0);
setImportPhase('');
setImportResult(null);
setIsImporting(false);
onCancel();
};
const requiredFields = deviceFields.filter((f) => f.visible && f.required);
const optionalFields = deviceFields.filter((f) => f.visible && !f.required);
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<UploadOutlined style={{ color: '#667eea' }} />
导入设备
</div>
}
open={visible}
onCancel={handleClose}
footer={null}
width={650}
destroyOnHidden
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '24px' },
}}
>
{!isImporting && !importResult ? (
<div>
<p style={{ color: '#666', marginBottom: '8px' }}>请上传CSV格式的设备数据文件</p>
<p style={{ color: '#999', fontSize: '12px', marginBottom: '20px' }}>
支持的编码格式GBK
</p>
<div
style={{
marginBottom: '20px',
padding: '16px',
background: 'linear-gradient(180deg, #fafafa 0%, #ffffff 100%)',
borderRadius: '12px',
border: '1px solid #f0f0f0',
}}
>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#333' }}>
CSV文件格式要求
</p>
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{requiredFields.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<span style={{ color: '#d93025', fontWeight: '500' }}>必填字段</span>
<span style={{ color: '#666', fontSize: '13px' }}>
{requiredFields.map((f) => f.displayName).join('、')}
</span>
</div>
)}
{optionalFields.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<span style={{ color: '#666', fontWeight: '500' }}>可选字段</span>
<span style={{ color: '#666', fontSize: '13px' }}>
{optionalFields.map((f) => f.displayName).join('、')}
</span>
</div>
)}
<ul
style={{
paddingLeft: '20px',
marginBottom: '10px',
color: '#666',
fontSize: '13px',
marginTop: '12px',
}}
>
<li>
设备类型server(服务器)switch(交换机)router(路由器)storage(存储设备)other(其他)
</li>
<li>状态值running(运行中)maintenance(维护中)offline(离线)fault(故障)</li>
<li>日期格式YYYY-MM-DD (例如2023-01-01)</li>
</ul>
</div>
</div>
<div style={{ marginBottom: '20px' }}>
<a href="/api/devices/import-template" download="设备导入模板.csv">
<Button
icon={<DownloadOutlined />}
style={{
height: '36px',
borderRadius: designTokens.borderRadius.small,
border: `1px solid ${designTokens.colors.border.light}`,
}}
>
下载导入模板
</Button>
</a>
<span style={{ color: '#999', fontSize: '12px', marginLeft: '10px' }}>
包含示例数据的CSV模板文件根据当前字段配置生成
</span>
</div>
<Upload
name="csvFile"
accept=".csv"
showUploadList={false}
beforeUpload={handleImport}
maxCount={1}
>
<Button
type="primary"
icon={<UploadOutlined />}
block
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
选择CSV文件
</Button>
</Upload>
</div>
) : isImporting ? (
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: '16px',
color: '#fff',
fontSize: '20px',
}}
>
<UploadOutlined spin />
</div>
<div>
<p
style={{
margin: '0 0 4px 0',
fontWeight: '600',
color: '#333',
fontSize: '16px',
}}
>
正在导入设备数据
</p>
<p style={{ margin: 0, color: '#667eea', fontSize: '14px' }}>{importPhase}</p>
</div>
</div>
<Progress
percent={importProgress}
status="active"
strokeColor={{ '0%': '#667eea', '100%': '#764ba2' }}
format={() => `${importProgress}%`}
/>
</div>
) : importResult?.statistics ? (
<div>
<p style={{ marginBottom: '10px', fontWeight: '600' }}>导入完成</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '12px',
marginBottom: '16px',
}}
>
<div
style={{
padding: '12px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{importResult.statistics.total || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>总记录数</div>
</div>
<div
style={{
padding: '12px',
background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{importResult.statistics.success || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>成功</div>
</div>
<div
style={{
padding: '12px',
background: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{importResult.statistics.failed || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>失败</div>
</div>
</div>
{importResult.statistics?.errors?.length > 0 && (
<div
style={{
marginTop: '20px',
maxHeight: 400,
overflowY: 'auto',
border: '1px solid #ffcccc',
borderRadius: '8px',
padding: '12px',
backgroundColor: '#fff7f7',
}}
>
<h4 style={{ color: '#d93025', marginBottom: '12px', fontWeight: '600' }}>
失败记录详情
</h4>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ backgroundColor: '#ffeeee' }}>
<th
style={{
border: '1px solid #ffcccc',
padding: '8px',
textAlign: 'left',
width: '80px',
}}
>
行号
</th>
<th
style={{
border: '1px solid #ffcccc',
padding: '8px',
textAlign: 'left',
}}
>
失败原因
</th>
</tr>
</thead>
<tbody>
{importResult.statistics.errors.map((item, index) => (
<tr key={index} style={{ borderBottom: '1px solid #ffcccc' }}>
<td
style={{
border: '1px solid #ffcccc',
padding: '8px',
fontWeight: 'bold',
}}
>
{item.row || index + 1}
</td>
<td
style={{
border: '1px solid #ffcccc',
padding: '8px',
color: '#d93025',
}}
>
{item.error || '未知错误'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Button
type="primary"
onClick={handleClose}
style={{
marginTop: '20px',
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
确定
</Button>
</div>
) : null}
</Modal>
);
};
export default React.memo(ImportModal);
@@ -0,0 +1,72 @@
import React from 'react';
const resizableTitleStyles = {
container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
paddingRight: '8px',
},
text: {
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
resizeHandle: {
width: '10px',
height: '100%',
cursor: 'col-resize',
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'transparent',
transition: 'background-color 0.2s',
},
};
const ResizableTitle = (props) => {
const { children, onResize, width, ...restProps } = props;
const handleMouseDown = (e) => {
if (!onResize) return;
e.preventDefault();
e.stopPropagation();
const th = e.currentTarget.closest('th');
if (!th) return;
const startWidth = th.offsetWidth;
const startX = e.clientX;
const handleMouseMove = (moveEvent) => {
const diff = moveEvent.clientX - startX;
const newWidth = Math.max(50, startWidth + diff);
onResize(newWidth);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
return (
<th {...restProps} style={{ position: 'relative' }}>
<div style={resizableTitleStyles.container}>
<span style={resizableTitleStyles.text}>{children}</span>
{onResize && (
<div onMouseDown={handleMouseDown} style={resizableTitleStyles.resizeHandle} />
)}
</div>
</th>
);
};
export default React.memo(ResizableTitle);
+7
View File
@@ -0,0 +1,7 @@
export { default as ResizableTitle } from './ResizableTitle';
export { default as DeviceDetailModal } from './DeviceDetailModal';
export { default as DeviceFormModal } from './DeviceFormModal';
export { default as ImportModal } from './ImportModal';
export { default as ExportModal } from './ExportModal';
export { default as FieldConfigModal } from './FieldConfigModal';
export { default as BatchStatusModal } from './BatchStatusModal';
+123
View File
@@ -0,0 +1,123 @@
import { useSWR, useFetch, useFetchList } from './useSWR';
export const useDevices = (params = {}) => {
const queryString = new URLSearchParams(
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
).toString();
const key = queryString ? `/devices?${queryString}` : '/devices';
return useFetchList(key);
};
export const useDevice = deviceId => {
return useFetch(deviceId ? `/devices/${deviceId}` : null);
};
export const useRacks = (params = {}) => {
const queryString = new URLSearchParams(
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
).toString();
const key = queryString ? `/racks?${queryString}` : '/racks';
return useFetchList(key);
};
export const useRack = rackId => {
return useFetch(rackId ? `/racks/${rackId}` : null);
};
export const useRooms = (params = {}) => {
const queryString = new URLSearchParams(
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
).toString();
const key = queryString ? `/rooms?${queryString}` : '/rooms';
return useFetchList(key);
};
export const useRoom = roomId => {
return useFetch(roomId ? `/rooms/${roomId}` : null);
};
export const useUsers = (params = {}) => {
const queryString = new URLSearchParams(
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
).toString();
const key = queryString ? `/users?${queryString}` : '/users';
return useFetchList(key);
};
export const useUser = userId => {
return useFetch(userId ? `/users/${userId}` : null);
};
export const useTickets = (params = {}) => {
const queryString = new URLSearchParams(
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
).toString();
const key = queryString ? `/tickets?${queryString}` : '/tickets';
return useFetchList(key);
};
export const useTicket = ticketId => {
return useFetch(ticketId ? `/tickets/${ticketId}` : null);
};
export const useDeviceFields = () => {
return useFetchList('/device-fields');
};
export const useConsumables = (params = {}) => {
const queryString = new URLSearchParams(
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
).toString();
const key = queryString ? `/consumables?${queryString}` : '/consumables';
return useFetchList(key);
};
export const useConsumableCategories = () => {
return useFetchList('/consumable-categories');
};
export const useCables = (params = {}) => {
const queryString = new URLSearchParams(
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
).toString();
const key = queryString ? `/cables?${queryString}` : '/cables';
return useFetchList(key);
};
export const usePorts = (params = {}) => {
const queryString = new URLSearchParams(
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
).toString();
const key = queryString ? `/device-ports?${queryString}` : '/device-ports';
return useFetchList(key);
};
export const useInventoryPlans = (params = {}) => {
const queryString = new URLSearchParams(
Object.entries(params).filter(([_, v]) => v !== undefined && v !== '')
).toString();
const key = queryString ? `/inventory/plans?${queryString}` : '/inventory/plans';
return useFetchList(key);
};
export const useSystemSettings = () => {
return useFetch('/system-settings');
};
export const useDashboardStats = () => {
return useFetch('/devices/stats');
};
export const useMutate = () => {
const { mutate } = useSWR();
return mutate;
};
+53
View File
@@ -0,0 +1,53 @@
import useSWR, { SWRConfig } from 'swr';
import api from '../api';
const fetcher = url => api.get(url).then(res => res);
export const swrConfig = {
fetcher,
revalidateOnFocus: false,
revalidateOnReconnect: true,
shouldRetryOnError: false,
dedupingInterval: 5000,
errorRetryCount: 2,
};
export const useSWRConfig = () => {
return {
mutate: useSWR().mutate,
};
};
export const useFetch = (key, options = {}) => {
const { data, error, isLoading, isValidating, mutate } = useSWR(key, options);
return {
data,
error,
isLoading,
isValidating,
mutate,
isError: !!error,
};
};
export const useFetchList = (key, options = {}) => {
const { data, error, isLoading, mutate } = useSWR(key, {
...options,
onSuccess: data => {
if (options.onSuccess) {
options.onSuccess(data);
}
},
});
return {
list: data?.list || data?.devices || data?.racks || data?.rooms || data || [],
total: data?.total || data?.count || 0,
error,
isLoading,
mutate,
};
};
export { SWRConfig, useSWR };
+23 -1
View File
@@ -592,7 +592,29 @@ body {
.ant-modal-body {
padding: 24px !important;
overflow: hidden !important;
}
.custom-scroll-container {
overflow-y: auto !important;
overflow-x: hidden !important;
}
.custom-scroll-container::-webkit-scrollbar {
width: 8px;
}
.custom-scroll-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.custom-scroll-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.custom-scroll-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.ant-modal-header {
-1
View File
@@ -536,7 +536,6 @@ function CableManagement() {
if (errors.length > 0) {
message.warning(`发现 ${errors.length} 条数据错误,已跳过`);
console.log('导入错误:', errors);
}
return validatedData;
+10 -1
View File
@@ -1256,8 +1256,16 @@ function ConsumableManagement() {
footer={null}
width={900}
style={{ top: 20 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }}
>
<div
style={{
maxHeight: '65vh',
overflowY: 'auto',
padding: '24px',
paddingRight: '16px',
}}
className="custom-scroll-container"
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
{/* 基本信息 */}
<div style={{
@@ -1778,6 +1786,7 @@ function ConsumableManagement() {
</Button>
</div>
</Form>
</div>
</Modal>
{/* 导入耗材弹窗 */}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-1
View File
@@ -509,7 +509,6 @@ function PortManagement() {
if (errors.length > 0) {
message.warning(`发现 ${errors.length} 条数据错误,已跳过`);
console.log('导入错误:', errors);
}
return validatedData;
+150
View File
@@ -0,0 +1,150 @@
import React from 'react';
import {
CloudServerOutlined,
SafetyOutlined,
DatabaseOutlined,
AppstoreOutlined,
SwapOutlined,
} from '@ant-design/icons';
export const STATUS_MAP = {
running: { text: '运行中', color: '#52c41a', badgeColor: 'green' },
maintenance: { text: '维护中', color: '#faad14', badgeColor: 'orange' },
offline: { text: '离线', color: '#8c8c8c', badgeColor: 'default' },
fault: { text: '故障', color: '#ff4d4f', badgeColor: 'red' },
};
export const TYPE_MAP = {
server: '服务器',
switch: '交换机',
router: '路由器',
storage: '存储设备',
other: '其他设备',
};
export const getStatusConfig = (status) => {
return STATUS_MAP[status] || { text: status, color: 'black', badgeColor: 'default' };
};
export const getTypeLabel = (type) => {
return TYPE_MAP[type] || type;
};
export const getDeviceTypeIcon = (type) => {
const iconMap = {
server: <CloudServerOutlined style={{ color: '#1890ff' }} />,
switch: <SwapOutlined style={{ color: '#52c41a' }} />,
router: <SafetyOutlined style={{ color: '#faad14' }} />,
storage: <DatabaseOutlined style={{ color: '#722ed1' }} />,
other: <AppstoreOutlined style={{ color: '#8c8c8c' }} />,
};
return iconMap[type] || <AppstoreOutlined style={{ color: '#8c8c8c' }} />;
};
export const formatDate = (date, fieldName) => {
if (!date) return '';
const dateObj = new Date(date);
const formattedDate = dateObj.toLocaleDateString('zh-CN');
if (fieldName === 'warrantyExpiry') {
const today = new Date();
today.setHours(0, 0, 0, 0);
dateObj.setHours(0, 0, 0, 0);
if (dateObj < today) {
return <span style={{ color: '#d93025', fontWeight: 'bold' }}>{formattedDate}</span>;
}
}
return formattedDate;
};
export const FIXED_FIELDS = [
'deviceId',
'name',
'type',
'model',
'serialNumber',
'rackId',
'position',
'height',
'powerConsumption',
'status',
'purchaseDate',
'warrantyExpiry',
'ipAddress',
'description',
];
export const SYSTEM_FIELDS = ['createdAt', 'updatedAt', 'Rack', 'Room', 'customFields'];
export const processDeviceData = (device) => {
const deviceWithFields = { ...device };
if (device.customFields && typeof device.customFields === 'object') {
Object.entries(device.customFields).forEach(([fieldName, value]) => {
deviceWithFields[fieldName] = value;
});
}
return deviceWithFields;
};
export const prepareDeviceFormData = (values, isEditing) => {
const deviceData = {
...values,
purchaseDate: values.purchaseDate ? values.purchaseDate.format('YYYY-MM-DD') : null,
warrantyExpiry: values.warrantyExpiry ? values.warrantyExpiry.format('YYYY-MM-DD') : null,
customFields: {},
};
Object.keys(deviceData).forEach((key) => {
if (!FIXED_FIELDS.includes(key) && key !== 'customFields' && key !== 'roomId') {
deviceData.customFields[key] = deviceData[key];
delete deviceData[key];
}
});
delete deviceData.roomId;
return deviceData;
};
export const getFormInitialValues = (device, racks) => {
if (!device) return {};
const deviceData = { ...device };
const cleanDeviceData = {};
FIXED_FIELDS.forEach((field) => {
if (deviceData[field] !== undefined) {
cleanDeviceData[field] = deviceData[field];
}
});
Object.entries(deviceData).forEach(([key, value]) => {
if (
!FIXED_FIELDS.includes(key) &&
!SYSTEM_FIELDS.includes(key) &&
key !== 'deviceId' &&
typeof value !== 'object' &&
value !== null
) {
cleanDeviceData[key] = value;
}
});
if (deviceData.customFields && typeof deviceData.customFields === 'object') {
Object.entries(deviceData.customFields).forEach(([key, value]) => {
cleanDeviceData[key] = value;
});
}
if (device.rackId) {
const rack = racks.find((r) => r.rackId === device.rackId);
if (rack) {
cleanDeviceData.roomId = rack.roomId;
}
}
return cleanDeviceData;
};