refactor: 移除调试日志并优化代码结构
feat(components): 新增设备管理相关组件和仪表盘组件 feat(hooks): 添加自定义hooks用于API调用和数据管理 style: 优化滚动条样式和模态框布局 chore: 清理无用脚本和调试文件 docs: 更新组件导出文件
This commit is contained in:
@@ -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);
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user