diff --git a/.trae/documents/IDC设备管理系统优化计划.md b/.trae/documents/IDC设备管理系统优化计划.md new file mode 100644 index 0000000..d475131 --- /dev/null +++ b/.trae/documents/IDC设备管理系统优化计划.md @@ -0,0 +1,160 @@ +# IDC设备管理系统优化计划 + +## 一、项目结构优化 + +### 1. 后端路由拆分 +- **问题**:单个路由文件过大(如devices.js超过1200行),包含重复代码 +- **优化方案**:将大型路由文件拆分为多个子路由文件,按功能模块组织 + - 设备基础路由 + - 设备导入导出 + - 设备批量操作 + - 设备增强功能 + +### 2. 前端代码分割 +- **问题**:前端代码未进行懒加载和代码分割,初始加载时间长 +- **优化方案**:使用React.lazy和Suspense实现组件懒加载,按页面模块分割代码 + +## 二、技术栈升级 + +### 1. 依赖版本更新 +- **问题**:部分依赖版本较旧,存在安全隐患 +- **优化方案**:升级核心依赖 + - bcryptjs: ^3.0.3 → ^4.0.1 + - jsonwebtoken: ^9.0.3 → ^9.0.2(最新稳定版) + - three: ^0.160.0 → ^0.182.0(保持与后端一致) + +### 2. 数据库优化 +- **问题**:缺乏索引优化,查询性能可能受影响 +- **优化方案**:为常用查询字段添加索引 + - 设备表:deviceId, rackId, status, type + - 用户表:username, email + - 工单表:status, createdAt + +## 三、代码质量提升 + +### 1. 统一错误处理 +- **问题**:错误处理分散,缺乏统一机制 +- **优化方案**:实现全局错误处理中间件 + - 统一错误格式 + - 错误日志记录 + - 友好的错误提示 + +### 2. 请求参数验证 +- **问题**:缺乏请求参数验证,容易受到恶意攻击 +- **优化方案**:使用Zod或Joi实现请求参数验证 + - 路由层验证 + - 模型层验证 + - 自定义验证规则 + +### 3. 重复代码消除 +- **问题**:同一功能在多个地方重复实现(如批量操作) +- **优化方案**:提取公共函数和中间件 + - 批量操作工具函数 + - 权限验证中间件 + - 文件处理工具函数 + +## 四、安全性增强 + +### 1. 密码哈希算法升级 +- **问题**:使用旧版bcryptjs,安全性较低 +- **优化方案**:升级到最新版bcryptjs,使用更强的哈希算法 + +### 2. API速率限制 +- **问题**:缺乏API请求频率限制,容易受到DDoS攻击 +- **优化方案**:实现速率限制中间件 + - IP级别的速率限制 + - 用户级别的速率限制 + - 不同API端点的差异化限制 + +### 3. 输入验证和消毒 +- **问题**:缺乏输入验证和消毒,容易受到XSS和SQL注入攻击 +- **优化方案**: + - 前端输入验证 + - 后端输入消毒 + - 使用参数化查询防止SQL注入 + +## 五、可维护性提升 + +### 1. 代码风格规范 +- **问题**:缺乏统一的代码风格规范 +- **优化方案**:配置ESLint和Prettier + - 统一代码风格 + - 自动格式化代码 + - 代码质量检查 + +### 2. API文档 +- **问题**:缺乏API文档,开发和维护困难 +- **优化方案**:集成Swagger/OpenAPI + - 自动生成API文档 + - 支持在线测试 + - 版本管理 + +### 3. 测试覆盖 +- **问题**:缺乏单元测试和集成测试,代码质量难以保证 +- **优化方案**: + - 编写单元测试(Jest) + - 编写集成测试(Supertest) + - 配置测试覆盖率报告 + +## 六、性能优化 + +### 1. 前端性能优化 +- **问题**:页面加载和交互性能有待提升 +- **优化方案**: + - 实现组件懒加载 + - 优化图片加载(使用WebP格式、懒加载) + - 减少不必要的重渲染 + - 使用React.memo和useMemo优化组件性能 + +### 2. 后端性能优化 +- **问题**:数据库查询性能和API响应速度 +- **优化方案**: + - 添加数据库索引 + - 实现缓存机制(Redis) + - 优化SQL查询,减少N+1问题 + - 使用连接池管理数据库连接 + +### 3. 图片上传优化 +- **问题**:图片上传和处理效率较低 +- **优化方案**: + - 实现图片压缩 + - 使用CDN存储图片 + - 异步处理图片上传 + +## 七、实施步骤 + +1. **第一阶段**:基础优化 + - 依赖版本更新 + - 代码风格规范配置 + - 统一错误处理实现 + +2. **第二阶段**:结构优化 + - 后端路由拆分 + - 前端代码分割 + - API文档集成 + +3. **第三阶段**:安全性增强 + - 密码哈希算法升级 + - 请求参数验证 + - API速率限制 + - 输入验证和消毒 + +4. **第四阶段**:性能优化 + - 数据库索引优化 + - 前端性能优化 + - 缓存机制实现 + +5. **第五阶段**:测试和CI/CD + - 编写单元测试和集成测试 + - 配置CI/CD流程 + - 测试覆盖率提升 + +## 八、预期效果 + +- **性能提升**:页面加载时间减少50%,API响应时间减少30% +- **安全性增强**:消除已知安全隐患,提高系统抗攻击能力 +- **可维护性提升**:代码结构清晰,文档完善,便于开发和维护 +- **开发效率提升**:统一的代码规范和工具链,减少开发和调试时间 +- **扩展性增强**:模块化设计,便于后续功能扩展和系统升级 + +通过以上优化措施,可以显著提升IDC设备管理系统的性能、安全性和可维护性,为用户提供更好的使用体验。 \ No newline at end of file diff --git a/.trae/documents/plan_20260120_031540.md b/.trae/documents/plan_20260120_031540.md new file mode 100644 index 0000000..911c92a --- /dev/null +++ b/.trae/documents/plan_20260120_031540.md @@ -0,0 +1,33 @@ +## 仪表盘获取统计数据失败修复方案 + +### 问题分析 +从代码分析,仪表盘 `fetchStats` 函数中存在以下潜在问题: +1. 使用了不存在的 API 端点:`/api/devices/count` +2. 没有处理 API 响应格式不一致的情况 +3. 缺少详细的错误日志,难以定位具体失败原因 + +### 修复方案 +修改 `Dashboard.jsx` 中的 `fetchStats` 函数,使用与其他页面一致的 API 调用方式: + +1. **替换不存在的 API 端点**:使用 `/api/devices?status=fault` 代替 `/api/devices/count` +2. **统一 API 调用模式**:与其他页面保持一致的 API 调用和数据解析方式 +3. **增强错误处理**:添加详细的错误信息和日志 +4. **添加请求超时处理**:防止请求长时间阻塞 +5. **优化并发请求**:使用 Promise.all 优化多个 API 请求 + +### 具体修改点 +1. 修改 `fetchStats` 函数中的 API 调用逻辑 +2. 调整错误处理和日志记录 +3. 添加请求超时配置 +4. 优化数据解析逻辑 + +### 预期效果 +修复后,仪表盘将能够成功获取所有统计数据,包括: +- 总设备数 +- 总机柜数 +- 总机房数 +- 故障设备数 +- 用户总数 +- 待处理工单 + +同时,页面将显示更友好的错误提示,便于开发人员定位问题。 \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4ebc97e..a2d9297 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -27,6 +27,55 @@ const SystemSettings = lazy(() => import('./pages/SystemSettings')); const { Header, Content, Sider } = Layout; +const designTokens = { + colors: { + primary: { + main: '#667eea', + gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + light: '#8b9ff0' + }, + success: { main: '#10b981' }, + warning: { main: '#f59e0b' }, + error: { main: '#ef4444' }, + text: { + primary: '#1e293b', + secondary: '#64748b', + inverse: '#ffffff' + }, + background: { + primary: '#ffffff', + secondary: '#f8fafc', + dark: '#1e293b' + }, + border: { + light: '#e2e8f0' + }, + sidebar: { + bg: '#ffffff', + bgHover: 'rgba(102, 126, 234, 0.08)', + bgActive: 'rgba(102, 126, 234, 0.15)', + text: '#475569', + textHover: '#667eea', + textActive: '#667eea', + border: '#e2e8f0' + } + }, + shadows: { + small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', + large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)' + }, + borderRadius: { + small: '6px', + medium: '10px' + }, + spacing: { + sm: '8px', + md: '16px', + lg: '24px' + } +}; + const PrivateRoute = ({ children }) => { const { token, initialized, loading } = useAuth(); const location = useLocation(); @@ -35,12 +84,15 @@ const PrivateRoute = ({ children }) => { return (
- + + 正在加载认证状态...
); } @@ -54,12 +106,15 @@ const PrivateRoute = ({ children }) => { fallback={
- + + 正在加载页面...
} > @@ -72,237 +127,331 @@ const PrivateRoute = ({ children }) => { const AppLayout = ({ children }) => { const [collapsed, setCollapsed] = useState(false); + const [activeKey, setActiveKey] = useState('dashboard'); const { user, logout } = useAuth(); const navigate = useNavigate(); - const { - token: { colorBgContainer, borderRadiusLG }, - } = theme.useToken(); - + const location = useLocation(); + const handleLogout = () => { logout(); message.success('已退出登录'); navigate('/login'); }; - const userMenuItems = [ + const getSelectedKey = () => { + const path = location.pathname; + if (path === '/') return 'dashboard'; + if (path.startsWith('/rooms') || path.startsWith('/racks') || path.startsWith('/visualization')) return 'room-management'; + if (path.startsWith('/devices') || path.startsWith('/fields')) return 'asset-management'; + if (path.startsWith('/consumables')) return 'consumables-management'; + if (path.startsWith('/users') || path.startsWith('/login-history') || path.startsWith('/operation-logs') || path.startsWith('/settings')) return 'system-management'; + if (path.startsWith('/tickets')) return 'ticket-management'; + return 'dashboard'; + }; + + const menuItems = [ { - key: 'logout', - icon: , - label: '退出登录', - onClick: handleLogout - } + key: 'dashboard', + icon: , + label: 仪表盘, + }, + { + key: 'room-management', + icon: , + label: '机房管理', + children: [ + { + key: 'rooms', + icon: , + label: 机房管理, + }, + { + key: 'racks', + icon: , + label: 机柜管理, + }, + { + key: 'visualization', + icon: , + label: 机柜可视化, + }, + ], + }, + { + key: 'asset-management', + icon: , + label: '资产管理', + children: [ + { + key: 'devices', + icon: , + label: 设备管理, + }, + { + key: 'fields', + icon: , + label: 字段管理, + }, + ], + }, + { + key: 'consumables-management', + icon: , + label: '耗材管理', + children: [ + { + key: 'consumables-stats', + icon: , + label: 耗材统计, + }, + { + key: 'consumables', + icon: , + label: 耗材列表, + }, + { + key: 'consumables-categories', + icon: , + label: 分类管理, + }, + { + key: 'consumables-logs', + icon: , + label: 操作日志, + }, + ], + }, + { + key: 'system-management', + icon: , + label: '系统管理', + children: [ + { + key: 'users', + icon: , + label: 用户管理, + }, + { + key: 'login-history', + icon: , + label: 登录历史, + }, + { + key: 'operation-logs', + icon: , + label: 操作日志, + }, + { + key: 'system-settings', + icon: , + label: 系统设置, + }, + ], + }, + { + key: 'ticket-management', + icon: , + label: '工单管理', + children: [ + { + key: 'tickets', + icon: , + label: 工单列表, + }, + { + key: 'ticket-categories', + icon: , + label: 故障分类, + }, + { + key: 'ticket-statistics', + icon: , + label: 统计报表, + }, + { + key: 'ticket-fields', + icon: , + label: 字段管理, + }, + ], + }, ]; return ( - +
+
+ +
+ {!collapsed && ( +
+
+ IDC管理 +
+
+ 数据中心管理平台 +
+
+ )} +
+ +
+ +
+ +
- - , - label: 仪表盘, - }, - { - key: 'room-management', - icon: , - label: '机房管理', - children: [ - { - key: 'rooms', - icon: , - label: 机房管理, - }, - { - key: 'racks', - icon: , - label: 机柜管理, - }, - { - key: 'visualization', - icon: , - label: 机柜可视化, - }, - ], - }, - { - key: 'asset-management', - icon: , - label: '资产管理', - children: [ - { - key: 'devices', - icon: , - label: 设备管理, - }, - { - key: 'fields', - icon: , - label: 字段管理, - }, - ], - }, - { - key: 'consumables-management', - icon: , - label: '耗材管理', - children: [ - { - key: 'consumables-stats', - icon: , - label: 耗材统计, - }, - { - key: 'consumables', - icon: , - label: 耗材列表, - }, - { - key: 'consumables-categories', - icon: , - label: 分类管理, - }, - { - key: 'consumables-logs', - icon: , - label: 操作日志, - }, - ], - }, - { - key: 'system-management', - icon: , - label: '系统管理', - children: [ - { - key: 'users', - icon: , - label: 用户管理, - }, - { - key: 'login-history', - icon: , - label: 登录历史, - }, - { - key: 'operation-logs', - icon: , - label: 操作日志, - }, - { - key: 'system-settings', - icon: , - label: 系统设置, - }, - ], - }, - { - key: 'ticket-management', - icon: , - label: '工单管理', - children: [ - { - key: 'tickets', - icon: , - label: 工单列表, - }, - { - key: 'ticket-categories', - icon: , - label: 故障分类, - }, - { - key: 'ticket-statistics', - icon: , - label: 统计报表, - }, - { - key: 'ticket-fields', - icon: , - label: 字段管理, - }, - ], - }, - ]} - /> - + +
{user && ( - - } - /> - {user.username} - +
+
+ } + /> + {user.username} +
- +
)}
{children} @@ -321,12 +470,15 @@ function App() { fallback={
- + + 正在加载登录页面...
} > diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx index c555c94..7830dcc 100644 --- a/frontend/src/components/ProtectedRoute.jsx +++ b/frontend/src/components/ProtectedRoute.jsx @@ -11,11 +11,14 @@ const ProtectedRoute = ({ children, requiredPermission }) => { return (
- + + 加载中...
); } diff --git a/frontend/src/pages/ConsumableStatistics.jsx b/frontend/src/pages/ConsumableStatistics.jsx index 12d0772..5b53302 100644 --- a/frontend/src/pages/ConsumableStatistics.jsx +++ b/frontend/src/pages/ConsumableStatistics.jsx @@ -1,10 +1,186 @@ import React, { useState, useEffect } from 'react'; -import { Card, Row, Col, Statistic, Table, Tag, DatePicker, Space, Select, Progress, message } from 'antd'; -import { InboxOutlined, ExportOutlined, WarningOutlined, DollarOutlined, ShoppingCartOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; +import { Card, Row, Col, Statistic, Table, Tag, DatePicker, Space, Select, Progress, message, Button } from 'antd'; +import { InboxOutlined, ExportOutlined, WarningOutlined, DollarOutlined, ShoppingCartOutlined, ExclamationCircleOutlined, PlusOutlined, BarChartOutlined, DownloadOutlined } from '@ant-design/icons'; import axios from 'axios'; import dayjs from 'dayjs'; const { RangePicker } = DatePicker; +const { Option } = Select; + +const designTokens = { + colors: { + primary: { + main: '#667eea', + gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + light: '#8b9ff0' + }, + success: { + main: '#10b981', + gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' + }, + warning: { + main: '#f59e0b', + gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' + }, + error: { + main: '#ef4444', + gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' + }, + text: { + primary: '#1e293b', + secondary: '#64748b', + tertiary: '#94a3b8', + inverse: '#ffffff' + }, + background: { + primary: '#ffffff', + secondary: '#f8fafc', + tertiary: '#f1f5f9' + }, + border: { + light: '#e2e8f0', + medium: '#cbd5e1', + dark: '#94a3b8' + } + }, + shadows: { + small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', + large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)' + }, + borderRadius: { + small: '6px', + medium: '10px', + large: '16px' + }, + spacing: { + xs: '4px', + sm: '8px', + md: '16px', + lg: '24px', + xl: '32px' + } +}; + +const pageContainerStyle = { + minHeight: '100vh', + background: designTokens.colors.background.secondary, + padding: designTokens.spacing.lg +}; + +const headerStyle = { + marginBottom: designTokens.spacing.lg, + padding: `${designTokens.spacing.lg}px ${designTokens.spacing.xl}px`, + background: designTokens.colors.background.primary, + borderRadius: designTokens.borderRadius.large, + boxShadow: designTokens.shadows.small, + border: `1px solid ${designTokens.colors.border.light}` +}; + +const titleRowStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: designTokens.spacing.md, + flexWrap: 'wrap', + gap: designTokens.spacing.md +}; + +const titleStyle = { + display: 'flex', + alignItems: 'center', + gap: designTokens.spacing.sm, + fontSize: '20px', + fontWeight: '600', + color: designTokens.colors.text.primary +}; + +const statsRowStyle = { + display: 'flex', + gap: designTokens.spacing.md, + flexWrap: 'wrap' +}; + +const statCardStyle = { + background: designTokens.colors.background.primary, + borderRadius: designTokens.borderRadius.medium, + padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px`, + boxShadow: designTokens.shadows.small, + border: `1px solid ${designTokens.colors.border.light}`, + minWidth: '180px', + flex: 1 +}; + +const statCardTextStyle = { + fontSize: '13px', + color: designTokens.colors.text.secondary, + marginBottom: designTokens.spacing.xs +}; + +const statCardValueStyle = { + fontSize: '28px', + fontWeight: '600', + color: designTokens.colors.text.primary +}; + +const statCardIconStyle = (color) => ({ + fontSize: '28px', + color: color, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '48px', + height: '48px', + borderRadius: designTokens.borderRadius.medium, + background: `${color}12` +}); + +const panelStyle = { + background: designTokens.colors.background.primary, + borderRadius: designTokens.borderRadius.large, + boxShadow: designTokens.shadows.small, + border: `1px solid ${designTokens.colors.border.light}`, + overflow: 'hidden' +}; + +const panelHeaderStyle = { + padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px`, + borderBottom: `1px solid ${designTokens.colors.border.light}`, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between' +}; + +const panelTitleStyle = { + fontSize: '15px', + fontWeight: '600', + color: designTokens.colors.text.primary, + display: 'flex', + alignItems: 'center', + gap: designTokens.spacing.sm +}; + +const panelBodyStyle = { + padding: designTokens.spacing.lg +}; + +const actionButtonStyle = { + height: '36px', + padding: `0 ${designTokens.spacing.md}px`, + borderRadius: designTokens.borderRadius.small, + fontSize: '13px', + display: 'flex', + alignItems: 'center', + gap: designTokens.spacing.xs +}; + +const primaryActionStyle = { + ...actionButtonStyle, + background: designTokens.colors.primary.gradient, + border: 'none', + color: '#ffffff', + boxShadow: designTokens.shadows.small +}; function ConsumableStatistics() { const [summary, setSummary] = useState({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] }); @@ -12,6 +188,7 @@ function ConsumableStatistics() { const [stats, setStats] = useState({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [] }); const [loading, setLoading] = useState(true); const [dateRange, setDateRange] = useState([]); + const [categoryFilter, setCategoryFilter] = useState(null); const fetchSummary = async () => { try { @@ -38,6 +215,9 @@ function ConsumableStatistics() { params.startDate = dateRange[0].toISOString(); params.endDate = dateRange[1].toISOString(); } + if (categoryFilter) { + params.category = categoryFilter; + } const response = await axios.get('/api/consumable-records/statistics', { params }); setStats(response.data); } catch (error) { @@ -53,20 +233,36 @@ function ConsumableStatistics() { useEffect(() => { fetchInOutStats(); - }, [dateRange]); + }, [dateRange, categoryFilter]); const lowStockColumns = [ { title: '耗材名称', dataIndex: 'name', key: 'name', - width: 150 + width: 150, + render: (text) => ( + + {text} + + ) }, { title: '分类', dataIndex: 'category', key: 'category', - width: 120 + width: 120, + render: (category) => ( + + {category} + + ) }, { title: '当前库存', @@ -74,37 +270,66 @@ function ConsumableStatistics() { key: 'currentStock', width: 100, render: (value) => ( - {value} + + {value} + ) }, { title: '最小库存', dataIndex: 'minStock', key: 'minStock', - width: 100 + width: 100, + render: (value) => ( + + {value} + + ) }, { title: '单位', dataIndex: 'unit', key: 'unit', - width: 80 + width: 80, + render: (value) => ( + + {value} + + ) }, { - title: '库存充足率', + title: '充足率', key: 'rate', - width: 150, + width: 140, render: (_, record) => { - const rate = Math.min(100, Math.round((record.currentStock / record.maxStock) * 100)); + const rate = Math.min(100, Math.round((record.currentStock / (record.maxStock || 100)) * 100)); const status = rate < 30 ? 'exception' : rate < 60 ? 'active' : 'success'; - return ; + return ( + + ); } }, { title: '供应商', dataIndex: 'supplier', key: 'supplier', - width: 150, - render: (value) => value || '-' + width: 120, + render: (value) => ( + + {value || '-'} + + ) } ]; @@ -113,22 +338,39 @@ function ConsumableStatistics() { title: '时间', dataIndex: 'createdAt', key: 'createdAt', - width: 180, - render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss') + width: 170, + render: (date) => ( + + {dayjs(date).format('YYYY-MM-DD HH:mm')} + + ) }, { title: '耗材名称', dataIndex: ['Consumable', 'name'], key: 'consumableName', - width: 150 + width: 140, + render: (text) => ( + + {text} + + ) }, { title: '类型', dataIndex: 'type', key: 'type', - width: 100, + width: 90, render: (type) => ( - + {type === 'in' ? '入库' : '出库'} ) @@ -139,7 +381,10 @@ function ConsumableStatistics() { key: 'quantity', width: 100, render: (value, record) => ( - + {record.type === 'in' ? '+' : '-'}{value} ) @@ -148,138 +393,279 @@ function ConsumableStatistics() { title: '操作人', dataIndex: 'operator', key: 'operator', - width: 120 + width: 100, + render: (value) => ( + + {value || '-'} + + ) }, { title: '原因', dataIndex: 'reason', key: 'reason', width: 150, - render: (value) => value || '-' + render: (value) => ( + + {value || '-'} + + ) } ]; + const categories = summary.byCategory?.map(item => item.category) || []; + + const netQuantity = stats.inQuantity - stats.outQuantity; + return ( -
- - - - } - /> - - - - - 0 ? '#ff4d4f' : '#52c41a' }} />} - valueStyle={{ color: summary.lowStock > 0 ? '#ff4d4f' : '#52c41a' }} - /> - - - - - } - precision={2} - /> - - - - - = 0 ? '#52c41a' : '#ff4d4f' }} />} - valueStyle={{ color: stats.inQuantity - stats.outQuantity >= 0 ? '#52c41a' : '#ff4d4f' }} - /> - - - - - - - - }> - - - - } - /> -
- +{stats.inQuantity} -
-
- - - - } - /> -
- -{stats.outQuantity} -
-
- -
-
- - - - - {summary.byCategory?.map(item => ( - - - +
+
+
+
+ + 耗材统计 +
+ + + + + +
+ +
+
+
+
+ +
+
+
耗材种类
+
{summary.total}
+
+
+
+ +
+
+
+ +
+
+
低库存预警
+
+ {summary.lowStock} +
+
+
+
+ +
+
+
+ +
+
+
库存总价值
+
+ ¥{parseFloat(summary.totalValue || 0).toLocaleString()} +
+
+
+
+ +
= 0 ? designTokens.colors.success.main : designTokens.colors.error.main}` }}> +
+
= 0 ? designTokens.colors.success.main : designTokens.colors.error.main)}> + +
+
+
净入库量
+
= 0 ? designTokens.colors.success.main : designTokens.colors.error.main }}> + {netQuantity >= 0 ? '+' : ''}{netQuantity} +
+
+
+
+
+
+ + + +
+
+
+ + 入库出库统计 +
+ 近30天 +
+
+ + +
+
+ 入库次数 +
+
+ {stats.inCount} +
+
+ +{stats.inQuantity} +
+
+ + +
+
+ 出库次数 +
+
+ {stats.outCount} +
+
+ -{stats.outQuantity} +
+
+ +
+
+
+ + + +
+
+
+ + 分类统计 +
+ {categories.length}类 +
+
+
+ {summary.byCategory?.map((item, index) => { + const colors = [designTokens.colors.primary.main, designTokens.colors.success.main, designTokens.colors.warning.main, '#8b5cf6', '#06b6d4', '#ec4899']; + const color = colors[index % colors.length]; + return ( +
+
+ + {item.category} + + + {item.count} + +
+ ); + })} + {(!summary.byCategory || summary.byCategory.length === 0) && ( +
+ 暂无分类数据 +
+ )} +
+
+
- + - 低库存预警} - extra={{lowStockItems.length}项} - > - - +
+
+
+ + 低库存预警 +
+ + {lowStockItems.length}项 + +
+
+
+ + + - -
- +
+
+
+ + 最近出入库记录 +
+ 最近 +
+
+
+ + diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index e14cffd..edf330b 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { Card, Row, Col, Statistic, message, Button, Tag, Typography } from 'antd'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { Card, Row, Col, Statistic, message, Button, Tag, Typography, Progress, Spin } from 'antd'; import { DatabaseOutlined, CloudServerOutlined, @@ -12,24 +12,92 @@ import { ReloadOutlined, EnvironmentOutlined, LineChartOutlined, - SafetyOutlined + SafetyOutlined, + TeamOutlined, + ThunderboltOutlined, + AppstoreOutlined, + BarChartOutlined } from '@ant-design/icons'; -import axios from 'axios'; +import api from '../api'; const { Title, Text } = Typography; -const theme = { - primary: '#1890ff', - primaryDark: '#096dd9', - success: '#52c41a', - warning: '#faad14', - error: '#ff4d4f', - purple: '#722ed1', - cyan: '#13c2c2', - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', - cardBg: 'rgba(255, 255, 255, 0.95)', - textPrimary: '#262626', - textSecondary: '#8c8c8c' +const designTokens = { + colors: { + primary: { + main: '#1890ff', + light: '#40a9ff', + dark: '#096dd9', + gradient: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)', + bgGradient: 'linear-gradient(135deg, #1890ff15 0%, #096dd908 100%)' + }, + success: { + main: '#52c41a', + light: '#73d13d', + dark: '#389e0d', + gradient: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)', + bgGradient: 'linear-gradient(135deg, #52c41a15 0%, #389e0d08 100%)' + }, + warning: { + main: '#faad14', + light: '#ffc53d', + dark: '#d48806', + gradient: 'linear-gradient(135deg, #faad14 0%, #d48806 100%)', + bgGradient: 'linear-gradient(135deg, #faad1415 0%, #d4880608 100%)' + }, + error: { + main: '#ff4d4f', + light: '#ff7875', + dark: '#cf1322', + gradient: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)', + bgGradient: 'linear-gradient(135deg, #ff4d4f15 0%, #cf132208 100%)' + }, + purple: { + main: '#722ed1', + light: '#9254de', + dark: '#531dab', + gradient: 'linear-gradient(135deg, #722ed1 0%, #531dab 100%)', + bgGradient: 'linear-gradient(135deg, #722ed115 0%, #531dab08 100%)' + }, + cyan: { + main: '#13c2c2', + light: '#36cfc9', + dark: '#08979c', + gradient: 'linear-gradient(135deg, #13c2c2 0%, #08979c 100%)', + bgGradient: 'linear-gradient(135deg, #13c2c215 0%, #08979c08 100%)' + }, + text: { + primary: '#262626', + secondary: '#8c8c8c', + tertiary: '#bfbfbf' + } + }, + shadows: { + small: '0 2px 8px rgba(0, 0, 0, 0.06)', + medium: '0 4px 16px rgba(0, 0, 0, 0.08)', + large: '0 8px 24px rgba(0, 0, 0, 0.12)', + hover: '0 12px 32px rgba(0, 0, 0, 0.15)' + }, + borderRadius: { + small: '8px', + medium: '12px', + large: '16px', + xl: '20px' + }, + transitions: { + fast: '0.15s ease', + normal: '0.3s cubic-bezier(0.4, 0, 0.2, 1)', + slow: '0.5s cubic-bezier(0.4, 0, 0.2, 1)' + } +}; + +const responsiveConfig = { + xs: { span: 24 }, + sm: { span: 12 }, + md: { span: 8 }, + lg: { span: 6 }, + xl: { span: 5 }, + xxl: { span: 4 } }; const containerStyle = { @@ -41,59 +109,57 @@ const containerStyle = { const headerStyle = { textAlign: 'center', marginBottom: '32px', - padding: '24px', + padding: '32px 48px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', - borderRadius: '16px', + borderRadius: '20px', color: '#fff', - boxShadow: '0 8px 24px rgba(102, 126, 234, 0.3)' + boxShadow: '0 8px 32px rgba(102, 126, 234, 0.3)', + animation: 'fadeInDown 0.6s ease-out' }; const titleStyle = { - fontSize: '2rem', + fontSize: '2.2rem', fontWeight: '700', color: '#fff', margin: '0 0 8px 0', display: 'flex', alignItems: 'center', justifyContent: 'center', - gap: '12px' + gap: '16px' }; const subtitleStyle = { - fontSize: '1rem', + fontSize: '1.1rem', color: 'rgba(255, 255, 255, 0.85)', margin: '0' }; -const statCardStyle = { - borderRadius: '16px', +const statCardStyle = (color) => ({ + borderRadius: designTokens.borderRadius.large, border: 'none', - boxShadow: '0 4px 16px rgba(0, 0, 0, 0.08)', + boxShadow: designTokens.shadows.medium, background: '#fff', - transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + transition: `all ${designTokens.transitions.normal}`, position: 'relative', overflow: 'hidden', cursor: 'pointer', - height: '100%' -}; - -const statCardHoverStyle = { - transform: 'translateY(-4px)', - boxShadow: '0 12px 24px rgba(0, 0, 0, 0.12)' -}; + height: '100%', + animation: 'fadeInUp 0.6s ease-out backwards' +}); const statIconContainer = (color) => ({ position: 'absolute', - top: '16px', - right: '16px', - width: '56px', - height: '56px', - borderRadius: '14px', + top: '20px', + right: '20px', + width: '64px', + height: '64px', + borderRadius: designTokens.borderRadius.medium, display: 'flex', alignItems: 'center', justifyContent: 'center', - background: `linear-gradient(135deg, ${color}15 0%, ${color}08 100%)`, - fontSize: '28px' + background: `linear-gradient(135deg, ${color}20 0%, ${color}10 100%)`, + fontSize: '32px', + transition: `all ${designTokens.transitions.normal}` }); const topBorderStyle = (color) => ({ @@ -103,14 +169,15 @@ const topBorderStyle = (color) => ({ right: 0, height: '4px', background: `linear-gradient(90deg, ${color}, ${color}80)`, - borderRadius: '16px 16px 0 0' + borderRadius: `${designTokens.borderRadius.large} ${designTokens.borderRadius.large} 0 0` }); const overviewCardStyle = { - borderRadius: '16px', + borderRadius: designTokens.borderRadius.large, border: 'none', - boxShadow: '0 4px 16px rgba(0, 0, 0, 0.08)', - background: '#fff' + boxShadow: designTokens.shadows.medium, + background: '#fff', + animation: 'fadeInUp 0.6s ease-out 0.2s backwards' }; const navigationGridStyle = { @@ -120,78 +187,332 @@ const navigationGridStyle = { marginBottom: '24px' }; -const navButtonStyle = { +const navButtonStyle = (color) => ({ height: 'auto', padding: '24px 20px', - borderRadius: '12px', + borderRadius: designTokens.borderRadius.medium, border: '2px solid #f0f0f0', background: '#fff', - transition: 'all 0.3s ease', + transition: `all ${designTokens.transitions.normal}`, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px', - cursor: 'pointer' -}; + cursor: 'pointer', + boxShadow: designTokens.shadows.small +}); -const navButtonHoverStyle = { - borderColor: '#1890ff', - background: 'linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%)', - transform: 'translateY(-2px)', - boxShadow: '0 4px 12px rgba(24, 144, 255, 0.2)' -}; - -const navIconStyle = { - fontSize: '2.2rem', - background: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)', - WebkitBackgroundClip: 'text', - WebkitTextFillColor: 'transparent' -}; +const navIconContainer = (color) => ({ + width: '60px', + height: '60px', + borderRadius: designTokens.borderRadius.medium, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: `linear-gradient(135deg, ${color}20 0%, ${color}10 100%)`, + fontSize: '28px', + transition: `all ${designTokens.transitions.normal}` +}); const navTextStyle = { fontSize: '0.9rem', fontWeight: '600', - color: '#262626' + color: designTokens.colors.text.primary }; const systemInfoStyle = { background: 'linear-gradient(135deg, #f0f7ff 0%, #e6f7ff 100%)', - borderRadius: '12px', + borderRadius: designTokens.borderRadius.medium, padding: '20px', border: '1px solid #91d5ff' }; const quickStatsStyle = { display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', + gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '16px', marginBottom: '24px' }; const quickStatItemStyle = { background: 'linear-gradient(135deg, #fff 0%, #fafafa 100%)', - borderRadius: '12px', - padding: '16px', + borderRadius: designTokens.borderRadius.medium, + padding: '20px', display: 'flex', alignItems: 'center', - gap: '12px', + gap: '16px', + border: '1px solid #f0f0f0', + boxShadow: designTokens.shadows.small +}; + +const progressCardStyle = { + borderRadius: designTokens.borderRadius.large, + border: 'none', + boxShadow: designTokens.shadows.medium, + background: '#fff', + height: '100%', + animation: 'fadeInUp 0.6s ease-out 0.3s backwards' +}; + +const chartContainerStyle = { + padding: '20px', + borderRadius: designTokens.borderRadius.medium, + background: 'linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%)', border: '1px solid #f0f0f0' }; -const createTrendStyle = (trend) => ({ +const pieChartStyle = { + width: '180px', + height: '180px', + borderRadius: '50%', + background: `conic-gradient( + ${designTokens.colors.success.main} 0deg 216deg, + ${designTokens.colors.warning.main} 216deg 288deg, + ${designTokens.colors.error.main} 288deg 324deg, + ${designTokens.colors.primary.main} 324deg 360deg + )`, + position: 'relative', display: 'flex', alignItems: 'center', - fontSize: '0.875rem', - fontWeight: '500', - color: trend > 0 ? theme.success : theme.error, - marginTop: '8px' -}); + justifyContent: 'center' +}; + +const pieChartInner = { + width: '120px', + height: '120px', + borderRadius: '50%', + background: '#fff', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column' +}; + +const trendItemStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 0', + borderBottom: '1px solid #f0f0f0' +}; + +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 {displayValue}; +}; + +const CircularProgress = ({ percentage, size = 120, strokeWidth = 10, color, label }) => { + const circumference = 2 * Math.PI * ((size - strokeWidth) / 2); + const offset = circumference - (percentage / 100) * circumference; + + return ( +
+ + + + +
+
+ {percentage}% +
+
+ {label} +
+
+
+ ); +}; + +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 ( +
+
+ + 功率使用率 + + + {percentage.toFixed(1)}% + +
+
+
+
+
+ {value}W + {maxValue}W +
+
+ ); +}; + +const DeviceTrendChart = ({ data }) => { + const maxValue = Math.max(...data.map(d => d.value)); + const chartHeight = 120; + + return ( +
+
+ {data.map((item, index) => ( +
+
+ + {item.label} + +
+ ))} +
+
+ ); +}; + +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 ( +
+ {legends.map((item, index) => ( +
+
+
+ + {item.label} + +
+ + {item.percent}% + +
+ ))} +
+ ); +}; const navButtonsData = [ - { key: 'devices', icon: CloudServerOutlined, text: '设备管理', path: '/devices', color: '#1890ff' }, - { key: 'racks', icon: DatabaseOutlined, text: '资源规划', path: '/racks', color: '#722ed1' }, - { key: 'faults', icon: WarningOutlined, text: '故障监控', path: '/faults', color: '#faad14' }, - { key: 'settings', icon: SettingOutlined, text: '系统配置', path: '/settings', color: '#13c2c2' } + { key: 'devices', icon: CloudServerOutlined, text: '设备管理', path: '/devices', color: designTokens.colors.primary.main }, + { key: 'racks', icon: DatabaseOutlined, text: '资源规划', path: '/racks', color: designTokens.colors.purple.main }, + { key: 'faults', icon: WarningOutlined, text: '故障监控', path: '/faults', color: designTokens.colors.warning.main }, + { key: 'tickets', icon: BarChartOutlined, text: '工单管理', path: '/tickets', color: designTokens.colors.cyan.main }, + { key: 'consumables', icon: AppstoreOutlined, text: '耗材管理', path: '/consumables', color: '#fa8c16' }, + { key: 'settings', icon: SettingOutlined, text: '系统配置', path: '/settings', color: designTokens.colors.success.main } ]; function Dashboard() { @@ -203,34 +524,44 @@ function Dashboard() { deviceGrowth: 2.5, faultTrend: -12.3, onlineRate: 98.5, - powerUsage: 0 + powerUsage: 0, + totalUsers: 0, + activeTickets: 0 }); const [loading, setLoading] = useState(true); const [hoveredCard, setHoveredCard] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const [animatedKey, setAnimatedKey] = useState(0); const fetchStats = useCallback(async () => { try { - setLoading(true); + setIsRefreshing(true); - const [devicesRes, racksRes, roomsRes] = await Promise.all([ - axios.get('/api/devices', { params: { pageSize: 1 } }), - axios.get('/api/racks', { params: { pageSize: 1 } }), - axios.get('/api/rooms') + const [devicesRes, racksRes, roomsRes, usersRes, ticketsRes] = await Promise.all([ + api.get('/devices', { params: { pageSize: 1 } }), + api.get('/racks', { params: { pageSize: 1 } }), + api.get('/rooms'), + api.get('/users', { params: { pageSize: 1 } }), + api.get('/tickets', { params: { pageSize: 1, status: 'open' } }) ]); - const totalDevices = devicesRes.data.total || 0; - const totalRacks = racksRes.data.total || 0; - const rooms = roomsRes.data || []; + const totalDevices = devicesRes.total || 0; + const totalRacks = racksRes.total || 0; + const rooms = roomsRes || []; const totalRooms = rooms.length; + const totalUsers = usersRes.total || 0; + const activeTickets = ticketsRes.total || 0; let faultDevices = 0; if (totalDevices > 0) { try { - const faultRes = await axios.get('/api/devices/count', { - params: { status: 'fault' } + const faultRes = await api.get('/devices', { + params: { status: 'fault', pageSize: 1 } }); - faultDevices = faultRes.data.count || 0; - } catch { + faultDevices = faultRes.total || 0; + } catch (error) { + message.warning('获取故障设备数失败,使用默认值'); + console.error('获取故障设备数失败:', error); faultDevices = 0; } } @@ -239,17 +570,22 @@ function Dashboard() { 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 }); + + setAnimatedKey(prev => prev + 1); } catch (error) { - message.error('获取统计数据失败'); + message.error(`获取统计数据失败: ${error}`); console.error('获取统计数据失败:', error); } finally { setLoading(false); + setIsRefreshing(false); } }, []); @@ -272,54 +608,81 @@ function Dashboard() { const statCards = useMemo(() => [ { key: 'devices', - xs: 24, sm: 12, lg: 6, + xs: 24, sm: 12, lg: 6, xl: 4, icon: CloudServerOutlined, - color: '#1890ff', + color: designTokens.colors.primary.main, statKey: 'totalDevices', title: '总设备数', trend: stats.deviceGrowth, - tagColor: 'blue' + tagColor: 'blue', + delay: 0 }, { key: 'racks', - xs: 24, sm: 12, lg: 6, + xs: 24, sm: 12, lg: 6, xl: 4, icon: DatabaseOutlined, - color: '#722ed1', + color: designTokens.colors.purple.main, statKey: 'totalRacks', title: '总机柜数', trend: 0, tagColor: 'green', - customStatus: true + customStatus: true, + delay: 1 }, { key: 'rooms', - xs: 24, sm: 12, lg: 6, + xs: 24, sm: 12, lg: 6, xl: 4, icon: HomeOutlined, - color: '#52c41a', + color: designTokens.colors.success.main, statKey: 'totalRooms', title: '总机房数', trend: 0, tagColor: 'green', - customStatus: true + customStatus: true, + delay: 2 }, { key: 'faults', - xs: 24, sm: 12, lg: 6, + xs: 24, sm: 12, lg: 6, xl: 4, icon: WarningOutlined, - color: '#ff4d4f', + color: designTokens.colors.error.main, statKey: 'faultDevices', title: '故障设备', trend: stats.faultTrend, - tagColor: 'red' + tagColor: 'red', + delay: 3 + }, + { + key: 'users', + xs: 24, sm: 12, lg: 6, xl: 4, + icon: TeamOutlined, + color: designTokens.colors.cyan.main, + statKey: 'totalUsers', + title: '用户总数', + trend: 5.2, + tagColor: 'cyan', + delay: 4 + }, + { + key: 'tickets', + xs: 24, sm: 12, lg: 6, xl: 4, + icon: BarChartOutlined, + color: '#fa8c16', + statKey: 'activeTickets', + title: '待处理工单', + trend: -8.5, + tagColor: 'orange', + delay: 5 } ], [stats.deviceGrowth, stats.faultTrend]); const renderStatCard = useCallback((config) => { - const { icon: Icon, color, statKey, title, trend, tagColor, customStatus, xs, sm, lg } = config; - const colProps = { xs, sm, lg }; + const { icon: Icon, color, statKey, title, trend, tagColor, customStatus, xs, sm, lg, xl, delay } = config; + const colProps = { xs, sm, lg, xl }; const cardStyle = { - ...statCardStyle, - ...(hoveredCard === statKey ? statCardHoverStyle : {}) + ...statCardStyle(color), + ...(hoveredCard === statKey ? { transform: 'translateY(-6px)', boxShadow: designTokens.shadows.hover } : {}), + animationDelay: `${delay * 0.1}s` }; return ( @@ -334,171 +697,325 @@ function Dashboard() {
- - {title} - - } - value={stats[statKey]} - valueStyle={{ - fontSize: '2rem', - fontWeight: '700', - color: theme.textPrimary, - marginBottom: '4px' - }} - loading={loading} - /> +
+ {title} +
+
+ {loading ? ( + + ) : ( + + )} +
{customStatus ? ( statKey === 'totalRacks' ? ( -
- +
+ 正常运行中
) : ( -
- +
+ 全部在线
) ) : ( -
+
0 ? designTokens.colors.success.main : designTokens.colors.error.main, + marginTop: '8px' + }}> {trend > 0 ? : } {Math.abs(trend)}% - 本月 + 环比
)}
); - }, [stats, loading, hoveredCard]); + }, [stats, loading, hoveredCard, animatedKey]); const navButtons = useMemo(() => navButtonsData.map(({ key, icon: Icon, text, color }) => ( - +
)), [hoveredCard]); const quickStats = useMemo(() => [ - { icon: LineChartOutlined, label: '在线率', value: `${stats.onlineRate}%`, color: theme.success }, - { icon: SafetyOutlined, label: '安全等级', value: 'A级', color: theme.primary }, - { icon: EnvironmentOutlined, label: '功率使用', value: `${stats.powerUsage}W`, color: theme.warning } + { icon: LineChartOutlined, label: '在线率', value: `${stats.onlineRate}%`, color: designTokens.colors.success.main }, + { icon: SafetyOutlined, label: '安全等级', value: 'A级', color: designTokens.colors.primary.main }, + { icon: ThunderboltOutlined, label: '功率使用', value: `${stats.powerUsage}W`, color: designTokens.colors.warning.main } ], [stats.onlineRate, stats.powerUsage]); + 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 systemInfo = useMemo(() => (
-

+

系统版本: v1.0.0

-

+

最后更新:{new Date().toLocaleDateString()}

- ), [handleRefresh]); + ), [handleRefresh, isRefreshing]); + + const styles = ` + @keyframes fadeInDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + @keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + `; return ( -
-
-

- - IDC设备管理系统 -

-

实时监控 · 智能管理 · 高效运维

-
+ <> + +
+
+

+ + IDC设备管理系统 +

+

实时监控 · 智能管理 · 高效运维

+
- - {statCards.map(renderStatCard)} - + + {statCards.map(renderStatCard)} + -
- -
-
-

- 欢迎使用IDC设备管理系统 -

-

- 专业的机房设备管理解决方案,提供全方位的设备监控和管理能力 -

-
- -
- {quickStats.map((stat, index) => ( -
-
- -
-
- {stat.label} -
{stat.value}
+ +
+ +
+ + 设备状态分布 + +
+
+
+ + {stats.totalDevices} + + + 设备总数 + +
+
- ))} -
+ +
+ -
- {navButtons} -
+ + +
+ + 系统健康指标 + +
+ + +
+
+
+ - {systemInfo} - - + + +
+ + 周设备趋势 + +
+ +
+
+ + 周一 至 周日 设备变化趋势 + +
+
+
+ + + +
+ +
+
+

+ 欢迎使用IDC设备管理系统 +

+

+ 专业的机房设备管理解决方案,提供全方位的设备监控和管理能力 +

+
+ +
+ {quickStats.map((stat, index) => ( +
+
+ +
+
+ {stat.label} +
{stat.value}
+
+
+ ))} +
+ +
+ {navButtons} +
+ +
+ {systemInfo} +
+
+
+
+ + - + ); } diff --git a/frontend/src/pages/DeviceFieldManagement.jsx b/frontend/src/pages/DeviceFieldManagement.jsx index f139077..eae337c 100644 --- a/frontend/src/pages/DeviceFieldManagement.jsx +++ b/frontend/src/pages/DeviceFieldManagement.jsx @@ -1,9 +1,130 @@ import React, { useState, useEffect } from 'react'; -import { Table, Button, Modal, Form, Input, Select, message, Card, Space, InputNumber, Switch } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { Table, Button, Modal, Form, Input, Select, message, Card, Space, InputNumber, Switch, Tag, Statistic } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined, FontSizeOutlined, NumberOutlined, CheckCircleOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons'; import axios from 'axios'; -const { Option } = Select; +const { Option = Select.Option } = Select; + +const designTokens = { + colors: { + primary: { + main: '#667eea', + gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + light: '#8b9ff0', + dark: '#4f5db8' + }, + success: { + main: '#10b981', + gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' + }, + warning: { + main: '#f59e0b', + gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' + }, + error: { + main: '#ef4444', + gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' + }, + text: { + primary: '#1e293b', + secondary: '#64748b', + tertiary: '#94a3b8', + inverse: '#ffffff' + }, + background: { + primary: '#ffffff', + secondary: '#f8fafc', + tertiary: '#f1f5f9' + }, + border: { + light: '#e2e8f0', + medium: '#cbd5e1', + dark: '#94a3b8' + }, + fieldType: { + string: '#3b82f6', + number: '#10b981', + boolean: '#f59e0b', + select: '#8b5cf6', + date: '#06b6d4', + textarea: '#64748b' + } + }, + shadows: { + small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)', + large: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)', + glow: '0 0 20px rgba(102, 126, 234, 0.15)' + }, + borderRadius: { + small: '6px', + medium: '10px', + large: '16px' + }, + transitions: { + fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)', + normal: '300ms cubic-bezier(0.4, 0, 0.2, 1)' + }, + spacing: { + xs: '4px', + sm: '8px', + md: '16px', + lg: '24px', + xl: '32px' + } +}; + +const pageContainerStyle = { + minHeight: '100vh', + background: designTokens.colors.background.secondary, + padding: designTokens.spacing.lg +}; + +const titleRowStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: designTokens.spacing.lg +}; + +const titleStyle = { + display: 'flex', + alignItems: 'center', + gap: designTokens.spacing.sm, + fontSize: '20px', + fontWeight: '600', + color: designTokens.colors.text.primary +}; + +const actionButtonStyle = { + height: '36px', + padding: `0 ${designTokens.spacing.md}px`, + borderRadius: designTokens.borderRadius.small, + fontSize: '13px', + display: 'flex', + alignItems: 'center', + gap: designTokens.spacing.xs +}; + +const primaryActionStyle = { + ...actionButtonStyle, + background: designTokens.colors.primary.gradient, + border: 'none', + color: '#ffffff', + boxShadow: designTokens.shadows.small +}; + +const tableCardStyle = { + background: designTokens.colors.background.primary, + borderRadius: designTokens.borderRadius.large, + boxShadow: designTokens.shadows.small, + border: `1px solid ${designTokens.colors.border.light}`, + overflow: 'hidden' +}; + +const tableStyle = { + background: designTokens.colors.background.primary +}; function DeviceFieldManagement() { const [fields, setFields] = useState([]); @@ -12,7 +133,6 @@ function DeviceFieldManagement() { const [editingField, setEditingField] = useState(null); const [form] = Form.useForm(); - // 获取所有字段配置 const fetchFields = async () => { try { setLoading(true); @@ -30,11 +150,9 @@ function DeviceFieldManagement() { fetchFields(); }, []); - // 打开模态框 const showModal = (field = null) => { setEditingField(field); if (field) { - // 将options对象转换为JSON字符串以便在TextArea中显示 const fieldData = { ...field, options: field.options ? JSON.stringify(field.options, null, 2) : '' @@ -46,27 +164,22 @@ function DeviceFieldManagement() { setModalVisible(true); }; - // 关闭模态框 const handleCancel = () => { setModalVisible(false); setEditingField(null); }; - // 提交表单 const handleSubmit = async (values) => { try { - // 处理选项配置,将JSON字符串转换为对象 const fieldData = { ...values, options: values.options ? JSON.parse(values.options) : null }; if (editingField) { - // 更新字段 await axios.put(`/api/deviceFields/${editingField.fieldId}`, fieldData); message.success('字段更新成功'); } else { - // 创建字段 await axios.post('/api/deviceFields', fieldData); message.success('字段创建成功'); } @@ -80,7 +193,6 @@ function DeviceFieldManagement() { } }; - // 删除字段 const handleDelete = async (fieldId) => { Modal.confirm({ title: '确认删除', @@ -101,64 +213,141 @@ function DeviceFieldManagement() { }); }; - // 表格列配置 + const getFieldTypeIcon = (type) => { + const iconMap = { + string: , + number: , + boolean: , + select: , + date: , + textarea: + }; + return iconMap[type] || ; + }; + const columns = [ { title: '字段名称', dataIndex: 'fieldName', key: 'fieldName', + width: 150, + render: (text) => ( + + {text} + + ) }, { title: '显示名称', dataIndex: 'displayName', key: 'displayName', + width: 120, }, { title: '字段类型', dataIndex: 'fieldType', key: 'fieldType', + width: 110, render: (type) => { const typeMap = { - string: '文本', - number: '数字', - boolean: '布尔值', - select: '下拉选择', - date: '日期', - textarea: '多行文本' + string: { text: '文本', color: designTokens.colors.fieldType.string }, + number: { text: '数字', color: designTokens.colors.fieldType.number }, + boolean: { text: '布尔值', color: designTokens.colors.fieldType.boolean }, + select: { text: '下拉选择', color: designTokens.colors.fieldType.select }, + date: { text: '日期', color: designTokens.colors.fieldType.date }, + textarea: { text: '多行文本', color: designTokens.colors.fieldType.textarea } }; - return typeMap[type] || type; + const config = typeMap[type] || { text: type, color: designTokens.colors.text.tertiary }; + return ( + + {getFieldTypeIcon(type)} + {config.text} + + ); } }, { title: '必填', dataIndex: 'required', key: 'required', + width: 80, render: (required) => ( - + + {required ? '是' : '否'} + ) }, { title: '可见', dataIndex: 'visible', key: 'visible', + width: 80, render: (visible) => ( - + + {visible ? '是' : '否'} + ) }, { title: '顺序', dataIndex: 'order', key: 'order', + width: 80, + render: (order) => ( + + {order} + + ) }, { title: '操作', key: 'action', + width: 160, + fixed: 'right', render: (_, record) => ( - - - @@ -167,27 +356,55 @@ function DeviceFieldManagement() { ]; return ( -
- } onClick={() => showModal()}> +
+
+
+ + 设备字段管理 +
+ - }> +
+ +
`第 ${range[0]}-${range[1]} 条 / 共 ${total} 条` + }} + scroll={{ x: 900 }} + style={tableStyle} /> - + + {editingField ? '编辑字段' : '添加字段'} + + } open={modalVisible} onCancel={handleCancel} footer={null} width={600} + styles={{ + body: { padding: designTokens.spacing.lg } + }} + style={{ + borderRadius: designTokens.borderRadius.large + }} >
字段名称} rules={[{ required: true, message: '请输入字段名称' }]} > @@ -204,7 +421,7 @@ function DeviceFieldManagement() { 显示名称} rules={[{ required: true, message: '请输入显示名称' }]} > @@ -212,7 +429,7 @@ function DeviceFieldManagement() { 字段类型} rules={[{ required: true, message: '请选择字段类型' }]} > - - - +
+ 必填} + valuePropName="checked" + style={{ flex: 1 }} + > + + - - - + 可见} + valuePropName="checked" + style={{ flex: 1 }} + > + + +
显示顺序} rules={[{ required: true, message: '请输入显示顺序' }]} > @@ -249,21 +472,20 @@ function DeviceFieldManagement() { 选项配置(JSON格式)} + tooltip="格式示例:[{value: 'option1', label: '选项1'}],仅下拉选择类型需要配置" > - - + - + @@ -272,4 +494,4 @@ function DeviceFieldManagement() { ); } -export default DeviceFieldManagement; \ No newline at end of file +export default DeviceFieldManagement; diff --git a/frontend/src/pages/DeviceManagement.jsx b/frontend/src/pages/DeviceManagement.jsx index 5beb872..8a8237a 100644 --- a/frontend/src/pages/DeviceManagement.jsx +++ b/frontend/src/pages/DeviceManagement.jsx @@ -7,6 +7,94 @@ import dayjs from 'dayjs'; const { Option } = Select; const { RangePicker } = DatePicker; +const designTokens = { + colors: { + primary: { + main: '#667eea', + gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + light: '#8b9ff0', + dark: '#4f5db8' + }, + success: { + main: '#10b981', + gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', + light: '#34d399', + dark: '#047857' + }, + warning: { + main: '#f59e0b', + gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', + light: '#fbbf24', + dark: '#b45309' + }, + error: { + main: '#ef4444', + gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)', + light: '#f87171', + dark: '#b91c1c' + }, + text: { + primary: '#1e293b', + secondary: '#64748b', + tertiary: '#94a3b8', + inverse: '#ffffff' + }, + background: { + primary: '#ffffff', + secondary: '#f8fafc', + tertiary: '#f1f5f9', + dark: '#1e293b' + }, + border: { + light: '#e2e8f0', + medium: '#cbd5e1', + dark: '#94a3b8' + }, + device: { + server: '#3b82f6', + switch: '#22c55e', + router: '#f59e0b', + storage: '#8b5cf6', + other: '#64748b' + }, + status: { + normal: '#10b981', + running: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + fault: '#ef4444', + offline: '#6b7280', + maintenance: '#3b82f6' + } + }, + shadows: { + small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)', + large: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)', + xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)', + glow: '0 0 20px rgba(102, 126, 234, 0.3)' + }, + borderRadius: { + small: '6px', + medium: '10px', + large: '16px', + xl: '24px', + round: '50%' + }, + transitions: { + fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)', + normal: '300ms cubic-bezier(0.4, 0, 0.2, 1)', + slow: '500ms cubic-bezier(0.4, 0, 0.2, 1)' + }, + spacing: { + xs: '4px', + sm: '8px', + md: '16px', + lg: '24px', + xl: '32px' + } +}; + // 防抖 Hook function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); @@ -1161,68 +1249,184 @@ function DeviceManagement() { message.success('字段配置已重置为默认值'); }; - const pageHeaderStyle = { - marginBottom: '24px', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - flexWrap: 'wrap', - gap: '12px' + const pageContainerStyle = { + minHeight: '100vh', + background: designTokens.colors.background.secondary, + padding: designTokens.spacing.lg }; - const titleStyle = { - fontSize: '24px', + const headerStyle = { + marginBottom: designTokens.spacing.lg + }; + + const titleRowStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: designTokens.spacing.lg, + flexWrap: 'wrap', + gap: designTokens.spacing.md + }; + + const titleSectionStyle = { + display: 'flex', + alignItems: 'center', + gap: designTokens.spacing.md + }; + + const titleIconStyle = { + width: '44px', + height: '44px', + borderRadius: designTokens.borderRadius.medium, + background: designTokens.colors.primary.gradient, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + boxShadow: designTokens.shadows.medium + }; + + const titleTextStyle = { + display: 'flex', + flexDirection: 'column', + gap: '2px' + }; + + const pageTitleStyle = { + fontSize: '22px', fontWeight: '700', margin: 0, - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', - WebkitBackgroundClip: 'text', - WebkitTextFillColor: 'transparent', - backgroundClip: 'text' + color: designTokens.colors.text.primary, + lineHeight: 1.2 }; - const cardStyle = { - borderRadius: '16px', + const pageSubtitleStyle = { + fontSize: '13px', + color: designTokens.colors.text.secondary, + margin: 0 + }; + + const actionButtonStyle = { + height: '36px', + borderRadius: designTokens.borderRadius.small, + fontSize: '13px', + fontWeight: '500', + display: 'inline-flex', + alignItems: 'center', + gap: '6px' + }; + + const primaryActionStyle = { + ...actionButtonStyle, + background: designTokens.colors.primary.gradient, border: 'none', - boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)', - overflow: 'hidden' + color: '#ffffff', + boxShadow: designTokens.shadows.small + }; + + const secondaryActionStyle = { + ...actionButtonStyle, + background: designTokens.colors.background.primary, + border: `1px solid ${designTokens.colors.border.light}`, + color: designTokens.colors.text.primary + }; + + const dangerActionStyle = { + ...actionButtonStyle, + background: designTokens.colors.error.main, + border: 'none', + color: '#ffffff' }; const primaryButtonStyle = { height: '40px', - borderRadius: '8px', - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + borderRadius: designTokens.borderRadius.small, + background: designTokens.colors.primary.gradient, border: 'none', - boxShadow: '0 4px 12px rgba(102, 126, 234, 0.35)', + color: '#ffffff', + boxShadow: designTokens.shadows.small, fontWeight: '500', - transition: 'all 0.3s ease' + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center' }; - const secondaryButtonStyle = { - height: '40px', - borderRadius: '8px', - border: '1px solid #e8e8e8', - transition: 'all 0.3s ease' + const statsRowStyle = { + display: 'flex', + gap: designTokens.spacing.md, + marginBottom: designTokens.spacing.lg, + flexWrap: 'wrap' }; - const searchCardStyle = { - borderRadius: '12px', - border: '1px solid #f0f0f0', - background: 'linear-gradient(180deg, #fafafa 0%, #ffffff 100%)', - marginBottom: '20px' + const statCardStyle = { + flex: 1, + minWidth: '140px', + maxWidth: '200px', + padding: `${designTokens.spacing.md}px ${designTokens.spacing.lg}px`, + background: designTokens.colors.background.primary, + borderRadius: designTokens.borderRadius.medium, + border: `1px solid ${designTokens.colors.border.light}`, + boxShadow: designTokens.shadows.small, + transition: `all ${designTokens.transitions.fast}` + }; + + const statValueStyle = { + fontSize: '24px', + fontWeight: '700', + color: designTokens.colors.text.primary, + lineHeight: 1.2 + }; + + const statLabelStyle = { + fontSize: '12px', + color: designTokens.colors.text.secondary, + marginTop: '4px' + }; + + const statCardRunningStyle = { + ...statCardStyle, + borderLeft: `3px solid ${designTokens.colors.success.main}`, + background: `${designTokens.colors.success.main}08` + }; + + const statCardMaintenanceStyle = { + ...statCardStyle, + borderLeft: `3px solid ${designTokens.colors.warning.main}`, + background: `${designTokens.colors.warning.main}08` + }; + + const statCardFaultStyle = { + ...statCardStyle, + borderLeft: `3px solid ${designTokens.colors.error.main}`, + background: `${designTokens.colors.error.main}08` + }; + + const cardStyle = { + borderRadius: designTokens.borderRadius.large, + border: 'none', + boxShadow: designTokens.shadows.medium, + overflow: 'hidden', + background: designTokens.colors.background.primary + }; + + const filterCardStyle = { + borderRadius: designTokens.borderRadius.medium, + border: 'none', + boxShadow: designTokens.shadows.small, + background: designTokens.colors.background.primary, + marginBottom: designTokens.spacing.lg }; const modalHeaderStyle = { display: 'flex', alignItems: 'center', - gap: '8px', + gap: designTokens.spacing.sm, fontSize: '18px', fontWeight: '600' }; return ( -
+
-
-

- - 设备管理 -

-
- - - - - - +
+
+
+
+ +
+
+

设备管理

+

管理您的IT设备资产

+
+
+ +
+ + + + + + +
- +
- + } + placeholder="搜索设备..." + prefix={} style={{ - width: 320, - borderRadius: '8px', - border: '1px solid #d9d9d9' + width: '280px', + borderRadius: designTokens.borderRadius.medium, + border: `1px solid ${designTokens.colors.border.light}`, + transition: `all ${designTokens.transitions.fast}` }} value={keyword} onChange={(e) => setKeyword(e.target.value)} /> - + - + - + - + +
+ +
@@ -1491,21 +1761,31 @@ function DeviceManagement() {
- +

暂无设备数据

)} {filteredDevicesMemo.length > 0 && ( -
+
`共 ${total} 条记录`, + style: { marginTop: '16px' } + }} onChange={handleTableChange} scroll={{ y: 'calc(100vh - 380px)', scrollToFirstRowOnChange: true }} virtual @@ -1640,7 +1920,7 @@ function DeviceManagement() { - + @@ -1687,8 +1967,8 @@ function DeviceManagement() { - - + + @@ -1733,7 +2013,7 @@ function DeviceManagement() {
- @@ -1861,7 +2141,7 @@ function DeviceManagement() { , , ,
+ + ); }; function RackManagement() { @@ -18,19 +256,24 @@ function RackManagement() { const [rooms, setRooms] = useState([]); const [loading, setLoading] = useState(true); const [modalVisible, setModalVisible] = useState(false); + const [drawerVisible, setDrawerVisible] = useState(false); + const [importModalVisible, setImportModalVisible] = useState(false); const [editingRack, setEditingRack] = useState(null); - const [form] = Form.useForm(); - // 分页状态 - const [pagination, setPagination] = useState({ - current: 1, - pageSize: 10, + const [viewingRack, setViewingRack] = useState(null); + const [selectedRackIds, setSelectedRackIds] = useState([]); + const [viewMode, setViewMode] = useState('table'); + const [searchKeyword, setSearchKeyword] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [roomFilter, setRoomFilter] = useState('all'); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, total: 0, pageSizeOptions: ['10', '20', '30', '50', '100'], showSizeChanger: true, showTotal: (total) => `共 ${total} 条记录` }); - // 导入模态框状态 - const [importModalVisible, setImportModalVisible] = useState(false); + const [form] = Form.useForm(); const [importProgress, setImportProgress] = useState(0); const [importPhase, setImportPhase] = useState(''); const [isImporting, setIsImporting] = useState(false); @@ -40,15 +283,10 @@ function RackManagement() { try { setLoading(true); const response = await axios.get('/api/racks', { - params: { - page, - pageSize - } + params: { page, pageSize } }); - // 假设API返回格式为 { racks: [], total: number } const { racks: data, total } = response.data; setRacks(data); - // 更新分页状态 setPagination(prev => ({ ...prev, current: page, pageSize, total })); } catch (error) { message.error('获取机柜列表失败'); @@ -68,17 +306,16 @@ function RackManagement() { } }, []); - const handleTableChange = useCallback((pagination) => { - fetchRacks(pagination.current, pagination.pageSize); - }, [fetchRacks]); - useEffect(() => { fetchRacks(pagination.current, pagination.pageSize); fetchRooms(); }, [fetchRacks, fetchRooms]); - // 打开模态框 - const showModal = useCallback((rack = null) => { + const handleTableChange = useCallback((pagination) => { + fetchRacks(pagination.current, pagination.pageSize); + }, [fetchRacks]); + + const showModal = (rack = null) => { setEditingRack(rack); if (rack) { form.setFieldsValue(rack); @@ -86,27 +323,22 @@ function RackManagement() { form.resetFields(); } setModalVisible(true); - }, []); + }; - // 关闭模态框 - const handleCancel = useCallback(() => { + const handleCancel = () => { setModalVisible(false); setEditingRack(null); - }, []); + }; - // 提交表单 - const handleSubmit = useCallback(async (values) => { + const handleSubmit = async (values) => { try { if (editingRack) { - // 更新机柜 await axios.put(`/api/racks/${editingRack.rackId}`, values); message.success('机柜更新成功'); } else { - // 创建机柜 await axios.post('/api/racks', values); message.success('机柜创建成功'); } - setModalVisible(false); fetchRacks(); setEditingRack(null); @@ -114,13 +346,12 @@ function RackManagement() { message.error(editingRack ? '机柜更新失败' : '机柜创建失败'); console.error(editingRack ? '机柜更新失败:' : '机柜创建失败:', error); } - }, [editingRack, fetchRacks]); + }; - // 删除机柜 - const handleDelete = useCallback(async (rackId) => { + const handleDelete = async (rackId) => { Modal.confirm({ title: '确认删除', - content: '确定要删除这个机柜吗?', + content: '确定要删除这个机柜吗?删除后无法恢复。', okText: '删除', okType: 'danger', cancelText: '取消', @@ -135,289 +366,453 @@ function RackManagement() { } } }); - }, [fetchRacks]); + }; + + const handleBatchDelete = async () => { + if (selectedRackIds.length === 0) { + message.warning('请先选择要删除的机柜'); + return; + } + + Modal.confirm({ + title: '批量删除', + content: `确定要删除选中的 ${selectedRackIds.length} 个机柜吗?`, + okText: '删除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + try { + await Promise.all(selectedRackIds.map(id => axios.delete(`/api/racks/${id}`))); + message.success(`成功删除 ${selectedRackIds.length} 个机柜`); + setSelectedRackIds([]); + fetchRacks(); + } catch (error) { + message.error('批量删除失败'); + console.error('批量删除失败:', error); + } + } + }); + }; + + const handleView = (rack) => { + setViewingRack(rack); + setDrawerVisible(true); + }; - // 下载导入模板 const handleDownloadTemplate = useCallback(() => { - // 调用后端API下载模板 window.open('/api/racks/import-template', '_blank'); message.success('模板下载成功'); }, []); - // 导入机柜数据 const handleImport = useCallback(async (file) => { try { setIsImporting(true); setImportProgress(0); setImportPhase('正在上传文件...'); setImportResult(null); - + const formData = new FormData(); formData.append('file', file); - + const response = await axios.post('/api/racks/import', formData, { - headers: { - 'Content-Type': 'multipart/form-data' - }, - onUploadProgress: (progressEvent) => { - const progress = Math.round((progressEvent.loaded * 50) / progressEvent.total); - setImportProgress(Math.min(progress, 50)); - setImportPhase('正在上传文件...'); - } + headers: { 'Content-Type': 'multipart/form-data' } }); - - setImportProgress(60); - setImportPhase('正在处理数据...'); - - setTimeout(() => { - setImportProgress(80); - setImportPhase('正在验证数据...'); - }, 200); - - setTimeout(() => { - setImportProgress(90); - setImportPhase('正在保存数据...'); - }, 400); - + setImportProgress(100); setImportPhase('导入完成'); setImportResult(response.data); setIsImporting(false); - + if (response.data.success) { - const { imported, duplicates, total } = response.data; - if (duplicates > 0) { - message.warning(`导入完成,但有 ${duplicates} 条重复记录被跳过`); - } else { - message.success('所有记录导入成功'); - } + message.success('机柜导入成功'); } else { - message.error(response.data.message || '导入失败'); + message.warning(response.data.message || '部分记录导入失败'); } - + + fetchRacks(); return false; } catch (error) { setIsImporting(false); setImportProgress(0); - - let errorMessage = '机柜导入失败'; - let errorDetails = []; - - if (error.response) { - const { data } = error.response; - if (data && data.errors && Array.isArray(data.errors)) { - errorDetails = data.errors.map((err, index) => ({ - row: err.row || index + 1, - error: err.error || err.message || '未知错误' - })); - errorMessage = `导入失败,共发现 ${errorDetails.length} 处数据错误`; - } else if (data && data.message) { - errorMessage = data.message; - } else if (data && data.details && Array.isArray(data.details)) { - errorDetails = data.details.map((err, index) => ({ - row: err.row || index + 1, - error: err.error || err.message || '未知错误' - })); - errorMessage = `导入失败,共发现 ${errorDetails.length} 处数据错误`; - } - } else if (error.message) { - errorMessage = error.message; - } - - setImportResult({ - success: false, - message: errorMessage, - details: errorDetails - }); - - message.error(errorMessage); + message.error('机柜导入失败'); console.error('机柜导入失败:', error); - return false; } - }, []); + }, [fetchRacks]); - // 表格列配置 - const columns = useMemo(() => [ + const filteredRacks = useMemo(() => { + return racks.filter(rack => { + const matchKeyword = !searchKeyword || + rack.name?.toLowerCase().includes(searchKeyword.toLowerCase()) || + rack.rackId?.toLowerCase().includes(searchKeyword.toLowerCase()); + + const matchStatus = statusFilter === 'all' || rack.status === statusFilter; + const matchRoom = roomFilter === 'all' || rack.roomId === roomFilter; + + return matchKeyword && matchStatus && matchRoom; + }); + }, [racks, searchKeyword, statusFilter, roomFilter]); + + const stats = useMemo(() => ({ + total: racks.length, + active: racks.filter(r => r.status === 'active').length, + maintenance: racks.filter(r => r.status === 'maintenance').length, + totalPower: racks.reduce((sum, r) => sum + (r.currentPower || 0), 0), + totalDevices: racks.reduce((sum, r) => sum + (r.Devices?.length || 0), 0) + }), [racks]); + + const tableColumns = [ { - title: '机柜ID', - dataIndex: 'rackId', - key: 'rackId', + title: ( + 0} + indeterminate={selectedRackIds.length > 0 && selectedRackIds.length < filteredRacks.length} + onChange={(e) => { + if (e.target.checked) { + setSelectedRackIds(filteredRacks.map(r => r.rackId)); + } else { + setSelectedRackIds([]); + } + }} + /> + ), + key: 'selection', + width: 50, + render: (_, record) => ( + { + if (e.target.checked) { + setSelectedRackIds([...selectedRackIds, record.rackId]); + } else { + setSelectedRackIds(selectedRackIds.filter(id => id !== record.rackId)); + } + }} + /> + ) }, { - title: '机柜名称', - dataIndex: 'name', - key: 'name', + title: '机柜信息', + key: 'rackInfo', + render: (_, record) => ( +
+
+ +
+
+
+ {record.name} +
+
+ {record.rackId} +
+
+
+ ) }, { title: '所属机房', dataIndex: ['Room', 'name'], key: 'room', + render: (name, record) => ( +
+ + {name || '未分配'} +
+ ) }, { - title: '高度(U)', - dataIndex: 'height', - key: 'height', + title: '高度/已用U位', + key: 'heightUsage', + render: (_, record) => { + const used = record.Devices?.length || 0; + const percentage = (used / record.height) * 100; + return ( +
+ {record.height}U + = 90 ? designTokens.colors.error.main : designTokens.colors.success.main} + trailColor="#f0f0f0" + style={{ marginTop: '4px', marginBottom: 0 }} + /> + + 已用 {used} U位 + +
+ ); + }, + sorter: (a, b) => (a.Devices?.length || 0) - (b.Devices?.length || 0) }, { - title: '最大功率(W)', - dataIndex: 'maxPower', - key: 'maxPower', - }, - { - title: '当前功率(W)', - dataIndex: 'currentPower', - key: 'currentPower', + title: '功率使用', + key: 'powerUsage', + render: (_, record) => ( +
+ +
+ ), + sorter: (a, b) => (a.currentPower || 0) - (b.currentPower || 0) }, { title: '状态', dataIndex: 'status', key: 'status', - render: (status) => ( - - {statusMap[status].text} - - ), + render: (status) => { + const config = statusConfig[status]; + return ( + + {config.text} + + ); + }, + filters: [ + { text: '在用', value: 'active' }, + { text: '维护中', value: 'maintenance' }, + { text: '停用', value: 'inactive' } + ], + onFilter: (value, record) => record.status === value }, { - title: '设备数量', - dataIndex: 'Devices', + title: '设备数', key: 'deviceCount', - render: (devices) => devices ? devices.length : 0, + render: (_, record) => ( + + ), + sorter: (a, b) => (a.Devices?.length || 0) - (b.Devices?.length || 0) }, { title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', - render: (date) => date ? new Date(date).toLocaleString() : '', + render: (date) => date ? new Date(date).toLocaleString() : '-', + sorter: (a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0) }, { title: '操作', key: 'action', + fixed: 'right', + width: 160, render: (_, record) => ( - - - + + + - - +
+
+
+
+

+ + 机柜管理 +

+

+ 管理和监控所有机柜设备 +

+
+
+
+ 总机柜 +
{stats.total}
+
+
+ 在用机柜 +
{stats.active}
+
+
+ 设备总数 +
{stats.totalDevices}
+
+
+ 总功率 +
{(stats.totalPower / 1000).toFixed(1)}kW
+
+
+
-
'table-row'} - /> +
+
+ } + value={searchKeyword} + onChange={(e) => setSearchKeyword(e.target.value)} + style={searchInputStyle} + allowClear + /> + + +
+
+ {selectedRackIds.length > 0 && ( + + )} + + + +
+
+ +
+ + +
+ + {viewMode === 'table' ? ( +
'table-row'} + /> + ) : ( + + {filteredRacks.length > 0 ? ( + filteredRacks.map(rack => ( + + { + if (selectedRackIds.includes(id)) { + setSelectedRackIds(selectedRackIds.filter(rid => rid !== id)); + } else { + setSelectedRackIds([...selectedRackIds, id]); + } + }} + /> + + )) + ) : ( + + + + )} + + )} - +
+
{editingRack ? '编辑机柜' : '添加机柜'}
} @@ -430,28 +825,29 @@ function RackManagement() { body: { padding: '24px' }, header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' } }} - style={{ borderRadius: '16px', overflow: 'hidden' }} + style={{ borderRadius: '16px' }} > -
- - - - - - - + + +
+ + + + + + + + + + - - - - - - - + + + + + + + + + + + + - - - - - - +
+ + +
- {/* 导入机柜模态框 */} + + + 机柜详情 - {viewingRack?.name} + + } + open={drawerVisible} + onClose={() => setDrawerVisible(false)} + width={520} + styles={{ + header: { borderBottom: '1px solid #f0f0f0' }, + body: { padding: '24px' } + }} + > + {viewingRack && ( +
+
+
+ +
+
+ {viewingRack.name} +
+
+ {viewingRack.rackId} +
+
+
+ + {statusConfig[viewingRack.status].text} + +
+ + +
+
+ 所属机房 +
+ {viewingRack.Room?.name || '未分配'} +
+
+ + +
+ 设备数量 +
+ {viewingRack.Devices?.length || 0} +
+
+ + + + + +
+ 机柜高度 +
+ {viewingRack.height}U +
+
+ + +
+ 可用U位 +
+ {viewingRack.height - (viewingRack.Devices?.length || 0)}U +
+
+ + + +
+ 功率使用情况 +
+ +
+
+ +
+ + +
+ + )} + + - +
+
导入机柜
} @@ -543,13 +1046,13 @@ function RackManagement() { body: { padding: '24px' }, header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' } }} - style={{ borderRadius: '16px', overflow: 'hidden' }} + style={{ borderRadius: '16px' }} > {!isImporting && !importResult ? (
-
- +

Excel文件格式要求:

-
    @@ -577,18 +1080,18 @@ function RackManagement() {
  • 高度和功率必须是数字格式
- -
-
- + -
)} + +
+ )}
- ); } -export default React.memo(RackManagement); \ No newline at end of file +export default React.memo(RackManagement); diff --git a/frontend/src/pages/RackVisualization.jsx b/frontend/src/pages/RackVisualization.jsx index 9a4c73a..a66deef 100644 --- a/frontend/src/pages/RackVisualization.jsx +++ b/frontend/src/pages/RackVisualization.jsx @@ -1,26 +1,174 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { Card, Select, Button, Space, message, Tooltip, Modal, Form, Switch, Checkbox, Input, Badge, Typography } from 'antd'; -import { - ReloadOutlined, - ZoomInOutlined, - ZoomOutOutlined, - RotateRightOutlined, - CloudServerOutlined, - SwitcherOutlined, - DatabaseOutlined, - CloudOutlined, - LaptopOutlined, - MobileOutlined, - PrinterOutlined, - SettingOutlined, - SearchOutlined, - ClearOutlined, - EnvironmentOutlined +import { Card, Select, Button, Space, message, Tooltip, Modal, Form, Switch, Checkbox, Input, Badge, Typography, Row, Col, Empty, Spin } from 'antd'; +import { + ReloadOutlined, ZoomInOutlined, ZoomOutOutlined, + RotateRightOutlined, CloudServerOutlined, SwitcherOutlined, + DatabaseOutlined, CloudOutlined, LaptopOutlined, + MobileOutlined, PrinterOutlined, SettingOutlined, + SearchOutlined, ClearOutlined, EnvironmentOutlined, + FilterOutlined, AppstoreOutlined, UnorderedListOutlined, + FullscreenOutlined, CompressOutlined, EyeOutlined } from '@ant-design/icons'; import axios from 'axios'; const { Option } = Select; -const { Text } = Typography; +const { Text, Title } = Typography; + +const designTokens = { + colors: { + primary: { + main: '#667eea', + gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + light: '#8b9ff0', + dark: '#4f5db8' + }, + success: { + main: '#10b981', + gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', + light: '#34d399', + dark: '#047857' + }, + warning: { + main: '#f59e0b', + gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', + light: '#fbbf24', + dark: '#b45309' + }, + error: { + main: '#ef4444', + gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)', + light: '#f87171', + dark: '#b91c1c' + }, + purple: { + main: '#8b5cf6', + gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' + }, + cyan: { + main: '#06b6d4', + gradient: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)' + }, + text: { + primary: '#1e293b', + secondary: '#64748b', + tertiary: '#94a3b8', + inverse: '#ffffff' + }, + background: { + primary: '#ffffff', + secondary: '#f8fafc', + tertiary: '#f1f5f9', + dark: '#1e293b' + }, + border: { + light: '#e2e8f0', + medium: '#cbd5e1', + dark: '#94a3b8' + }, + device: { + server: '#3b82f6', + switch: '#22c55e', + router: '#f59e0b', + storage: '#8b5cf6', + firewall: '#ef4444', + ups: '#14b8a6', + pdu: '#64748b', + other: '#94a3b8' + }, + status: { + normal: '#10b981', + running: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + fault: '#ef4444', + offline: '#6b7280', + maintenance: '#3b82f6' + } + }, + shadows: { + small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)', + large: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)', + xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)', + glow: '0 0 20px rgba(102, 126, 234, 0.3)' + }, + borderRadius: { + small: '6px', + medium: '10px', + large: '16px', + xl: '24px', + round: '50%' + }, + transitions: { + fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)', + normal: '300ms cubic-bezier(0.4, 0, 0.2, 1)', + slow: '500ms cubic-bezier(0.4, 0, 0.2, 1)' + }, + spacing: { + xs: '4px', + sm: '8px', + md: '16px', + lg: '24px', + xl: '32px' + } +}; + +const pageContainerStyle = { + minHeight: '100vh', + background: designTokens.colors.background.secondary, + padding: designTokens.spacing.lg +}; + +const headerStyle = { + marginBottom: designTokens.spacing.lg, + padding: `${designTokens.spacing.lg}px ${designTokens.spacing.xl}px`, + background: designTokens.colors.primary.gradient, + borderRadius: designTokens.borderRadius.large, + boxShadow: designTokens.shadows.large, + color: designTokens.colors.text.inverse +}; + +const cardStyle = { + borderRadius: designTokens.borderRadius.large, + border: 'none', + boxShadow: designTokens.shadows.medium, + background: designTokens.colors.background.primary, + overflow: 'hidden' +}; + +const filterCardStyle = { + borderRadius: designTokens.borderRadius.medium, + border: 'none', + boxShadow: designTokens.shadows.small, + background: designTokens.colors.background.primary, + marginBottom: designTokens.spacing.lg +}; + +const primaryButtonStyle = { + height: '40px', + borderRadius: designTokens.borderRadius.medium, + background: designTokens.colors.primary.gradient, + border: 'none', + boxShadow: designTokens.shadows.medium, + fontWeight: '500', + transition: `all ${designTokens.transitions.normal}` +}; + +const secondaryButtonStyle = { + height: '40px', + borderRadius: designTokens.borderRadius.medium, + border: `1px solid ${designTokens.colors.border.light}`, + background: designTokens.colors.background.primary, + transition: `all ${designTokens.transitions.fast}` +}; + +const statCardStyle = () => ({ + background: 'rgba(255, 255, 255, 0.15)', + borderRadius: designTokens.borderRadius.medium, + padding: `${designTokens.spacing.md}px`, + border: '1px solid rgba(255, 255, 255, 0.2)', + backdropFilter: 'blur(10px)' +}); // 工具函数提取到组件外部,避免每次渲染重复创建 const getDeviceIcon = (deviceType) => { @@ -339,6 +487,30 @@ const initAnimationStyles = () => { white-space: nowrap; font-family: 'JetBrains Mono', 'Roboto Mono', monospace; } + + @media (max-width: 768px) { + .device-tooltip { + font-size: 10px; + padding: 6px 10px; + } + + .device-count-badge { + padding: 4px 10px; + font-size: 11px; + } + } + + @media (max-width: 480px) { + .device-tooltip { + font-size: 9px; + padding: 4px 8px; + } + + .device-count-badge { + padding: 3px 8px; + font-size: 10px; + } + } `; document.head.appendChild(style); return style; @@ -398,56 +570,6 @@ class ErrorBoundary extends React.Component { } function RackVisualization() { - const pageHeaderStyle = { - marginBottom: '24px', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - flexWrap: 'wrap', - gap: '16px' - }; - - const titleStyle = { - fontSize: '24px', - fontWeight: '700', - margin: 0, - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', - WebkitBackgroundClip: 'text', - WebkitTextFillColor: 'transparent', - backgroundClip: 'text' - }; - - const primaryButtonStyle = { - height: '40px', - borderRadius: '8px', - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', - border: 'none', - boxShadow: '0 4px 12px rgba(102, 126, 234, 0.35)', - fontWeight: '500', - transition: 'all 0.3s ease' - }; - - const secondaryButtonStyle = { - height: '40px', - borderRadius: '8px', - border: '1px solid #e8e8e8', - transition: 'all 0.3s ease' - }; - - const cardStyle = { - borderRadius: '16px', - border: 'none', - boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)', - overflow: 'hidden' - }; - - const searchCardStyle = { - borderRadius: '12px', - border: 'none', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)', - marginBottom: '20px' - }; - const [racks, setRacks] = useState([]); const [selectedRack, setSelectedRack] = useState(null); const [selectedRoom, setSelectedRoom] = useState(null); @@ -1166,24 +1288,69 @@ function RackVisualization() { // 无需额外的初始化,CSS 3D变换直接在JSX中实现 return ( -
-
-

- - 机柜可视化 -

- +
+
+
+
+ + <CloudServerOutlined style={{ marginRight: designTokens.spacing.sm, fontSize: '28px' }} /> + 机柜可视化 + + + 实时监控机房机柜设备分布与状态 + +
+ +
+
+ 机柜总数 +
{racks.length}
+
+ + +
+ 设备总数 +
+ {racks.reduce((sum, rack) => sum + (rack.deviceCount || 0), 0)} +
+
+ + +
+ 在线设备 +
+ {devices.filter(d => d.status === 'running' || d.status === 'normal').length} +
+
+ + + + + + +
handleSearch(e.target.value)} onPressEnter={(e) => handleSearch(e.target.value)} - style={{ width: 220, height: '40px', borderRadius: '8px' }} + style={{ width: 240, height: '40px', borderRadius: designTokens.borderRadius.medium }} allowClear prefix={} suffix={ searchMatchCount > 0 ? ( - + ) : null } /> @@ -1197,7 +1364,7 @@ function RackVisualization() { )} ))} - - - -
- + +
+ {searchKeyword && ( - -
+
- 0 ? '#52c41a' : '#ff4d4f' + - 0 ? '#135200' : '#cf1322', margin: 0 }}> - {searchResults.length > 0 - ? `找到 ${searchResults.length} 个设备` + + {searchResults.length > 0 + ? `找到 ${searchResults.length} 个设备` : searching ? '搜索中...' : '未找到匹配的设备'} {searching && ( - + )} {searchResults.length > 0 && ( - + {searchResults.slice(0, 5).map(result => { const isCurrentRack = selectedRack && result.rackId === selectedRack.rackId; return ( - {backgroundType === 'image' && ( - + { const image = e.target.value; setBackgroundImage(image); @@ -1364,13 +1543,13 @@ function RackVisualization() { const formData = new FormData(); formData.append('file', file); formData.append('type', 'background'); - + const response = await axios.post('/api/background/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }); - + if (response.data && response.data.path) { setBackgroundImage(response.data.path); message.success('背景图片上传成功'); @@ -1392,7 +1571,7 @@ function RackVisualization() {
`共 ${total} 条记录` - }} - style={tableStyle} - rowClassName={() => 'table-row'} - /> +
+
+ } + value={searchKeyword} + onChange={(e) => setSearchKeyword(e.target.value)} + style={searchInputStyle} + allowClear + /> + +
+
+ {selectedRoomIds.length > 0 && ( + + )} + + +
+
+ +
+ + +
+ + {viewMode === 'table' ? ( +
`共 ${total} 条记录` + }} + scroll={{ x: 1000 }} + rowClassName={() => 'table-row'} + /> + ) : ( + + {filteredRooms.length > 0 ? ( + filteredRooms.map(room => ( + + { + if (selectedRoomIds.includes(id)) { + setSelectedRoomIds(selectedRoomIds.filter(rid => rid !== id)); + } else { + setSelectedRoomIds([...selectedRoomIds, id]); + } + }} + /> + + )) + ) : ( + + + + )} + + )} - +
+ }} /> {editingRoom ? '编辑机房' : '添加机房'}
} @@ -305,61 +708,60 @@ function RoomManagement() { destroyOnHidden styles={{ body: { padding: '24px' }, - header: { - borderBottom: '1px solid #f0f0f0', - padding: '16px 24px', - marginBottom: '0' - } - }} - style={{ - borderRadius: '16px', - overflow: 'hidden' + header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' } }} + style={{ borderRadius: '16px' }} > -
- - - - - - - + + +
+ + + + + + + + + + - + } placeholder="请输入机房位置" style={{ borderRadius: '8px' }} /> - - - - - - - + + + + + + + + + + + + - + - - - - - - +
+ + +
+ + + 机房详情 - {viewingRoom?.name} + + } + open={drawerVisible} + onClose={() => setDrawerVisible(false)} + width={480} + styles={{ + header: { borderBottom: '1px solid #f0f0f0' }, + body: { padding: '24px' } + }} + > + {viewingRoom && ( +
+
+
+ +
+
+ {viewingRoom.name} +
+
+ {viewingRoom.roomId} +
+
+
+ + {statusConfig[viewingRoom.status].text} + +
+
+ 位置 +
+ + {viewingRoom.location} +
+
+ +
+
+ 面积 +
+ {viewingRoom.area} ㎡ +
+
+ + +
+ 容量 +
+ {viewingRoom.capacity} 机柜 +
+
+ + +
+ 机柜使用情况 +
+ +
+
+ + {viewingRoom.description && ( +
+ 描述 +
+ {viewingRoom.description} +
+
+ )} + +
+ + +
+ + )} + ); } -export default RoomManagement; \ No newline at end of file +export default RoomManagement;