feat(仪表盘): 添加统计API并优化仪表盘组件

This commit is contained in:
zhang1106
2026-03-15 12:19:13 +08:00
parent 7aa09ac9e1
commit a7f051172a
10 changed files with 696 additions and 298 deletions
+154 -177
View File
@@ -317,21 +317,6 @@ function CableManagement() {
}
};
const handleDeleteSwitch = async switchId => {
try {
await axios.delete(`/api/devices/${switchId}`);
message.success({
content: '删除设备成功',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
fetchDevices();
fetchCables();
} catch (error) {
message.error('删除设备失败');
console.error('删除设备失败:', error);
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
@@ -662,7 +647,11 @@ function CableManagement() {
};
const getPortConnectionStatus = (portName, switchData) => {
const cable = switchData.cables.find(c => c.sourcePort === portName);
const cable = switchData.cables.find(c => {
if (c.sourcePort === portName) return true;
if (c.sourcePort?.toLowerCase() === portName?.toLowerCase()) return true;
return false;
});
if (!cable) {
return { status: 'disconnected', text: '未连接', color: 'default' };
}
@@ -727,7 +716,11 @@ function CableManagement() {
key: 'targetDevice',
width: 200,
render: (_, record) => {
const cable = record.switchData.cables.find(c => c.sourcePort === record.portName);
const cable = record.switchData.cables.find(c => {
if (c.sourcePort === record.portName) return true;
if (c.sourcePort?.toLowerCase() === record.portName?.toLowerCase()) return true;
return false;
});
if (!cable) return <Text type="secondary">-</Text>;
return (
<div>
@@ -743,7 +736,11 @@ function CableManagement() {
key: 'cableType',
width: 100,
render: (_, record) => {
const cable = record.switchData.cables.find(c => c.sourcePort === record.portName);
const cable = record.switchData.cables.find(c => {
if (c.sourcePort === record.portName) return true;
if (c.sourcePort?.toLowerCase() === record.portName?.toLowerCase()) return true;
return false;
});
if (!cable) return <Text type="secondary">-</Text>;
return getCableTypeTag(cable.cableType);
},
@@ -754,7 +751,11 @@ function CableManagement() {
key: 'cableLength',
width: 80,
render: (_, record) => {
const cable = record.switchData.cables.find(c => c.sourcePort === record.portName);
const cable = record.switchData.cables.find(c => {
if (c.sourcePort === record.portName) return true;
if (c.sourcePort?.toLowerCase() === record.portName?.toLowerCase()) return true;
return false;
});
if (!cable) return <Text type="secondary">-</Text>;
return cable.cableLength ? <Tag color="orange" style={{ borderRadius: '4px' }}>{cable.cableLength}m</Tag> : <Text type="secondary">-</Text>;
},
@@ -762,35 +763,37 @@ function CableManagement() {
{
title: '操作',
key: 'action',
width: 120,
fixed: 'right',
width: 160,
render: (_, record) => {
const cable = record.switchData.cables.find(c => c.sourcePort === record.portName);
const cable = record.switchData.cables.find(c => {
if (c.sourcePort === record.portName) return true;
if (c.sourcePort?.toLowerCase() === record.portName?.toLowerCase()) return true;
return false;
});
if (!cable) {
return <Text type="secondary">-</Text>;
}
return (
<Space size="small">
{cable && (
<>
<Tooltip title="编辑">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(cable)}
style={{ color: designTokens.colors.primary.main }}
/>
</Tooltip>
<Tooltip title="删除">
<Popconfirm
title="确定要删除这条接线吗?"
onConfirm={() => handleDelete(cable.cableId)}
okText="确定"
cancelText="取消"
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Tooltip>
</>
)}
<Tooltip title="编辑">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(cable)}
style={{ color: designTokens.colors.primary.main }}
/>
</Tooltip>
<Tooltip title="删除">
<Popconfirm
title="确定要删除这条接线吗?"
onConfirm={() => handleDelete(cable.cableId)}
okText="确定"
cancelText="取消"
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Tooltip>
</Space>
);
},
@@ -1047,10 +1050,8 @@ function CableManagement() {
activeKey={expandedKeys}
onChange={setExpandedKeys}
style={{ background: 'transparent', border: 'none' }}
expandIconPosition="end"
>
<AnimatePresence>
{Object.entries(groupedCables).map(([switchId, switchData], index) => {
{Object.entries(groupedCables).map(([switchId, switchData], index) => {
const switchDevice = switchData.switch;
const switchPorts = devicePorts[switchId] || [];
const connectedCount = switchData.cables.filter(c => c.status === 'normal').length;
@@ -1058,147 +1059,123 @@ function CableManagement() {
const faultCount = switchData.cables.filter(c => c.status === 'fault').length;
return (
<motion.div
<Panel
key={switchId}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<Panel
header={
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
paddingRight: '16px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: designTokens.borderRadius.md,
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '24px',
boxShadow: designTokens.shadows.md,
}}
>
<AppstoreOutlined />
header={
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
paddingRight: '16px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: designTokens.borderRadius.md,
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '24px',
boxShadow: designTokens.shadows.md,
}}
>
<AppstoreOutlined />
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '16px', color: designTokens.colors.neutral[800] }}>
{switchDevice?.name || '未知设备'}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '16px', color: designTokens.colors.neutral[800] }}>
{switchDevice?.name || '未知设备'}
</div>
<div style={{ fontSize: '13px', color: designTokens.colors.neutral[500], marginTop: '2px' }}>
{switchDevice?.deviceId || '-'} · {switchDevice?.model || '交换机'}
</div>
<div style={{ fontSize: '13px', color: designTokens.colors.neutral[500], marginTop: '2px' }}>
{switchDevice?.deviceId || '-'} · {switchDevice?.model || '交换机'}
</div>
</div>
<Space size="middle">
<Tooltip title="正常连接">
<Tag
color="success"
style={{ borderRadius: '4px', padding: '4px 12px' }}
icon={<CheckCircleOutlined />}
>
{connectedCount}
</Tag>
</Tooltip>
<Tooltip title="未连接">
<Tag
style={{ borderRadius: '4px', padding: '4px 12px' }}
icon={<DisconnectOutlined />}
>
{disconnectedCount}
</Tag>
</Tooltip>
{faultCount > 0 && (
<Tooltip title="故障">
<Tag
color="error"
style={{ borderRadius: '4px', padding: '4px 12px' }}
icon={<ExclamationCircleOutlined />}
>
{faultCount}
</Tag>
</Tooltip>
)}
<Tag
color="blue"
style={{ borderRadius: '4px', padding: '4px 12px', fontWeight: 500 }}
>
总计: {switchData.cables.length}
</Tag>
</Space>
</div>
}
extra={
<Space size="small" onClick={e => e.stopPropagation()}>
<Tooltip title="添加接线">
<Button
type="text"
icon={<PlusOutlined />}
onClick={() => {
setEditingCable(null);
form.setFieldsValue({ sourceDeviceId: switchId });
setModalVisible(true);
}}
style={{ color: designTokens.colors.primary.main }}
/>
</Tooltip>
<Tooltip title="删除设备">
<Popconfirm
title={`确定要删除交换机 ${switchDevice?.name} 吗?`}
onConfirm={() => handleDeleteSwitch(switchId)}
okText="确定"
cancelText="取消"
<Space size="middle">
<Tooltip title="正常连接">
<Tag
color="success"
style={{ borderRadius: '4px', padding: '4px 12px' }}
icon={<CheckCircleOutlined />}
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
{connectedCount}
</Tag>
</Tooltip>
<Tooltip title="未连接">
<Tag
style={{ borderRadius: '4px', padding: '4px 12px' }}
icon={<DisconnectOutlined />}
>
{disconnectedCount}
</Tag>
</Tooltip>
{faultCount > 0 && (
<Tooltip title="故障">
<Tag
color="error"
style={{ borderRadius: '4px', padding: '4px 12px' }}
icon={<ExclamationCircleOutlined />}
>
{faultCount}
</Tag>
</Tooltip>
)}
<Tag
color="blue"
style={{ borderRadius: '4px', padding: '4px 12px', fontWeight: 500 }}
>
总计: {switchData.cables.length}
</Tag>
</Space>
}
style={{
background: '#fff',
borderRadius: designTokens.borderRadius.lg,
marginBottom: '12px',
border: `1px solid ${designTokens.colors.neutral[200]}`,
</div>
}
extra={
<Space size="small" onClick={e => e.stopPropagation()}>
<Tooltip title="添加接线">
<Button
type="text"
icon={<PlusOutlined />}
onClick={() => {
setEditingCable(null);
form.setFieldsValue({ sourceDeviceId: switchId });
setModalVisible(true);
}}
style={{ color: designTokens.colors.primary.main }}
/>
</Tooltip>
</Space>
}
style={{
background: '#fff',
borderRadius: designTokens.borderRadius.lg,
marginBottom: '12px',
border: `1px solid ${designTokens.colors.neutral[200]}`,
overflow: 'hidden',
}}
>
<Table
columns={portColumns}
dataSource={switchPorts.map(port => ({
...port,
switchData: switchData,
}))}
rowKey="portId"
pagination={false}
size="middle"
style={{
borderRadius: designTokens.borderRadius.md,
overflow: 'hidden',
}}
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
<Table
columns={portColumns}
dataSource={switchPorts.map(port => ({
...port,
switchData: switchData,
}))}
rowKey="portId"
pagination={false}
size="middle"
scroll={{ x: 1100 }}
style={{
borderRadius: designTokens.borderRadius.md,
overflow: 'hidden',
}}
/>
</motion.div>
</Panel>
</motion.div>
/>
</Panel>
);
})}
</AnimatePresence>
</Collapse>
)}
</Card>
+128 -69
View File
@@ -79,22 +79,6 @@ const PIE_CHART_COLORS = {
primary: designTokens.colors.primary.main,
};
const pieChartStyle = {
width: '180px',
height: '180px',
borderRadius: '50%',
background: `conic-gradient(
${PIE_CHART_COLORS.success} 0deg 216deg,
${PIE_CHART_COLORS.warning} 216deg 288deg,
${PIE_CHART_COLORS.error} 288deg 324deg,
${PIE_CHART_COLORS.primary} 324deg 360deg
)`,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const pieChartInner = {
width: '120px',
height: '120px',
@@ -118,50 +102,122 @@ function Dashboard() {
const [hoveredCard, setHoveredCard] = useState(null);
const [animatedKey, setAnimatedKey] = useState(0);
const { data: devicesData, isLoading: devicesLoading, mutate: mutateDevices } = useFetch(
'/devices?pageSize=1'
);
const { data: racksData, isLoading: racksLoading } = useFetch('/racks?pageSize=1');
const { data: roomsData, isLoading: roomsLoading } = useFetch('/rooms');
const { data: usersData, isLoading: usersLoading } = useFetch('/users?pageSize=1');
const { data: ticketsData, isLoading: ticketsLoading } = useFetch('/tickets?pageSize=1&status=open');
const { data: faultData, isLoading: faultLoading } = useFetch('/devices?status=fault&pageSize=1');
const { data: statsData, isLoading: loading, mutate: mutateStats } = useFetch('/statistics');
const loading = devicesLoading || racksLoading || roomsLoading || usersLoading || ticketsLoading;
const isRefreshing = devicesLoading && devicesData;
const isRefreshing = loading && statsData;
const stats = useMemo(() => {
const totalDevices = devicesData?.total || 0;
const totalRacks = racksData?.total || 0;
const rooms = roomsData || [];
const totalRooms = Array.isArray(rooms) ? rooms.length : 0;
const totalUsers = usersData?.total || 0;
const activeTickets = ticketsData?.total || 0;
const faultDevices = faultData?.total || 0;
if (!statsData) {
return {
totalDevices: 0,
totalRacks: 0,
totalRooms: 0,
totalUsers: 0,
activeTickets: 0,
faultDevices: 0,
deviceGrowth: 0,
faultTrend: 0,
userGrowth: 0,
ticketTrend: 0,
onlineRate: 100,
powerUsage: 0,
totalMaxPower: 10000,
deviceStatus: {
running: 0,
maintenance: 0,
fault: 0,
offline: 0,
},
deviceTrendData: [],
};
}
return {
totalDevices,
totalRacks,
totalRooms,
totalUsers,
activeTickets,
faultDevices,
deviceGrowth: 2.5,
faultTrend: -12.3,
onlineRate:
totalDevices > 0
? (((totalDevices - faultDevices) / totalDevices) * 100).toFixed(1)
: 100,
powerUsage: Math.floor(Math.random() * 5000) + 2000,
totalDevices: statsData.totalDevices || 0,
totalRacks: statsData.totalRacks || 0,
totalRooms: statsData.totalRooms || 0,
totalUsers: statsData.totalUsers || 0,
activeTickets: statsData.activeTickets || 0,
faultDevices: statsData.faultDevices || 0,
deviceGrowth: statsData.deviceGrowth || 0,
faultTrend: statsData.faultTrend || 0,
userGrowth: statsData.userGrowth || 0,
ticketTrend: statsData.ticketTrend || 0,
onlineRate: statsData.onlineRate || 100,
powerUsage: statsData.powerUsage || 0,
totalMaxPower: statsData.totalMaxPower || 10000,
deviceStatus: statsData.deviceStatus || {
running: 0,
maintenance: 0,
fault: 0,
offline: 0,
},
deviceTrendData: statsData.deviceTrendData || [],
};
}, [devicesData, racksData, roomsData, usersData, ticketsData, faultData]);
}, [statsData]);
const deviceStatusPercentages = useMemo(() => {
const total = stats.totalDevices;
if (total === 0) {
return {
running: 0,
maintenance: 0,
fault: 0,
offline: 0,
};
}
return {
running: Math.round((stats.deviceStatus.running / total) * 100),
maintenance: Math.round((stats.deviceStatus.maintenance / total) * 100),
fault: Math.round((stats.deviceStatus.fault / total) * 100),
offline: Math.round((stats.deviceStatus.offline / total) * 100),
};
}, [stats.totalDevices, stats.deviceStatus]);
const pieChartStyle = useMemo(() => {
const runningDeg = (deviceStatusPercentages.running / 100) * 360;
const maintenanceDeg = (deviceStatusPercentages.maintenance / 100) * 360;
const faultDeg = (deviceStatusPercentages.fault / 100) * 360;
const offlineDeg = (deviceStatusPercentages.offline / 100) * 360;
let gradientParts = [];
let currentAngle = 0;
if (runningDeg > 0) {
gradientParts.push(`${PIE_CHART_COLORS.success} ${currentAngle}deg ${currentAngle + runningDeg}deg`);
currentAngle += runningDeg;
}
if (maintenanceDeg > 0) {
gradientParts.push(`${PIE_CHART_COLORS.warning} ${currentAngle}deg ${currentAngle + maintenanceDeg}deg`);
currentAngle += maintenanceDeg;
}
if (faultDeg > 0) {
gradientParts.push(`${PIE_CHART_COLORS.error} ${currentAngle}deg ${currentAngle + faultDeg}deg`);
currentAngle += faultDeg;
}
if (offlineDeg > 0) {
gradientParts.push(`${PIE_CHART_COLORS.primary} ${currentAngle}deg ${currentAngle + offlineDeg}deg`);
}
const conicGradient = gradientParts.length > 0 ? `conic-gradient(${gradientParts.join(', ')})` : 'conic-gradient(#e5e7eb 0deg 360deg)';
return {
width: '180px',
height: '180px',
borderRadius: '50%',
background: conicGradient,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
}, [deviceStatusPercentages]);
const handleRefresh = useCallback(async () => {
setAnimatedKey((prev) => prev + 1);
await Promise.all([
mutateDevices(),
]);
}, [mutateDevices]);
await mutateStats();
}, [mutateStats]);
const handleHover = useCallback((key) => {
setHoveredCard(key);
@@ -182,6 +238,7 @@ function Dashboard() {
title: '总设备数',
trend: stats.deviceGrowth,
tagColor: 'blue',
hideTrend: true,
delay: 0,
},
{
@@ -197,7 +254,7 @@ function Dashboard() {
title: '总机柜数',
trend: 0,
tagColor: 'green',
customStatus: true,
hideTrend: true,
delay: 1,
},
{
@@ -213,7 +270,7 @@ function Dashboard() {
title: '总机房数',
trend: 0,
tagColor: 'green',
customStatus: true,
hideTrend: true,
delay: 2,
},
{
@@ -229,6 +286,7 @@ function Dashboard() {
title: '故障设备',
trend: stats.faultTrend,
tagColor: 'red',
hideTrend: true,
delay: 3,
},
{
@@ -242,8 +300,9 @@ function Dashboard() {
color: '#13c2c2',
statKey: 'totalUsers',
title: '用户总数',
trend: 5.2,
trend: stats.userGrowth,
tagColor: 'cyan',
hideTrend: true,
delay: 4,
},
{
@@ -257,25 +316,25 @@ function Dashboard() {
color: '#fa8c16',
statKey: 'activeTickets',
title: '待处理工单',
trend: -8.5,
trend: stats.ticketTrend,
tagColor: 'orange',
hideTrend: true,
delay: 5,
},
],
[stats.deviceGrowth, stats.faultTrend]
[stats.deviceGrowth, stats.faultTrend, stats.userGrowth, stats.ticketTrend]
);
const deviceTrendData = useMemo(
() => [
{ label: '周一', value: 45, color: designTokens.colors.primary.main },
{ label: '周二', value: 52, color: designTokens.colors.primary.main },
{ label: '周三', value: 48, color: designTokens.colors.primary.main },
{ label: '周四', value: 60, color: designTokens.colors.success.main },
{ label: '周五', value: 55, color: designTokens.colors.success.main },
{ label: '周六', value: 42, color: designTokens.colors.warning.main },
{ label: '周日', value: 58, color: designTokens.colors.primary.main },
],
[]
() => {
const rawData = statsData?.deviceTrendData || [];
return rawData.map((item) => ({
label: item.label,
value: item.value,
color: designTokens.colors.primary.main
}));
},
[statsData?.deviceTrendData]
);
const styles = `
@@ -358,7 +417,7 @@ function Dashboard() {
</span>
</div>
</div>
<StatusLegend />
<StatusLegend deviceStatusPercentages={deviceStatusPercentages} />
</div>
</div>
</Card>
@@ -403,7 +462,7 @@ function Dashboard() {
</div>
}
>
<PowerGauge value={stats.powerUsage} maxValue={10000} />
<PowerGauge value={stats.powerUsage} maxValue={stats.totalMaxPower} />
</ErrorBoundary>
</div>
</div>
@@ -485,7 +544,7 @@ function Dashboard() {
</p>
</div>
<QuickStats onlineRate={stats.onlineRate} powerUsage={stats.powerUsage} />
<QuickStats onlineRate={stats.onlineRate} powerUsage={stats.powerUsage} totalMaxPower={stats.totalMaxPower} />
</Col>
<Col xs={24} md={8}>
+1 -1
View File
@@ -204,7 +204,7 @@ function PortManagement() {
const deviceId = port.deviceId;
if (!grouped[deviceId]) {
grouped[deviceId] = {
device: devices.find(d => d.deviceId === deviceId),
device: port.device || devices.find(d => d.deviceId === deviceId),
ports: [],
};
}