diff --git a/.gitignore b/.gitignore index 8889c85..23fe28e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ lerna-debug.log* .vscode/ .idea/ .trae/ +.vercel/ *.swp *.swo *~ diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..a48e5d3 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,7 @@ +node_modules +build +dist +.git +.trae +.log +.figma \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 473cc54..c4d7717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ 所有版本变更记录按时间倒序排列。 +## [1.1.0] - 2026-01-26 + +### 优化 + +#### 3D可视化性能优化 +- 优化 Scene.jsx 渲染配置,降低设备像素比至 [1, 1.2] +- 减小阴影贴图尺寸至 [1024, 1024] +- 移除 ContactShadows 组件以减少阴影计算 +- 简化光源配置,移除冗余 pointLight +- 优化环境光分辨率配置 + +#### 设备模型优化 +- 实现性能模式(PERFORMANCE_MODE)以简化设备细节 +- 添加设备滑轨动画控制开关 +- 设备弹出动画默认关闭,可通过界面开关启用 +- 优化设备状态指示灯渲染性能 + +#### LOD(多级细节)系统 +- 实现 LOD 管理器,根据相机距离自动切换设备细节级别 +- 高细节模式:完整设备模型,包含所有端口和细节 +- 中等细节模式:简化设备模型,使用 InstancedMesh 渲染端口 +- 低细节模式:极简设备模型,仅保留基本轮廓和状态灯 +- 修复 LOD 切换时设备位置偏移问题 + +#### 交互体验改进 +- 添加设备弹出动画开关控制 +- 优化设备悬停和点击交互响应 +- 改进视角控制流畅度 + +### 修复 + +- 修复 AnimationManager 导入错误,移除对不存在文件的引用 +- 修复设备在缩放时位置偏移的问题 +- 修复 LOD 模型中状态灯位置计算错误 +- 修复 LODManager 中的几何体参数错误 + +--- + ## [1.0.0] - 2026-01-21 ### 新增功能 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 30fe6d5..758202f 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,163 +1,76 @@ # IDC设备管理系统 - 安装部署指南 -[![https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Node.js](https://img.shields.io/badge/Node.js-%3E%3D14.0.0-brightgreen.svg)](https://nodejs.org/) -[![React](https://img.shields.io/badge/React-18.2.0-blue.svg)](https://reactjs.org/) -[![Status](https://img.shields.io/badge/Status-Production%20Ready-success.svg)]() - -## 📋 目录 - -- [环境要求](#环境要求) -- [快速开始](#快速开始) -- [详细安装步骤](#详细安装步骤) - - [开发环境部署](#开发环境部署) -- [生产部署](#生产部署) -- [数据库配置](#数据库配置) -- [监控和维护](#监控和维护) -- [常见问题解决](#常见问题解决) - ---- - -## 🖥️ 环境要求 - -### 开发环境 -- **Node.js**: ≥14.0.0 -- **npm**: ≥6.0.0 或 **yarn**: ≥1.22.0 -- **操作系统**: Windows 10/11, macOS 10.14+, Linux (Ubuntu 18.04+) -- **内存**: 最小 4GB RAM -- **硬盘**: 最小 2GB 可用空间 - -### 生产环境 -- **Node.js**: ≥14.0.0 -- **npm**: ≥6.0.0 或 **yarn**: ≥1.22.0 -- **Web服务器**: Nginx (推荐) 或 Apache -- **操作系统**: Linux (Ubuntu 18.04+, CentOS 7+, RHEL 7+) -- **内存**: 最小 8GB RAM (推荐 16GB+) -- **硬盘**: 最小 10GB 可用空间 (SSD 推荐) -- **SSL证书**: Let's Encrypt (推荐) 或其他CA证书 - ---- - -## ⚡ 快速开始 +## 快速开始 ### 1. 克隆项目 -**方式一:从 GitHub 克隆(推荐)** +**方式一:从GitHub克隆(推荐)** ```bash git clone https://github.com/gituib/idc_assest.git cd idc_assest ``` -**方式二:从 Gitee 克隆** +**方式二:从Gitee克隆(国内访问更快)** ```bash git clone https://gitee.com/zhang1106/idc_assest.git cd idc_assest ``` -### 2. 安装后端依赖 +### 2. 安装依赖 + ```bash -cd backend -npm install +cd backend && npm install +cd ../frontend && npm install ``` -### 3. 安装前端依赖 -```bash -cd ../frontend -npm install -``` +### 3. 启动服务 -### 4. 启动服务 ```bash -# 启动后端服务(端口8000) +# 后端(端口8000) cd backend && npm run dev -# 启动前端服务(端口3000)- 新终端 +# 前端(端口3000)- 新终端 cd frontend && npm run dev ``` **访问地址**: -- 前端应用:http://localhost:3000 +- 前端:http://localhost:3000 - 后端API:http://localhost:8000/api -- 健康检查:http://localhost:8000/health --- -## 📖 详细安装步骤 +## 环境要求 -> **📝 安装说明**: -> 本文档将部署分为两个阶段: -> 1. **开发环境部署**:在本地机器上配置完整的开发和测试环境 -> 2. **生产环境部署**:在Linux服务器上配置生产环境,通过域名访问 +| 项目 | 要求 | +|------|------| +| Node.js | ≥14.0.0 | +| npm | ≥6.0.0 | +| 操作系统 | Windows 10/11、macOS、Linux | +| 内存 | 开发:4GB+ / 生产:8GB+ | -> **🎯 建议路径**: -> - **开发人员**:只需要阅读"开发环境部署"部分 -> - **运维人员**:需要阅读完整文档,包括"生产部署"部分 +--- -### 开发环境部署 +## 开发环境部署 ### 后端配置 -#### 1. 进入后端目录 +#### 1. 环境变量 + ```bash cd backend -``` - -#### 2. 安装依赖包 -```bash -npm install -``` - -#### 3. 配置环境变量 -项目支持使用 `.env` 文件进行配置。请按以下步骤操作: - -```bash -# 复制环境变量示例文件 cp .env.example .env - -# 编辑 .env 文件,修改配置 -# 可以使用任意文本编辑器,如: -# Windows: notepad .env -# macOS: nano .env -# 或IDE: 在编辑器中打开 .env 文件 ``` -**重要说明**: -- `.env` 文件包含了所有可配置的选项和详细注释 -- 每个配置项都有默认值,默认情况下可以直接使用 -- 如需修改配置,请编辑 `.env` 文件中的相应值 -- 建议开发时使用 `NODE_ENV=development`,部署时使用 `NODE_ENV=production` +#### 2. 数据库配置 -**配置项说明**: +**默认配置(推荐)**:使用SQLite,无需额外配置 - **服务器配置**: - - `PORT`:服务器端口(默认8000) - - `NODE_ENV`:运行环境 - - `development`:开发模式,详细日志便于调试 - - `production`:生产模式,性能优化,减少日志输出 - - **数据库配置**: - - `DB_TYPE`:数据库类型(默认 sqlite,可选 mysql) - - `sqlite`:零配置嵌入式数据库,适合开发和小规模应用 - - `mysql`:关系型数据库,适合生产环境和大规模应用 - - `DB_PATH`:SQLite数据库文件路径(默认 ./idc_management.db) - - `MYSQL_HOST`:MySQL服务器地址(默认 localhost) - - `MYSQL_PORT`:MySQL端口(默认 3306) - - `MYSQL_USERNAME`:MySQL用户名(默认 root) - - `MYSQL_PASSWORD`:MySQL密码 - - `MYSQL_DATABASE`:MySQL数据库名(默认 idc_management) - -#### 4. 数据库配置 - -**默认配置(推荐)**:使用SQLite数据库,无需额外配置 - -**MySQL配置**:如需使用MySQL数据库,请按以下步骤操作: - -1. 确保MySQL服务已安装并运行 -2. 创建数据库: +**MySQL配置**: ```sql CREATE DATABASE idc_management CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ``` -3. 在 `.env` 文件中修改配置: + +修改 `.env` 文件: ```env DB_TYPE=mysql MYSQL_HOST=localhost @@ -167,31 +80,24 @@ MYSQL_PASSWORD=your_password MYSQL_DATABASE=idc_management ``` -#### 5. 启动后端服务 +#### 3. 启动后端 + ```bash -# 开发模式(自动重启) npm run dev - -# 生产模式 -npm start ``` -**后端服务将在 http://localhost:8000 启动**(或配置的端口号) - ### 前端配置 -#### 1. 进入前端目录 +#### 1. 启动开发服务器 + ```bash cd frontend +npm run dev ``` -#### 2. 安装依赖包 -```bash -npm install -``` +#### 2. 修改API地址(如需要) -#### 3. 配置API地址 -项目使用Vite代理配置,API地址在 `frontend/vite.config.js` 中配置: +编辑 `vite.config.js`: ```javascript proxy: { '/api': { @@ -200,246 +106,89 @@ proxy: { } } ``` -如需修改后端地址,更新 `vite.config.js` 中的 `target` 字段。 - -#### 4. 启动前端开发服务器 -```bash -npm run dev -``` - -**前端应用将在 http://localhost:3000 启动** --- -> **✅ 开发环境部署完成** -> -> 至此,开发环境已完全配置完成。您可以: -> - 前端开发:http://localhost:3000 -> - 后端API:http://localhost:8000/api -> - 后端健康检查:http://localhost:8000/health -> -> **接下来**:如果您需要部署到生产环境,请继续阅读下一节"生产部署"。 +## 生产环境部署 ---- +### 后端部署 -## 🚀 生产部署 +#### 1. 配置环境变量 -> **📋 生产部署说明**: -> - **独立部署**:生产部署在专门的Linux服务器上进行,与开发环境完全分离 -> - **部署地址**:将通过域名或IP访问,如 `http://your-domain.com` -> - **安全要求**:生产环境有更严格的安全配置要求 -> - **前置条件**:您需要拥有Linux服务器的root权限 -> -> **⚠️ 部署前必读**:生产部署涉及服务器安全,请确保在安全环境下操作,并及时更新安全配置。 - -#### 部署准备 - -1. **环境检查** ```bash -# 检查系统环境 -node --version # 确保 Node.js ≥ 14.0 -npm --version # 确保 npm 可用 - -# 检查服务器端口占用 -netstat -tulpn | grep :8000 -netstat -tulpn | grep :80 -``` - -2. **创建部署目录** -```bash -# 创建应用目录 -mkdir -p /var/www/idc_assest -mkdir -p /var/log/idc_assest -mkdir -p /etc/nginx/sites-available -mkdir -p /etc/nginx/sites-enabled -``` - -#### 后端部署 - -1. **设置生产环境配置** -```bash -# 进入后端目录 cd backend - -# 复制并编辑环境配置文件 cp .env.example .env +``` -# 编辑生产环境配置(重要!) -# 设置生产模式 +编辑 `.env` 文件: +```env NODE_ENV=production - -# 设置安全端口(可选) PORT=8000 - -# 数据库配置(推荐使用MySQL) DB_TYPE=mysql MYSQL_HOST=localhost MYSQL_PORT=3306 -MYSQL_USERNAME=idc_user # 创建专用数据库用户 -MYSQL_PASSWORD=secure_password # 使用强密码 +MYSQL_USERNAME=idc_user +MYSQL_PASSWORD=secure_password MYSQL_DATABASE=idc_management ``` -2. **安装生产依赖** -```bash -# 安装生产依赖(只安装生产环境需要的包) -npm install --only=production +#### 2. 安装生产依赖 -# 验证关键依赖是否安装成功 -ls node_modules | grep -E "(express|sequelize|mysql2)" +```bash +npm install --only=production ``` -3. **配置数据库** +#### 3. 配置数据库 + ```sql --- 连接MySQL(使用root用户) -mysql -u root -p - --- 创建专用数据库 CREATE DATABASE idc_management CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - --- 创建专用用户(安全最佳实践) CREATE USER 'idc_user'@'localhost' IDENTIFIED BY 'secure_password'; GRANT ALL PRIVILEGES ON idc_management.* TO 'idc_user'@'localhost'; FLUSH PRIVILEGES; -EXIT; ``` -4. **安装并配置PM2(进程管理器)** +#### 4. 使用PM2管理进程 + ```bash -# 全局安装PM2 npm install -g pm2 - -# 启动应用 pm2 start server.js --name "idc-backend" --env production - -# 设置开机自启 pm2 startup pm2 save - -# 查看运行状态 -pm2 status -pm2 logs idc-backend ``` -5. **配置防火墙** -```bash -# Ubuntu/Debian -sudo ufw allow 22 # SSH -sudo ufw allow 80 # HTTP -sudo ufw allow 443 # HTTPS -sudo ufw enable +### 前端部署 -# CentOS/RHEL -sudo firewall-cmd --permanent --add-service=ssh -sudo firewall-cmd --permanent --add-service=http -sudo firewall-cmd --permanent --add-service=https -sudo firewall-cmd --reload -``` +#### 1. 构建生产版本 -#### 前端部署 - -1. **构建生产版本** ```bash cd frontend - -# 安装依赖 npm install - -# 构建生产版本(会生成优化的静态文件) npm run build - -# 验证构建结果 -ls -la dist/ ``` -2. **部署到Web服务器** -```bash -# 将构建文件复制到Web目录 -sudo cp -r dist/* /var/www/idc-frontend/ +#### 2. 部署静态文件 -# 设置正确的文件权限 +```bash +sudo cp -r dist/* /var/www/idc-frontend/ sudo chown -R www-data:www-data /var/www/idc-frontend sudo chmod -R 755 /var/www/idc-frontend ``` -#### Nginx配置 +### Nginx配置 + +#### 1. 创建配置文件 -1. **创建站点配置文件** ```bash sudo nano /etc/nginx/sites-available/idc_assest ``` -2. **编辑Nginx配置** +#### 2. 配置内容 + ```nginx -# 主配置 server { listen 80; - server_name your-domain.com www.your-domain.com; + server_name your-domain.com; - # 安全头部 - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; - add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; - - # 前端静态文件 - root /var/www/idc-frontend; - index index.html; - - # 静态资源缓存 - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - - # 前端路由(SPA支持) - location / { - try_files $uri $uri/ /index.html; - } - - # API代理 - location /api { - proxy_pass http://127.0.0.1:8000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_cache_bypass $http_upgrade; - - # 超时设置 - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - } - - # 日志配置 - access_log /var/log/nginx/idc_assest-access.log; - error_log /var/log/nginx/idc_assest-error.log; -} - -# HTTPS配置(推荐) -server { - listen 443 ssl http2; - server_name your-domain.com www.your-domain.com; - - # SSL证书配置(使用Let's Encrypt) - ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; - - # SSL安全配置 - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384; - ssl_prefer_server_ciphers off; - ssl_session_cache shared:SSL:10m; - - # HSTS - add_header Strict-Transport-Security "max-age=63072000" always; - - # 其他配置同HTTP root /var/www/idc-frontend; index index.html; @@ -450,1592 +199,209 @@ server { location /api { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_cache_bypass $http_upgrade; } } - -# HTTP重定向到HTTPS -server { - listen 80; - server_name your-domain.com www.your-domain.com; - return 301 https://$server_name$request_uri; -} ``` -3. **启用站点并重启Nginx** +#### 3. 启用配置 + ```bash -# 启用站点 sudo ln -s /etc/nginx/sites-available/idc_assest /etc/nginx/sites-enabled/ - -# 测试配置 sudo nginx -t - -# 重启Nginx sudo systemctl restart nginx -sudo systemctl enable nginx ``` -#### SSL证书配置(推荐) +### SSL证书(可选) -1. **安装Certbot** ```bash -# Ubuntu/Debian -sudo apt update sudo apt install certbot python3-certbot-nginx - -# CentOS/RHEL -sudo yum install certbot python3-certbot-nginx -``` - -2. **获取SSL证书** -```bash -# 自动配置SSL证书 -sudo certbot --nginx -d your-domain.com -d www.your-domain.com - -# 设置自动续期 -sudo crontab -e -# 添加以下行: -0 12 * * * /usr/bin/certbot renew --quiet +sudo certbot --nginx -d your-domain.com ``` --- -## 🗄️ 数据库配置 +## 数据库配置 -### 数据库选择建议 +### SQLite(开发环境) -| 数据库类型 | 适用场景 | 优势 | 劣势 | -|------------|----------|------|------| -| **SQLite** | 开发环境、小规模应用、个人使用 | 零配置、无需安装、数据文件便携 | 并发能力有限、大数据量性能差 | -| **MySQL** | 生产环境、企业级应用、大规模数据 | 高性能、并发处理能力强、数据完整性好 | 需要额外配置和维护 | +- 默认配置,无需额外设置 +- 数据库文件:`backend/idc_management.db` -### SQLite配置(推荐开发使用) +### MySQL(生产环境) -1. **默认配置** -```env -# .env 文件中保持默认配置 -DB_TYPE=sqlite -DB_PATH=./idc_management.db -NODE_ENV=development -``` +#### 创建数据库 -2. **文件位置管理** -```bash -# 备份SQLite数据库 -mkdir -p backup -cp backend/idc_management.db backup/database_$(date +%Y%m%d_%H%M%S).db - -# 查看数据库信息 -sqlite3 backend/idc_management.db ".schema" -sqlite3 backend/idc_management.db ".tables" -``` - -### MySQL配置(推荐生产使用) - -1. **创建生产数据库** ```sql --- 创建专用数据库 CREATE DATABASE idc_management CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` --- 创建专用用户 -CREATE USER 'idc_prod_user'@'localhost' IDENTIFIED BY 'secure_password_123!'; -GRANT ALL PRIVILEGES ON idc_management.* TO 'idc_prod_user'@'localhost'; +#### 创建用户 + +```sql +CREATE USER 'idc_user'@'localhost' IDENTIFIED BY 'secure_password'; +GRANT ALL PRIVILEGES ON idc_management.* TO 'idc_user'@'localhost'; FLUSH PRIVILEGES; ``` -2. **配置生产环境变量** -```env -# .env 文件 -NODE_ENV=production -DB_TYPE=mysql -MYSQL_HOST=localhost -MYSQL_PORT=3306 -MYSQL_USERNAME=idc_prod_user -MYSQL_PASSWORD=secure_password_123! -MYSQL_DATABASE=idc_management +### 数据备份 + +#### SQLite备份 + +```bash +cp backend/idc_management.db backup/database_$(date +%Y%m%d).db ``` -### 数据备份与恢复 +#### MySQL备份 + +```bash +mysqldump -u idc_user -p idc_management > backup/database_$(date +%Y%m%d).sql +``` + +#### 自动备份脚本 -#### 1. 自动备份脚本 ```bash #!/bin/bash -# backup_database.sh - 自动数据库备份脚本 - -# 设置备份目录 BACKUP_DIR="/var/backups/idc_assest" DATE=$(date +%Y%m%d_%H%M%S) -BACKUP_FILE="$BACKUP_DIR/database_$DATE" - -# 创建备份目录 mkdir -p $BACKUP_DIR -# SQLite备份 -if [ -f "backend/idc_management.db" ]; then - cp backend/idc_management.db "$BACKUP_FILE.db" - echo "SQLite备份完成: $BACKUP_FILE.db" -fi - # MySQL备份 -mysqldump -u idc_prod_user -pYourPassword123! idc_management > "$BACKUP_FILE.sql" -if [ $? -eq 0 ]; then - echo "MySQL备份完成: $BACKUP_FILE.sql" -fi - -# 压缩备份文件 -gzip "$BACKUP_FILE.db" -gzip "$BACKUP_FILE.sql" +mysqldump -u idc_user -pYourPassword idc_management > "$BACKUP_DIR/database_$DATE.sql" +gzip "$BACKUP_DIR/database_$DATE.sql" # 清理30天前的备份 find $BACKUP_DIR -name "database_*.gz" -mtime +30 -delete - -echo "备份任务完成" ``` -#### 2. 定时任务配置 +添加到crontab: ```bash -# 添加到crontab crontab -e - -# 每天凌晨2点执行备份 -0 2 * * * /path/to/backup_database.sh - -# 每周日凌晨3点执行完整备份 -0 3 * * 0 /path/to/full_backup.sh -``` - -#### 3. 数据恢复 - -**SQLite恢复**: -```bash -# 停止应用 -pm2 stop idc-backend - -# 恢复数据库 -cp backup/database_20240101.db backend/idc_management.db - -# 重启应用 -pm2 start idc-backend -``` - -**MySQL恢复**: -```bash -# 停止应用 -pm2 stop idc-backend - -# 恢复数据库 -gunzip backup/database_20240101.sql.gz -mysql -u idc_prod_user -p idc_management < backup/database_20240101.sql - -# 重启应用 -pm2 start idc-backend -``` - -### 监控和维护 - -#### 1. 数据库健康检查 -```bash -#!/bin/bash -# health_check.sh - 数据库健康检查 - -# 检查SQLite -if [ -f "backend/idc_management.db" ]; then - echo "SQLite数据库文件存在" - sqlite3 backend/idc_management.db "PRAGMA integrity_check;" -else - echo "SQLite数据库文件不存在" -fi - -# 检查MySQL -mysql -u idc_prod_user -pYourPassword123! -e "SELECT 1;" idc_management > /dev/null 2>&1 -if [ $? -eq 0 ]; then - echo "MySQL连接正常" -else - echo "MySQL连接失败" -fi -``` - -#### 2. 性能监控 -```sql --- MySQL性能监控查询 -SHOW PROCESSLIST; -SHOW ENGINE INNODB STATUS; -SELECT * FROM information_schema.innodb_trx; -SELECT * FROM information_schema.innodb_locks; +# 每天凌晨2点执行 +0 2 * * * /path/to/backup_script.sh ``` --- -## 🔧 常见问题解决 +## 常见问题 + +### 端口冲突 -### 1. 端口冲突 ```bash # 检查端口占用 netstat -tulpn | grep :8000 -netstat -tulpn | grep :3000 # 修改端口 -PORT=8001 npm run dev # 后端 -npm run dev -- --port 3001 # 前端 +PORT=8001 npm run dev ``` -### 2. 权限问题 -```bash -# Linux/macOS权限修复 -chmod +x backend/server.js -chown -R $USER:$USER backend/ -``` +### 依赖安装失败 -### 3. 依赖安装失败 ```bash -# 清理缓存重新安装 npm cache clean --force rm -rf node_modules package-lock.json npm install ``` -### 4. 数据库连接失败 +### 数据库连接失败 -**SQLite连接问题**: -- 检查数据库文件权限 -- 确保有写入权限 -- 验证磁盘空间充足 +**SQLite**: +- 检查文件权限 +- 确保磁盘空间充足 -**MySQL连接问题**: -- 检查MySQL服务是否运行:`systemctl status mysql` 或 `netstat -tulpn | grep :3306` -- 验证MySQL连接参数是否正确 -- 确保数据库已创建:`CREATE DATABASE idc_management;` -- 检查MySQL用户权限 -- 查看MySQL错误日志 +**MySQL**: +- 检查服务状态:`systemctl status mysql` +- 验证连接参数 +- 确认数据库已创建 + +### PM2命令 + +```bash +pm2 status # 查看状态 +pm2 logs idc-backend # 查看日志 +pm2 restart idc-backend # 重启服务 +pm2 stop idc-backend # 停止服务 +pm2 delete idc-backend # 删除服务 +``` + +### Nginx命令 + +```bash +sudo nginx -t # 测试配置 +sudo systemctl restart nginx # 重启服务 +sudo systemctl status nginx # 查看状态 +``` --- -## 📊 监控和维护 +## 更新升级 + +### 1. 备份数据 -### 服务监控 ```bash -# 检查服务状态 -curl http://localhost:8000/health - -# 查看日志 -pm2 logs idc-backend +mysqldump -u idc_user -p idc_management > backup.sql ``` -### 数据迁移 -```bash -# 导出数据 -node scripts/export-data.js +### 2. 拉取最新代码 -# 导入数据 -node scripts/import-data.js backup/data.json +**从GitHub拉取**: +```bash +git pull origin master +``` + +**从Gitee拉取**: +```bash +git pull origin master +``` + +### 3. 更新依赖 + +```bash +cd backend && npm install +cd ../frontend && npm install && npm run build +``` + +### 4. 重启服务 + +```bash +pm2 restart idc-backend +sudo systemctl restart nginx +``` + +--- + +## 监控维护 + +### 查看日志 + +```bash +pm2 logs idc-backend +tail -f /var/log/nginx/idc_assest-error.log +``` + +### 健康检查 + +```bash +curl http://localhost:8000/health ``` ### 性能优化 -#### 1. PM2性能配置 +**PM2配置**(`ecosystem.config.js`): ```javascript -// ecosystem.config.js module.exports = { apps: [{ name: 'idc-backend', script: 'server.js', - instances: 'max', // 使用所有CPU核心 + instances: 'max', exec_mode: 'cluster', - env: { - NODE_ENV: 'production', - PORT: 8000 - }, - max_memory_restart: '1G', // 内存超过1G自动重启 - node_args: '--max-old-space-size=1024' + max_memory_restart: '1G' }] }; ``` -#### 2. 数据库优化 -```sql --- MySQL性能优化 -SET GLOBAL innodb_buffer_pool_size = 1073741824; -- 1GB -SET GLOBAL query_cache_size = 67108864; -- 64MB - --- 创建索引优化查询 -CREATE INDEX idx_device_rack ON devices(rackId); -CREATE INDEX idx_device_type ON devices(deviceType); -``` - --- -## 🔒 安全配置 - -### 1. 防火墙配置 -```bash -# Ubuntu/Debian UFW -sudo ufw default deny incoming -sudo ufw default allow outgoing -sudo ufw allow ssh -sudo ufw allow 80/tcp -sudo ufw allow 443/tcp -sudo ufw enable - -# CentOS/RHEL firewalld -sudo firewall-cmd --set-default-zone=public -sudo firewall-cmd --permanent --add-service=ssh -sudo firewall-cmd --permanent --add-service=http -sudo firewall-cmd --permanent --add-service=https -sudo firewall-cmd --reload -``` - -### 2. 系统安全加固 -```bash -# 更新系统 -sudo apt update && sudo apt upgrade -y - -# 配置fail2ban防止暴力破解 -sudo apt install fail2ban -sudo systemctl enable fail2ban -sudo systemctl start fail2ban - -# 配置SSH密钥登录 -ssh-keygen -t rsa -b 4096 -ssh-copy-id user@server-ip -``` - -### 3. 应用程序安全 -```env -# .env 生产环境安全配置 -NODE_ENV=production -PORT=8000 - -# 设置强密码 -DATABASE_PASSWORD=ComplexPassword123!@# - -# 启用HTTPS(使用反向代理) -FORCE_HTTPS=true - -# 设置会话密钥 -SESSION_SECRET=random_session_secret_key_here -``` - ---- - -## 📞 技术支持 - -如果在安装部署过程中遇到问题,请: - -1. **查看日志文件** - - 后端日志:`pm2 logs idc-backend` - - Nginx日志:`tail -f /var/log/nginx/idc_assest-error.log` - -2. **检查服务状态** - ```bash - pm2 status - sudo systemctl status nginx - ``` - -3. **验证配置文件** - ```bash - # 测试Nginx配置 - sudo nginx -t - - # 测试数据库连接 - mysql -u idc_prod_user -p -e "SELECT 1;" idc_management - ``` - ---- - -## 更新升级流程 - -### 手动更新步骤 - -#### 1. 备份当前版本 - -```bash -# 创建备份目录 -mkdir -p /var/backups/idc_assest/$(date +%Y%m%d) -cd /var/backups/idc_assest/$(date +%Y%m%d) - -# 备份数据库 -mysqldump -u idc_prod_user -p idc_management > database_backup.sql - -# 备份配置文件 -cp -r /var/www/idc_assest/backend/.env ./ -cp -r /var/www/idc_assest/frontend/.env ./ - -# 备份上传文件 -cp -r /var/www/idc_assest/backend/uploads ./ -``` - -#### 2. 下载最新代码 - -```bash -cd /var/www/idc_assest - -# 方式一:从 GitHub 拉取最新代码(推荐) -git fetch github -git checkout master -git pull github master - -# 方式二:从 Gitee 拉取最新代码 -# git fetch origin -# git checkout master -# git pull origin master -``` - -#### 3. 更新依赖 - -```bash -# 更新后端依赖 -cd backend -npm install -cd .. - -# 更新前端依赖并构建 -cd frontend -npm install -npm run build -cd .. -``` - -#### 4. 重启服务 - -```bash -# 重启后端 -pm2 restart idc-backend - -# 重启Nginx -sudo systemctl restart nginx - -# 验证服务 -curl http://localhost:8000/health -``` - -### Docker环境更新 - -```bash -cd /var/www/idc_assest/docker - -# 拉取最新代码 -cd .. -git pull origin main -cd docker - -# 重新构建并启动 -docker-compose down -docker-compose up -d --build - -# 验证服务 -curl http://localhost/health -``` - -### 回滚操作 - -```bash -# 查看历史版本 -cd /var/www/idc_assest -git log --oneline -10 - -# 回滚到指定版本 -git checkout - -# 重新构建 -cd frontend && npm run build && cd .. -cd backend && npm install && cd .. - -# 重启服务 -pm2 restart idc-backend -``` - -### 版本兼容性检查 - -```bash -# 检查Node.js版本 -node --version - -# 检查依赖版本 -cd backend && npm list | grep -E "(express|sequelize|mysql2)" && cd .. -cd frontend && npm list | grep -E "(react|antd|vite)" && cd .. -``` - ---- - -## 📊 系统架构说明 - -### 整体架构图 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 用户访问层 │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ 浏览器 │ │ 移动端 │ │ API客户端 │ │ -│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ -└─────────┼──────────────────┼──────────────────┼───────────────┘ - │ │ │ - └──────────────────┼──────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Web服务层 │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Nginx / Apache │ │ -│ │ • 静态资源服务 • 反向代理 • SSL终端 • 负载均衡 │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└─────────────────────────┬───────────────────────────────────────┘ - │ - ┌───────────────┼───────────────┐ - │ │ │ - ▼ ▼ ▼ -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ 前端应用 │ │ 后端API │ │ 静态资源 │ -│ (React) │ │ (Express) │ │ (Nginx) │ -│ 端口: 3000 │ │ 端口: 8000 │ │ 端口: 80/443 │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ 数据存储层 │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ MySQL │ │ SQLite │ │ 文件存储 │ │ -│ │ 端口: 3306 │ │ 嵌入式 │ │ uploads/ │ │ -│ │ │ │ │ │ │ │ -│ │ • 设备信息 │ │ • 开发环境 │ │ • 设备图片 │ │ -│ │ • 用户数据 │ │ • 快速部署 │ │ • 附件 │ │ -│ │ • 工单记录 │ │ │ │ • 备份文件 │ │ -│ │ • 耗材库存 │ │ │ │ │ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 技术栈版本 - -| 层级 | 技术 | 版本要求 | 用途 | -|------|------|----------|------| -| 前端 | React | 18.2.0+ | UI框架 | -| 前端 | Ant Design | 5.8.6+ | 组件库 | -| 前端 | Vite | 4.4.9+ | 构建工具 | -| 前端 | Three.js | 0.160.0+ | 3D可视化 | -| 后端 | Node.js | 14.0.0+ | 运行时 | -| 后端 | Express | 4.18.2+ | Web框架 | -| 后端 | Sequelize | 6.32.1+ | ORM框架 | -| 数据库 | MySQL | 8.0+ | 主数据库 | -| 数据库 | SQLite | 5.1.6+ | 嵌入式数据库 | -| 服务器 | Nginx | 1.18+ | 反向代理 | -| 进程管理 | PM2 | 5.0+ | 进程管理 | - -### 数据流说明 - -1. **用户请求流程** - - 用户通过浏览器访问系统 - - 请求首先到达Nginx - - Nginx判断请求类型: - - 静态资源:直接返回 - - API请求:转发到后端服务 - - 前端路由:返回index.html - -2. **数据处理流程** - - 后端接收API请求 - - 验证用户身份和权限 - - 通过Sequelize操作数据库 - - 返回JSON响应 - -3. **实时通信** - - WebSocket用于实时告警推送 - - HTTP轮询用于数据刷新 - ---- - -## 🔍 故障排查指南 - -### 常见错误及解决方案 - -#### 1. 后端服务无法启动 - -**错误信息**:Error: listen EADDRINUSE: address already in use :::8000 - -**原因分析**:端口8000已被其他进程占用 - -**解决方案**: -```bash -# 查看占用端口的进程 -netstat -tulpn | grep :8000 -lsof -i :8000 - -# 终止占用进程 -kill -9 - -# 或修改为其他端口 -PORT=8001 npm run dev -``` - -#### 2. 数据库连接失败 - -**错误信息**:SequelizeConnectionError: Access denied for user - -**原因分析**:数据库用户名或密码错误 - -**解决方案**: -```bash -# 检查.env配置 -cat backend/.env | grep -E "(MYSQL|USERNAME|PASSWORD)" - -# 测试数据库连接 -mysql -u idc_user -p -h localhost - -# 检查MySQL服务状态 -sudo systemctl status mysql -sudo systemctl start mysql -``` - -#### 3. 前端构建失败 - -**错误信息**:Error: Cannot find module 'node-sass' - -**原因分析**:依赖安装不完整 - -**解决方案**: -```bash -# 清理并重新安装依赖 -cd frontend -rm -rf node_modules package-lock.json -npm install - -# 检查Node.js版本兼容性 -node --version -``` - -#### 4. Nginx 502 Bad Gateway - -**原因分析**:后端服务未运行或连接超时 - -**解决方案**: -```bash -# 检查后端服务状态 -pm2 status - -# 查看后端日志 -pm2 logs idc-backend - -# 检查Nginx错误日志 -tail -f /var/log/nginx/idc_assest-error.log - -# 测试后端服务 -curl http://127.0.0.1:8000/health -``` - -#### 5. 文件上传失败 - -**错误信息**:Error: ENOENT: no such file or directory - -**原因分析**:上传目录不存在或权限不足 - -**解决方案**: -```bash -# 创建上传目录 -mkdir -p backend/uploads -chmod 755 backend/uploads - -# 检查目录权限 -ls -la backend/ | grep uploads -``` - -#### 6. CORS跨域错误 - -**错误信息**:Access to XMLHttpRequest at '...' from origin '...' has been blocked by CORS policy - -**原因分析**:CORS配置不正确 - -**解决方案**: -```javascript -// 检查backend/server.js中的CORS配置 -const cors = require('cors'); -app.use(cors({ - origin: 'http://your-domain.com', - credentials: true -})); -``` - -### 诊断命令速查表 - -```bash -# 检查端口占用 -netstat -tulpn | grep -E "(80|443|8000|3306)" - -# 检查进程状态 -ps aux | grep -E "(node|nginx|mysql)" - -# 检查磁盘空间 -df -h - -# 检查内存使用 -free -m - -# 检查系统负载 -top -bn1 | head -5 - -# 网络连通性测试 -curl -I http://localhost:8000/health -curl -I http://localhost/api/devices - -# 查看系统日志 -tail -f /var/log/syslog -journalctl -xe - -# Docker诊断(Docker部署) -docker-compose ps -docker-compose logs --tail=100 -docker stats -``` - -### 日志文件位置 - -| 服务 | 日志位置 | -|------|----------| -| 后端(PM2) | `pm2 logs idc-backend` | -| 后端(文件) | `/var/log/idc_assest/backend/` | -| Nginx | `/var/log/nginx/idc_assest-access.log` | -| Nginx | `/var/log/nginx/idc_assest-error.log` | -| MySQL | `/var/log/mysql/error.log` | -| Docker | `docker-compose logs` | - ---- - -## ⚡ 性能优化 - -### 后端性能优化 - -#### 1. PM2集群模式 - -```javascript -// ecosystem.config.js -module.exports = { - apps: [{ - name: 'idc-backend', - script: 'server.js', - instances: 'max', // 使用所有CPU核心 - exec_mode: 'cluster', // 集群模式 - env: { - NODE_ENV: 'production', - PORT: 8000 - }, - max_memory_restart: '1G', // 内存超过1G自动重启 - node_args: '--max-old-space-size=1024', - listen_timeout: 3000, // 监听超时 - kill_timeout: 5000, // 终止超时 - max_restarts: 10, // 最大重启次数 - min_uptime: '10s' // 最小运行时间 - }] -}; -``` - -#### 2. 数据库连接池优化 - -```javascript -// backend/db.js -const sequelize = new Sequelize({ - dialect: 'mysql', - host: process.env.MYSQL_HOST, - port: process.env.MYSQL_PORT, - username: process.env.MYSQL_USERNAME, - password: process.env.MYSQL_PASSWORD, - database: process.env.MYSQL_DATABASE, - pool: { - max: 20, // 最大连接数 - min: 5, // 最小连接数 - acquire: 60000, // 获取连接最大等待时间 - idle: 10000 // 连接空闲最大时间 - }, - logging: false, // 关闭SQL日志 - dialectOptions: { - charset: 'utf8mb4' - } -}); -``` - -#### 3. 缓存策略 - -```javascript -// 使用内存缓存热点数据 -const cache = new Map(); - -// 设备统计缓存(5分钟过期) -function getDeviceStats() { - const cacheKey = 'device_stats'; - const cached = cache.get(cacheKey); - - if (cached && Date.now() - cached.time < 5 * 60 * 1000) { - return cached.data; - } - - const stats = calculateDeviceStats(); - cache.set(cacheKey, { data: stats, time: Date.now() }); - return stats; -} -``` - -### 数据库性能优化 - -#### 1. 创建索引 - -```sql --- 设备表索引 -CREATE INDEX idx_device_rack ON devices(rackId); -CREATE INDEX idx_device_type ON devices(deviceType); -CREATE INDEX idx_device_status ON devices(status); -CREATE INDEX idx_device_created ON devices(createdAt); - --- 工单表索引 -CREATE INDEX idx_ticket_status ON tickets(status); -CREATE INDEX idx_ticket_priority ON tickets(priority); -CREATE INDEX idx_ticket_device ON tickets(deviceId); -CREATE INDEX idx_ticket_created ON tickets(createdAt); - --- 耗材表索引 -CREATE INDEX idx_consumable_category ON consumables(category); -CREATE INDEX idx_consumable_status ON consumables(status); -``` - -#### 2. MySQL配置优化 - -```ini -# /etc/mysql/mysql.conf.d/mysqld.cnf - -[mysqld] -# 缓冲池大小(建议为物理内存的70%) -innodb_buffer_pool_size = 2G - -# 日志文件大小 -innodb_log_file_size = 512M - -# 刷新策略 -innodb_flush_log_at_trx_commit = 2 -innodb_flush_method = O_DIRECT - -# 连接数 -max_connections = 200 - -# 查询缓存(MySQL 8.0已移除) -# query_cache_type = 0 -``` - -### 前端性能优化 - -#### 1. 构建优化 - -```javascript -// vite.config.js -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; - -export default defineConfig({ - plugins: [react()], - build: { - // 开启压缩 - minify: 'terser', - terserOptions: { - compress: { - drop_console: true, - drop_debugger: true - } - }, - // 代码分割 - rollupOptions: { - output: { - manualChunks: { - 'antd': ['antd', '@ant-design/icons'], - 'charts': ['recharts'], - 'three': ['three'] - } - } - }, - // 资源优化 - assetsDir: 'assets', - chunkSizeWarningLimit: 1000 - }, - // 依赖预构建 - optimizeDeps: { - include: ['antd', 'axios', 'react-router-dom'] - } -}); -``` - -#### 2. 路由懒加载 - -```javascript -// App.jsx -import { lazy, Suspense } from 'react'; - -const Dashboard = lazy(() => import('./pages/Dashboard')); -const DeviceManagement = lazy(() => import('./pages/DeviceManagement')); - -// 使用 -}> - - } /> - - -``` - -### Nginx性能优化 - -```nginx -# /etc/nginx/nginx.conf - -worker_processes auto; -worker_rlimit_nofile 65535; - -events { - worker_connections 2048; - use epoll; - multi_accept on; -} - -http { - # 打开文件缓存 - open_file_cache max=10000 inactive=20s; - open_file_cache_valid 30s; - open_file_cache_min_uses 2; - - # Gzip压缩 - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml; - - # 缓冲区优化 - client_body_buffer_size 16K; - client_max_body_size 100M; - proxy_buffer_size 128K; - proxy_buffers 4 256K; - proxy_busy_buffers_size 256K; - - # 连接超时 - keepalive_timeout 65; - keepalive_requests 100; - - # 上游服务器配置 - upstream backend { - server 127.0.0.1:8000; - keepalive 32; - } -} -``` - ---- - -## 📝 日志管理 - -### 日志配置 - -```javascript -// backend/logger.js -const winston = require('winston'); -const path = require('path'); - -const logDir = process.env.LOG_DIR || './logs'; - -const logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'info', - format: winston.format.combine( - winston.format.timestamp({ - format: 'YYYY-MM-DD HH:mm:ss' - }), - winston.format.errors({ stack: true }), - winston.format.json() - ), - defaultMeta: { service: 'idc-backend' }, - transports: [ - // 错误日志 - new winston.transports.File({ - filename: path.join(logDir, 'error.log'), - level: 'error', - maxsize: 10485760, // 10MB - maxFiles: 10 - }), - // 组合日志 - new winston.transports.File({ - filename: path.join(logDir, 'combined.log'), - maxsize: 10485760, - maxFiles: 10 - }), - // 控制台输出 - new winston.transports.Console({ - format: winston.format.combine( - winston.format.colorize(), - winston.format.simple() - ) - }) - ] -}); - -module.exports = logger; -``` - -### 日志轮转配置 - -#### 1. 使用logrotate(Linux) - -```bash -# /etc/logrotate.d/idc_assest -/var/log/idc_assest/*.log { - daily - missingok - rotate 14 - compress - delaycompress - notifempty - create 0640 www-data www-data - sharedscripts - postrotate - pm2 restart idc-backend > /dev/null 2>&1 || true - endscript -} -``` - -#### 2. 使用PM2日志轮转 - -```bash -# 安装pm2-logrotate -pm2 install pm2-logrotate - -# 配置 -pm2 set pm2-logrotate:max_size 50M # 单个文件最大50MB -pm2 set pm2-logrotate:retain 30 # 保留30个文件 -pm2 set pm2-logrotate:compress true # 压缩历史文件 -pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss -``` - -### 日志分析示例 - -```bash -# 查看错误日志 -tail -f /var/log/idc_assest/error.log - -# 统计API响应时间 -grep -o '"duration":[0-9]*' /var/log/idc_assest/combined.log | \ - awk -F: '{sum+=$2; count++} END {print "平均响应时间:", sum/count, "ms"}' - -# 统计用户登录情况 -grep "登录成功" /var/log/idc_assest/combined.log | \ - awk '{print $4}' | sort | uniq -c | sort -rn - -# 查找异常请求 -grep -E "(ERROR|500|401)" /var/log/idc_assest/error.log -``` - ---- - -## 💾 备份与灾难恢复 - -### 备份策略 - -| 备份类型 | 频率 | 保留时间 | 说明 | -|----------|------|----------|------| -| 全量备份 | 每周日凌晨3点 | 4周 | 完整数据库备份 | -| 增量备份 | 每天凌晨2点 | 7天 | 每日变更数据 | -| 实时备份 | 持续 | 永久 | 二进制日志 | -| 配置备份 | 每次变更 | 12个月 | 配置文件和代码 | - -### 自动化备份脚本 - -```bash -#!/bin/bash -# backup_full.sh - 完整备份脚本 - -set -e - -# 配置 -BACKUP_DIR="/var/backups/idc_assest" -DATE=$(date +%Y%m%d_%H%M%S) -BACKUP_FILE="$BACKUP_DIR/full_backup_$DATE" -KEEP_DAYS=30 - -# 创建备份目录 -mkdir -p "$BACKUP_DIR" - -# 1. 备份数据库 -echo "正在备份数据库..." -mysqldump -u idc_prod_user -p"$MYSQL_PASSWORD" \ - --single-transaction \ - --routines \ - --triggers \ - --events \ - idc_management | gzip > "$BACKUP_FILE.sql.gz" - -# 2. 备份配置文件 -echo "正在备份配置文件..." -tar czf "$BACKUP_DIR/config_$DATE.tar.gz" \ - backend/.env \ - nginx/conf.d/ - -# 3. 备份上传文件 -echo "正在备份上传文件..." -tar czf "$BACKUP_DIR/uploads_$DATE.tar.gz" \ - backend/uploads/ - -# 4. 备份代码(排除node_modules) -echo "正在备份代码..." -tar czf "$BACKUP_DIR/code_$DATE.tar.gz" \ - --exclude=node_modules \ - --exclude=dist \ - --exclude=uploads \ - . - -# 5. 清理旧备份 -echo "正在清理旧备份..." -find "$BACKUP_DIR" -name "full_backup_*.sql.gz" -mtime +$KEEP_DAYS -delete -find "$BACKUP_DIR" -name "*.tar.gz" -mtime +$KEEP_DAYS -delete - -# 6. 验证备份 -echo "正在验证备份..." -if [ -f "$BACKUP_FILE.sql.gz" ]; then - gunzip -t "$BACKUP_FILE.sql.gz" && echo "数据库备份验证成功" -fi - -# 7. 生成备份清单 -echo "备份完成,文件列表:" -ls -lh "$BACKUP_DIR"/*"$DATE"* - -# 8. 发送通知(可选) -# curl -X POST "https://hooks.example.com/notify" -d "backup completed" - -echo "备份任务完成:$DATE" -``` - -### 定时任务配置 - -```bash -# crontab配置 -crontab -e - -# 每日增量备份(凌晨2点) -0 2 * * * /var/www/idc_assest/scripts/backup_incremental.sh - -# 每周完整备份(周日凌晨3点) -0 3 * * 0 /var/www/idc_assest/scripts/backup_full.sh - -# 每月清理旧备份(每月1日凌晨4点) -0 4 1 * * /var/www/idc_assest/scripts/cleanup_old_backups.sh -``` - -### 灾难恢复流程 - -#### 1. 数据恢复步骤 - -```bash -# 1. 停止服务 -pm2 stop idc-backend - -# 2. 恢复数据库 -gunzip -c /var/backups/idc_assest/full_backup_20240101_030000.sql.gz | \ - mysql -u idc_prod_user -p idc_management - -# 3. 恢复配置文件 -tar xzf /var/backups/idc_assest/config_20240101.tar.gz -C / - -# 4. 恢复上传文件 -tar xzf /var/backups/idc_assest/uploads_20240101.tar.gz -C / - -# 5. 重启服务 -pm2 restart idc-backend - -# 6. 验证恢复 -curl http://localhost:8000/health -``` - -#### 2. 完整系统恢复 - -```bash -# 1. 创建新服务器 -# 2. 安装必要软件 -# 3. 从Git克隆代码 -# 4. 恢复配置文件 -# 5. 恢复数据库 -# 6. 恢复上传文件 -# 7. 重新安装依赖 -# 8. 重启服务 -``` - -### 备份验证 - -```bash -#!/bin/bash -# verify_backup.sh - 备份验证脚本 - -BACKUP_FILE="$1" - -if [ -z "$BACKUP_FILE" ]; then - echo "用法: $0 <备份文件>" - exit 1 -fi - -echo "正在验证备份文件:$BACKUP_FILE" - -# 检查文件存在 -if [ ! -f "$BACKUP_FILE" ]; then - echo "错误:文件不存在" - exit 1 -fi - -# 检查文件大小(至少1KB) -FILE_SIZE=$(stat -f%z "$BACKUP_FILE" 2>/dev/null || stat -c%s "$BACKUP_FILE") -if [ "$FILE_SIZE" -lt 1024 ]; then - echo "警告:文件大小异常小" -fi - -# 对于SQL备份,验证SQL语法 -if [[ "$BACKUP_FILE" == *.sql.gz ]]; then - echo "验证SQL语法..." - gunzip -c "$BACKUP_FILE" | head -100 | grep -q "INSERT INTO\|CREATE TABLE" - if [ $? -eq 0 ]; then - echo "✓ SQL语法验证通过" - else - echo "✗ SQL语法验证失败" - exit 1 - fi -fi - -# 对于压缩包,验证完整性 -if [[ "$BACKUP_FILE" == *.tar.gz ]]; then - echo "验证压缩包完整性..." - tar -tzf "$BACKUP_FILE" > /dev/null 2>&1 - if [ $? -eq 0 ]; then - echo "✓ 压缩包验证通过" - else - echo "✗ 压缩包验证失败" - exit 1 - fi -fi - -echo "备份验证完成" -``` - ---- - -## 🚨 已部署项目更新指南 - -> **📋 版本更新说明**: -> 本节介绍如何将已部署的生产环境更新到最新版本,包含网卡管理、批量创建端口等新功能。 - -### 更新前准备 - -#### 1. 确认当前版本 -```bash -# 检查最后更新日期 -cd /var/www/idc_assest -git log -1 --format="%cd" --date=short - -# 查看当前分支 -git branch -``` - -#### 2. 备份现有数据(强制操作) -```bash -# 创建备份目录 -BACKUP_DIR="/var/backups/idc_assest/$(date +%Y%m%d_%H%M%S)" -mkdir -p $BACKUP_DIR - -echo "开始备份..." - -# 备份数据库(SQLite) -if [ -f "/var/www/idc_assest/backend/idc_management.db" ]; then - cp /var/www/idc_assest/backend/idc_management.db "$BACKUP_DIR/idc_management.db" - echo "✓ SQLite数据库已备份" -fi - -# 备份数据库(MySQL) -read -p "是否需要备份MySQL数据库? (y/n): " need_mysql -if [ "$need_mysql" = "y" ]; then - read -p "MySQL用户名: " db_user - read -s -p "MySQL密码: " db_pass - echo - mysqldump -u $db_user -p$db_pass idc_management > "$BACKUP_DIR/database.sql" - echo "✓ MySQL数据库已备份" -fi - -# 备份配置文件 -cp /var/www/idc_assest/backend/.env "$BACKUP_DIR/.env" -cp /var/www/idc_assest/frontend/.env* "$BACKUP_DIR/" 2>/dev/null || true -echo "✓ 配置文件已备份" - -# 备份上传文件 -cp -r /var/www/idc_assest/backend/uploads "$BACKUP_DIR/uploads" 2>/dev/null || true -echo "✓ 上传文件已备份" - -echo "" -echo "备份完成,保存在: $BACKUP_DIR" -``` - -### 数据库迁移 - -#### SQLite 数据库更新 - -```bash -cd /var/www/idc_assest/backend - -# 方式一:使用迁移脚本(推荐) -node scripts/migrate-v2.js - -# 预期输出: -# ======================================== -# IDC管理系统 - 数据库迁移脚本 v2.0 -# ======================================== -# 🔍 检测数据库类型... -# 数据库类型: sqlite -# 📋 开始迁移... -# ... -# ✅ 迁移完成! -# ======================================== -# 迁移成功完成!🎉 -# ======================================== -``` - -#### MySQL 数据库更新 - -```bash -cd /var/www/idc_assest/backend - -# 执行迁移脚本 -node scripts/migrate-v2.js - -# 预期输出: -# ======================================== -# IDC管理系统 - 数据库迁移脚本 v2.0 -# ======================================== -# 🔍 检测数据库类型... -# 数据库类型: mysql -# 📋 开始迁移... -# 1. 创建 network_cards 表 -# 2. 为 device_ports 添加 nic_id 字段 -# 3. 创建相关索引 -# 🔄 执行 MySQL 迁移... -# → 创建表: network_cards -# → 检查 nic_id 字段是否存在... -# → 添加 nic_id 字段... -# → 创建 nic_id 索引... -# ✅ 迁移完成! -# ======================================== -# 迁移成功完成!🎉 -# ======================================== -``` - -> **💡 提示**:迁移脚本会自动检测数据库类型并执行相应的迁移操作。 - -### 更新后端代码 - -```bash -cd /var/www/idc_assest - -# 拉取最新代码 -git pull origin master - -# 更新依赖 -cd backend -npm install --only=production - -# 检查新增的文件 -echo "新增的文件:" -git diff --name-only --diff-filter=A HEAD - -# 验证新模型文件 -ls -la models/NetworkCard.js -ls -la routes/networkCards.js -``` - -### 注册新路由 - -检查 `backend/server.js` 是否已包含新路由: - -```javascript -// 确认以下代码存在 -const networkCardsRouter = require('./routes/networkCards'); -app.use('/api/network-cards', networkCardsRouter); -``` - -如果不存在,请手动添加: - -```bash -# 编辑 server.js -nano /var/www/idc_assest/backend/server.js - -# 在合适位置添加(通常在其他 app.use 语句附近) -const networkCardsRouter = require('./routes/networkCards'); -app.use('/api/network-cards', networkCardsRouter); -``` - -### 重启后端服务 - -```bash -# 重启PM2进程 -pm2 restart idc-backend - -# 验证服务状态 -pm2 status idc-backend - -# 查看日志确认无错误 -pm2 logs idc-backend --lines 50 -``` - -### 更新前端代码 - -```bash -cd /var/www/idc_assest/frontend - -# 拉取最新代码 -git pull origin master - -# 更新依赖 -npm install - -# 构建生产版本 -npm run build - -# 验证构建结果 -ls -la dist/ - -# 部署到Web目录 -sudo rm -rf /var/www/idc-frontend/* -sudo cp -r dist/* /var/www/idc-frontend/ -sudo chown -R www-data:www-data /var/www/idc-frontend -``` - -### 验证更新 - -#### 1. API 接口验证 - -```bash -# 测试新API接口 -curl http://localhost:8000/api/network-cards - -# 预期返回:空数组或现有数据 -curl http://localhost:8000/api/device-ports - -# 预期返回:端口数据(可能包含 nic_id 字段) -``` - -#### 2. 前端功能验证 - -访问管理界面,验证以下功能: - -- [ ] **网卡管理** - - [ ] 设备详情中显示"端口与网卡"标签页 - - [ ] 可以创建新网卡 - - [ ] 网卡列表正确显示 - - [ ] 可以删除网卡(需无端口关联) - -- [ ] **端口管理** - - [ ] 可以创建端口时选择所属网卡 - - [ ] 端口按网卡分组显示 - - [ ] 显示未分组的端口 - -- [ ] **批量创建端口** - - [ ] 输入格式如 `1/0/1-1/0/48` 可以创建多个端口 - - [ ] 预览功能正确显示待创建端口数量 - -#### 3. 数据库验证 - -```bash -# 验证新表存在 -sqlite3 /var/www/idc_assest/backend/idc_management.db ".tables" -# 应包含:network_cards - -# 验证新字段 -sqlite3 /var/www/idc_assest/backend/idc_management.db ".schema device_ports" | grep nic_id -# 应显示 nic_id 字段定义 - -# 检查数据 -sqlite3 /var/www/idc_assest/backend/idc_management.db "SELECT COUNT(*) FROM network_cards;" -``` - -### 回滚操作(如果出现问题) - -```bash -cd /var/www/idc_assest - -# 1. 停止服务 -pm2 stop idc-backend - -# 2. 恢复数据库 -# SQLite -cp /var/backups/idc_assest/最新备份目录/idc_management.db backend/idc_management.db - -# 或 MySQL -mysql -u idc_prod_user -p idc_management < /var/backups/idc_assest/最新备份目录/database.sql - -# 3. 恢复代码 -git checkout HEAD@{1} - -# 4. 重启服务 -pm2 start idc-backend - -# 5. 验证回滚 -curl http://localhost:8000/api/health -``` - -### 更新日志 - -**v2.x.x 新增功能**: -- 网卡(NIC)管理功能,支持为设备添加多块网卡 -- 端口与网卡关联,支持按网卡分组管理端口 -- 批量创建端口,支持端口范围格式(如 `1/0/1-1/0/48`) -- 新增 `network_cards` 数据库表 -- `device_ports` 表新增 `nic_id` 字段 -- 新增API端点: - - `GET /api/network-cards` - 获取网卡列表 - - `POST /api/network-cards` - 创建网卡 - - `PUT /api/network-cards/:nicId` - 更新网卡 - - `DELETE /api/network-cards/:nicId` - 删除网卡 - - `GET /api/network-cards/device/:deviceId/with-ports` - 获取网卡及端口 - ---- - -## 🔐 安全加固清单 - -### 服务器安全 - -- [ ] 配置防火墙规则(仅开放必要端口) -- [ ] 启用SSH密钥认证,禁用密码登录 -- [ ] 安装配置fail2ban防止暴力破解 -- [ ] 定期更新系统安全补丁 -- [ ] 配置自动安全更新 -- [ ] 启用系统审计日志 -- [ ] 限制root用户登录 - -### 数据库安全 - -- [ ] 使用强密码策略 -- [ ] 创建专用数据库用户,禁用root远程登录 -- [ ] 定期备份数据库 -- [ ] 启用数据库审计日志 -- [ ] 限制数据库用户权限(最小权限原则) -- [ ] 加密数据库连接(SSL/TLS) - -### 应用安全 - -- [ ] 配置HTTPS强制跳转 -- [ ] 设置安全的Cookie属性(HttpOnly, Secure) -- [ ] 启用CSRF防护 -- [ ] 实现请求速率限制 -- [ ] 配置安全的HTTP头 -- [ ] 敏感信息加密存储 -- [ ] 实现完善的权限控制 - -### 监控与告警 - -- [ ] 配置异常登录告警 -- [ ] 启用API访问日志 -- [ ] 监控服务状态和资源使用 -- [ ] 配置磁盘空间告警 -- [ ] 设置数据库连接数告警 -- [ ] 实现自动化健康检查 - ---- - -**🎉 部署完成后,您就可以开始使用IDC设备管理系统了!** - ---- - -**⭐ 如果这个部署指南对您有帮助,请给我们一个Star!** \ No newline at end of file +**⭐ 如果这个部署指南对您有帮助,请给我们一个Star!** diff --git a/README.md b/README.md index ea29a26..0dc418b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,20 @@ - **3D可视化**:三维机柜可视化展示,支持设备悬停详情查看 - **系统配置**:设备字段、工单字段自定义管理 +### 项目截图 + +![数据看板](docs/images/dashboard.png) +*数据看板 - 实时监控数据中心运行状态* + +![3D机柜可视化](docs/images/3d-visualization.png) +*3D机柜可视化 - 三维展示机柜设备布局* + +![设备管理](docs/images/device-management.png) +*设备管理 - 设备全生命周期管理* + +![机房管理](docs/images/room-management.png) +*机房管理 - 多机房分类管理* + ### 技术栈 | 类别 | 技术 | 版本 | @@ -119,7 +133,7 @@ npm run dev ### 3D可视化 -三维机柜展示,设备悬停详情,实时交互体验,视角控制功能。 +三维机柜展示,设备悬停详情,实时交互体验,视角控制功能,支持设备弹出动画开关。 ## API接口 diff --git a/backend/initDeviceFields.js b/backend/initDeviceFields.js index 85cee81..d2238f3 100644 --- a/backend/initDeviceFields.js +++ b/backend/initDeviceFields.js @@ -127,6 +127,38 @@ const defaultDeviceFields = [ required: false, order: 14, visible: true + }, + { + fieldName: 'owner', + displayName: '责任人', + fieldType: 'string', + required: false, + order: 15, + visible: true + }, + { + fieldName: 'department', + displayName: '所属部门', + fieldType: 'string', + required: false, + order: 16, + visible: true + }, + { + fieldName: 'assetId', + displayName: '资产编号', + fieldType: 'string', + required: false, + order: 17, + visible: true + }, + { + fieldName: 'brand', + displayName: '品牌', + fieldType: 'string', + required: false, + order: 18, + visible: true } ]; diff --git a/backend/models/Rack.js b/backend/models/Rack.js index 499fba2..73df28a 100644 --- a/backend/models/Rack.js +++ b/backend/models/Rack.js @@ -16,7 +16,7 @@ const Rack = sequelize.define('Rack', { height: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 42 // 标准机柜高度(U数) + defaultValue: 45 // 标准机柜高度(U数) }, maxPower: { type: DataTypes.FLOAT, diff --git a/backend/routes/cables.js b/backend/routes/cables.js index 073f3b8..0f32b36 100644 --- a/backend/routes/cables.js +++ b/backend/routes/cables.js @@ -90,6 +90,52 @@ router.get('/device/:deviceId', async (req, res) => { } }); +// 获取指定机柜内所有设备的接线 +router.get('/rack/:rackId', async (req, res) => { + try { + const { rackId } = req.params; + + // 1. 找出该机柜下的所有设备ID + const devices = await Device.findAll({ + where: { rackId: rackId }, + attributes: ['deviceId'] + }); + + const deviceIds = devices.map(d => d.deviceId); + + if (deviceIds.length === 0) { + return res.json([]); + } + + // 2. 查找这些设备参与的所有接线 + const cables = await Cable.findAll({ + where: { + [Op.or]: [ + { sourceDeviceId: { [Op.in]: deviceIds } }, + { targetDeviceId: { [Op.in]: deviceIds } } + ] + }, + include: [ + { + model: Device, + as: 'sourceDevice', + attributes: ['deviceId', 'name', 'type', 'rackId'] + }, + { + model: Device, + as: 'targetDevice', + attributes: ['deviceId', 'name', 'type', 'rackId'] + } + ] + }); + + res.json(cables); + } catch (error) { + console.error('获取机柜接线失败:', error); + res.status(500).json({ error: error.message }); + } +}); + router.post('/', async (req, res) => { try { const { cableId, sourceDeviceId, sourcePort, targetDeviceId, targetPort, cableType, cableLength, status, description } = req.body; diff --git a/backend/routes/deviceFields.js b/backend/routes/deviceFields.js index 0c3a2fd..f3005e3 100644 --- a/backend/routes/deviceFields.js +++ b/backend/routes/deviceFields.js @@ -83,15 +83,27 @@ router.post('/config', async (req, res) => { // 批量更新字段配置 const updatedFields = []; for (const config of fieldConfigs) { - // 使用fieldName作为更新条件,因为这是唯一的 - const [updated] = await DeviceField.update( - { visible: config.visible }, - { where: { fieldName: config.fieldName } } - ); + // 检查字段是否存在 + const existingField = await DeviceField.findOne({ where: { fieldName: config.fieldName } }); - if (updated) { - const updatedField = await DeviceField.findOne({ where: { fieldName: config.fieldName } }); - updatedFields.push(updatedField); + if (existingField) { + // 更新现有字段 + await existingField.update({ + visible: config.visible, + displayName: config.displayName // 同时更新显示名称,以防变化 + }); + updatedFields.push(existingField); + } else { + // 创建新字段 + const newField = await DeviceField.create({ + fieldName: config.fieldName, + visible: config.visible, + displayName: config.displayName, + fieldType: config.fieldType || 'text', + required: false, // 默认为非必填 + order: 0 // 默认顺序 + }); + updatedFields.push(newField); } } diff --git a/backend/routes/devicePorts.js b/backend/routes/devicePorts.js index a91b5df..4ca4d5c 100644 --- a/backend/routes/devicePorts.js +++ b/backend/routes/devicePorts.js @@ -78,7 +78,7 @@ router.get('/device/:deviceId', async (req, res) => { router.post('/', async (req, res) => { try { - const { portId, deviceId, portName, portType, portSpeed, status, vlanId, description } = req.body; + const { portId, deviceId, nicId, portName, portType, portSpeed, status, vlanId, description } = req.body; if (!deviceId || !portName) { return res.status(400).json({ error: '缺少必填字段' }); @@ -97,6 +97,7 @@ router.post('/', async (req, res) => { const port = await DevicePort.create({ portId: autoPortId, deviceId, + nicId: nicId || null, portName, portType: portType || 'RJ45', portSpeed: portSpeed || '1G', @@ -156,6 +157,7 @@ router.post('/batch', async (req, res) => { await DevicePort.create({ portId: portData.portId, deviceId: portData.deviceId, + nicId: portData.nicId || null, portName: portData.portName, portType: portData.portType || 'RJ45', portSpeed: portData.portSpeed || '1G', diff --git a/backend/routes/devices.js b/backend/routes/devices.js index 3fe122e..2d5ef65 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const { Op } = require('sequelize'); +const { sequelize } = require('../db'); // Import sequelize for transactions const fs = require('fs'); const path = require('path'); const csv = require('csv-parser'); @@ -11,6 +12,8 @@ const Rack = require('../models/Rack'); const Room = require('../models/Room'); const DeviceField = require('../models/DeviceField'); const Ticket = require('../models/Ticket'); +const DevicePort = require('../models/DevicePort'); // Import DevicePort +const Cable = require('../models/Cable'); // Import Cable // 获取所有设备(支持搜索和筛选) router.get('/', async (req, res) => { @@ -841,91 +844,150 @@ router.put('/batch-offline', async (req, res) => { // 批量删除设备 router.delete('/batch-delete', async (req, res) => { + const t = await sequelize.transaction(); try { const { deviceIds } = req.body; if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) { + await t.rollback(); return res.status(400).json({ error: '请提供有效的设备ID列表' }); } const devices = await Device.findAll({ - where: { deviceId: { [Op.in]: deviceIds } } + where: { deviceId: { [Op.in]: deviceIds } }, + transaction: t }); + // 1. 删除相关接线 (Delete associated Cables) + await Cable.destroy({ + where: { + [Op.or]: [ + { sourceDeviceId: { [Op.in]: deviceIds } }, + { targetDeviceId: { [Op.in]: deviceIds } } + ] + }, + transaction: t + }); + + // 2. 删除相关端口 (Delete associated DevicePorts) + await DevicePort.destroy({ + where: { deviceId: { [Op.in]: deviceIds } }, + transaction: t + }); + + // 3. 解除工单关联 await Ticket.update( { deviceId: null }, - { where: { deviceId: { [Op.in]: deviceIds } } } + { where: { deviceId: { [Op.in]: deviceIds } }, transaction: t } ); + // 4. 删除设备 const deletedCount = await Device.destroy({ - where: { deviceId: { [Op.in]: deviceIds } } + where: { deviceId: { [Op.in]: deviceIds } }, + transaction: t }); + // 更新机柜功率 for (const device of devices) { - const rack = await Rack.findByPk(device.rackId); - if (rack) { - await rack.update({ - currentPower: Math.max(0, rack.currentPower - device.powerConsumption) - }); + if (device.rackId) { + const rack = await Rack.findByPk(device.rackId, { transaction: t }); + if (rack) { + await rack.update({ + currentPower: Math.max(0, rack.currentPower - device.powerConsumption) + }, { transaction: t }); + } } } + + await t.commit(); res.json({ message: `批量删除成功,已删除 ${deletedCount} 个设备`, deletedCount }); } catch (error) { + await t.rollback(); res.status(500).json({ error: error.message }); } }); // 删除设备 router.delete('/:deviceId', async (req, res) => { + const t = await sequelize.transaction(); try { + const { deviceId } = req.params; + // 获取设备信息以更新功率 - const device = await Device.findByPk(req.params.deviceId); + const device = await Device.findByPk(deviceId, { transaction: t }); if (!device) { + await t.rollback(); return res.status(404).json({ error: '设备不存在' }); } - const deleted = await Device.destroy({ - where: { deviceId: req.params.deviceId } + // 1. 删除相关接线 (Delete associated Cables) + // 必须在删除设备之前删除,否则可能触发外键约束错误 + const deletedCables = await Cable.destroy({ + where: { + [Op.or]: [ + { sourceDeviceId: deviceId }, + { targetDeviceId: deviceId } + ] + }, + transaction: t }); - if (deleted) { - const Cable = require('../models/Cable'); - - const deletedCables = await Cable.destroy({ - where: { - [Op.or]: [ - { sourceDeviceId: req.params.deviceId }, - { targetDeviceId: req.params.deviceId } - ] - } - }); - - if (deletedCables > 0) { - console.log(`已删除 ${deletedCables} 条相关接线`); - } - - if (device.rackId) { + // 2. 删除相关端口 (Delete associated DevicePorts) + // 必须在删除设备之前删除,否则触发外键约束错误 + const deletedPorts = await DevicePort.destroy({ + where: { deviceId: deviceId }, + transaction: t + }); + + // 3. 解除工单关联 (Unlink Tickets) + await Ticket.update( + { deviceId: null }, + { where: { deviceId: deviceId }, transaction: t } + ); + + // 4. 删除设备 (Delete Device) + await Device.destroy({ + where: { deviceId: deviceId }, + transaction: t + }); + + // 提交事务 + await t.commit(); + + if (deletedCables > 0) { + console.log(`已删除 ${deletedCables} 条相关接线`); + } + + // 更新机柜功率 (Update Rack power) + // 注意:设备已删除,不需要再减去功率?或者需要? + // 原逻辑是:rack.currentPower - device.powerConsumption + // 既然设备已经物理删除了,机柜的当前功率确实应该减少。 + if (device.rackId) { + try { const rack = await Rack.findByPk(device.rackId); if (rack) { await rack.update({ currentPower: Math.max(0, rack.currentPower - device.powerConsumption) }); } + } catch (err) { + console.error('更新机柜功率失败:', err); } - - res.status(200).json({ - message: '删除成功', - deviceId: req.params.deviceId, - deletedCablesCount: deletedCables - }); - } else { - res.status(404).json({ error: '设备不存在' }); } + + res.status(200).json({ + message: '删除成功', + deviceId: deviceId, + deletedCablesCount: deletedCables, + deletedPortsCount: deletedPorts + }); } catch (error) { + await t.rollback(); + console.error('删除设备失败:', error); res.status(500).json({ error: error.message }); } }); diff --git a/backend/scripts/ensure_nic_schema.js b/backend/scripts/ensure_nic_schema.js new file mode 100644 index 0000000..83668d8 --- /dev/null +++ b/backend/scripts/ensure_nic_schema.js @@ -0,0 +1,46 @@ +const { sequelize } = require('../db'); +const { QueryTypes } = require('sequelize'); + +async function ensureSchema() { + try { + const dbType = sequelize.getDialect(); + console.log(`Checking schema for ${dbType}...`); + + if (dbType === 'sqlite') { + // Check if nicId column exists in device_ports + const [columns] = await sequelize.query("PRAGMA table_info(device_ports)"); + const hasNicId = columns.some(col => col.name === 'nicId'); + + if (!hasNicId) { + console.log('Adding nicId column to device_ports...'); + await sequelize.query('ALTER TABLE device_ports ADD COLUMN nicId VARCHAR(255) NULL REFERENCES network_cards(nicId)'); + console.log('Added nicId column.'); + } else { + console.log('nicId column already exists in device_ports.'); + } + } else if (dbType === 'mysql') { + const [columns] = await sequelize.query( + "SHOW COLUMNS FROM `device_ports` LIKE 'nicId'", + { type: QueryTypes.SELECT } + ); + + if (columns.length === 0) { + console.log('Adding nicId column to device_ports...'); + await sequelize.query( + 'ALTER TABLE `device_ports` ADD COLUMN `nicId` VARCHAR(255) NULL AFTER `deviceId`' + ); + console.log('Added nicId column.'); + } else { + console.log('nicId column already exists.'); + } + } + + console.log('Schema check complete.'); + } catch (error) { + console.error('Schema check failed:', error); + } finally { + await sequelize.close(); + } +} + +ensureSchema(); diff --git a/backend/server.js b/backend/server.js index 5be5de4..0cddb89 100644 --- a/backend/server.js +++ b/backend/server.js @@ -64,6 +64,7 @@ const ticketFieldRoutes = require('./routes/ticketFields'); const systemSettingsRoutes = require('./routes/systemSettings'); const cableRoutes = require('./routes/cables'); const devicePortRoutes = require('./routes/devicePorts'); +const networkCardRoutes = require('./routes/networkCards'); // 使用路由 app.use('/api/devices', deviceRoutes); @@ -83,6 +84,7 @@ app.use('/api/ticket-fields', ticketFieldRoutes); app.use('/api/system-settings', systemSettingsRoutes); app.use('/api/cables', cableRoutes); app.use('/api/device-ports', devicePortRoutes); +app.use('/api/network-cards', networkCardRoutes); // 静态文件服务 app.use('/uploads', express.static('uploads')); diff --git a/docs/images/3d-visualization.png b/docs/images/3d-visualization.png new file mode 100644 index 0000000..61c5fe6 Binary files /dev/null and b/docs/images/3d-visualization.png differ diff --git a/docs/images/README.md b/docs/images/README.md new file mode 100644 index 0000000..cacea3b --- /dev/null +++ b/docs/images/README.md @@ -0,0 +1,136 @@ +# 项目截图说明 + +## 截图列表 + +以下截图需要在README.md中展示: + +1. **dashboard.png** - 数据看板页面截图 + - 路径:访问 http://localhost:3000 + - 内容:展示数据看板的统计图表、设备状态分布等 + +2. **3d-visualization.png** - 3D机柜可视化页面截图 + - 路径:访问 http://localhost:3000/visualization-3d + - 内容:展示3D机柜的三维视图、设备布局、交互功能 + +3. **device-management.png** - 设备管理页面截图 + - 路径:访问 http://localhost:3000/devices + - 内容:展示设备列表、筛选功能、操作按钮等 + +4. **room-management.png** - 机房管理页面截图 + - 路径:访问 http://localhost:3000/rooms + - 内容:展示机房列表、机柜统计、管理功能等 + +## 添加截图步骤 + +### Windows系统 + +1. **启动项目** + ```bash + cd backend && npm run dev + cd frontend && npm run dev + ``` + +2. **打开浏览器访问对应页面** + +3. **使用截图工具** + - Windows截图工具:Win + Shift + S + - 或使用其他截图工具(如Snipaste、ShareX等) + +4. **保存截图** + - 将截图保存为PNG格式 + - 文件名对应上面的列表 + - 保存到 `docs/images/` 目录 + +5. **优化截图(可选)** + - 建议分辨率:1920x1080 或更高 + - 建议压缩:使用TinyPNG等工具压缩图片大小 + - 建议格式:PNG(保持清晰度)或 WebP(更小体积) + +### macOS系统 + +1. **启动项目** + ```bash + cd backend && npm run dev + cd frontend && npm run dev + ``` + +2. **打开浏览器访问对应页面** + +3. **使用截图工具** + - macOS截图工具:Cmd + Shift + 4(区域截图) + - 或 Cmd + Shift + 3(全屏截图) + +4. **保存截图** + - 将截图保存为PNG格式 + - 文件名对应上面的列表 + - 保存到 `docs/images/` 目录 + +## 截图建议 + +### 最佳实践 + +- **浏览器全屏模式**:按F11进入全屏,避免浏览器UI干扰 +- **隐藏开发者工具**:确保没有开发者工具面板 +- **合适的窗口大小**:建议使用1920x1080或1366x768分辨率 +- **数据准备**:确保页面有足够的测试数据展示 +- **清晰度**:保持截图清晰,避免模糊 + +### 截图内容建议 + +- **数据看板**:展示多种图表类型、统计数据、关键指标 +- **3D可视化**:展示机柜的3D视图、设备详情、交互状态 +- **设备管理**:展示设备列表、搜索筛选、操作按钮 +- **机房管理**:展示机房列表、机柜分布、统计信息 + +## 自动化截图(可选) + +如果需要自动化截图,可以使用以下工具: + +### Playwright + +```javascript +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch(); + const page = await browser.newPage(); + await page.setViewportSize({ width: 1920, height: 1080 }); + + // 截图数据看板 + await page.goto('http://localhost:3000'); + await page.screenshot({ path: 'docs/images/dashboard.png' }); + + // 截图3D可视化 + await page.goto('http://localhost:3000/visualization-3d'); + await page.waitForTimeout(2000); // 等待3D场景加载 + await page.screenshot({ path: 'docs/images/3d-visualization.png' }); + + await browser.close(); +})(); +``` + +### Puppeteer + +```javascript +const puppeteer = require('puppeteer'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + + // 截图数据看板 + await page.goto('http://localhost:3000'); + await page.screenshot({ path: 'docs/images/dashboard.png' }); + + await browser.close(); +})(); +``` + +## 注意事项 + +- 确保截图文件名与README.md中的引用一致 +- 建议使用PNG格式以保持清晰度 +- 截图大小建议控制在500KB以内 +- 避免在截图中暴露敏感信息(如真实IP、密码等) +- 定期更新截图以反映最新的UI变化 diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png new file mode 100644 index 0000000..bdf693e Binary files /dev/null and b/docs/images/dashboard.png differ diff --git a/docs/images/device-management.png b/docs/images/device-management.png new file mode 100644 index 0000000..e095bff Binary files /dev/null and b/docs/images/device-management.png differ diff --git a/docs/images/room-management.png b/docs/images/room-management.png new file mode 100644 index 0000000..33d3b94 Binary files /dev/null and b/docs/images/room-management.png differ diff --git a/frontend/index.html b/frontend/index.html index 9d62953..9de6262 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,8 @@ IDC设备管理系统 + +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 647487a..6e4c51a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "dependencies": { "@ant-design/icons": "^6.1.0", + "@react-three/drei": "^9.122.0", + "@react-three/fiber": "^8.18.0", "antd": "^5.8.6", "axios": "^1.5.0", "dayjs": "^1.11.19", @@ -16,7 +18,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", - "three": "^0.160.0", + "three": "^0.160.1", "xlsx": "^0.18.5" }, "devDependencies": { @@ -244,7 +246,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -593,7 +594,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -637,11 +637,16 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", @@ -1157,6 +1162,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@rc-component/async-validator": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz", @@ -1318,6 +1341,195 @@ "react-dom": ">=18.0.0" } }, + "node_modules/@react-spring/animated": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", + "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", + "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", + "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", + "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/three": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.7.5.tgz", + "integrity": "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/core": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "@react-three/fiber": ">=6.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "three": ">=0.126" + } + }, + "node_modules/@react-spring/types": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", + "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", + "license": "MIT" + }, + "node_modules/@react-three/drei": { + "version": "9.122.0", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.122.0.tgz", + "integrity": "sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@react-spring/three": "~9.7.5", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^2.9.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "react-composer": "^5.0.3", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.7.8", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.0", + "tunnel-rat": "^0.1.2", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^8", + "react": "^18", + "react-dom": "^18", + "three": ">=0.137" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", + "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18 <19", + "react-dom": ">=18 <19", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/@react-three/fiber/node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.23.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", @@ -1649,27 +1861,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -1739,11 +1930,10 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "license": "MIT" }, "node_modules/@types/babel__core": { @@ -1809,6 +1999,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1816,6 +2012,75 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", + "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", + "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1921,6 +2186,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1953,29 +2224,6 @@ "node": ">= 14" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/antd": { "version": "5.29.2", "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.2.tgz", @@ -2098,6 +2346,26 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.8", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.8.tgz", @@ -2112,7 +2380,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -2138,7 +2405,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2153,6 +2419,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2173,6 +2463,15 @@ "node": ">= 0.4" } }, + "node_modules/camera-controls": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", + "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001760", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", @@ -2294,6 +2593,38 @@ "node": ">=0.8" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", @@ -2354,8 +2685,7 @@ "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -2401,12 +2731,20 @@ "node": ">=6" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -2580,6 +2918,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -2696,6 +3040,12 @@ "node": ">= 0.4" } }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2747,6 +3097,12 @@ "node": ">= 0.4" } }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -2801,6 +3157,32 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2824,6 +3206,39 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2836,7 +3251,6 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -2906,6 +3320,15 @@ "node": ">=6" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2928,14 +3351,14 @@ "yallist": "^3.0.2" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" } }, "node_modules/magic-string": { @@ -2964,6 +3387,21 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3028,6 +3466,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3058,6 +3505,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3078,7 +3534,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3115,26 +3570,37 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "is-promise": "^2.1.0", + "lie": "^3.0.2" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/proxy-from-env": { @@ -3764,7 +4230,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3772,12 +4237,23 @@ "node": ">=0.10.0" } }, + "node_modules/react-composer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", + "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3792,6 +4268,31 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3834,6 +4335,21 @@ "react-dom": ">=16.8" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3852,7 +4368,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3929,6 +4444,27 @@ "semver": "bin/semver.js" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3986,6 +4522,32 @@ "dev": true, "license": "MIT" }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -4018,6 +4580,15 @@ "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT" }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -4031,7 +4602,6 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -4051,6 +4621,39 @@ "integrity": "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==", "license": "MIT" }, + "node_modules/three-mesh-bvh": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz", + "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==", + "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.151.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -4156,6 +4759,73 @@ "node": ">=20" } }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4187,13 +4857,30 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/vite": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.27", @@ -4813,7 +5500,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4896,6 +5582,17 @@ "node": ">=18" } }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", @@ -4943,6 +5640,21 @@ "node": ">=20" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -5044,6 +5756,35 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz", + "integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 68bc596..db003fc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@ant-design/icons": "^6.1.0", + "@react-three/drei": "^9.122.0", + "@react-three/fiber": "^8.18.0", "antd": "^5.8.6", "axios": "^1.5.0", "dayjs": "^1.11.19", @@ -17,7 +19,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", - "three": "^0.160.0", + "three": "^0.160.1", "xlsx": "^0.18.5" }, "devDependencies": { diff --git a/frontend/public/assets/3d/env.hdr b/frontend/public/assets/3d/env.hdr new file mode 100644 index 0000000..e7438f8 Binary files /dev/null and b/frontend/public/assets/3d/env.hdr differ diff --git a/frontend/public/fonts/Inter-Regular.woff2 b/frontend/public/fonts/Inter-Regular.woff2 new file mode 100644 index 0000000..2bcd222 Binary files /dev/null and b/frontend/public/fonts/Inter-Regular.woff2 differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bd3fe37..3fc7a54 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,6 @@ import React, { useState, Suspense, lazy } from 'react'; import { Layout, Menu, theme, Button, Dropdown, Avatar, message, Space, Divider, ConfigProvider as AntdConfigProvider } from 'antd'; -import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, HistoryOutlined, AuditOutlined, ToolOutlined, ScheduleOutlined, SettingOutlined, ApiOutlined, PartitionOutlined } from '@ant-design/icons'; +import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, HistoryOutlined, AuditOutlined, ToolOutlined, ScheduleOutlined, SettingOutlined, ApiOutlined, PartitionOutlined, CodepenOutlined } from '@ant-design/icons'; import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from './context/AuthContext'; import { ConfigProvider, useConfig } from './context/ConfigContext'; @@ -11,7 +11,7 @@ const DeviceManagement = lazy(() => import('./pages/DeviceManagement')); const RackManagement = lazy(() => import('./pages/RackManagement')); const RoomManagement = lazy(() => import('./pages/RoomManagement')); const DeviceFieldManagement = lazy(() => import('./pages/DeviceFieldManagement')); -const RackVisualization = lazy(() => import('./pages/RackVisualization')); +const Rack3DVisualization = lazy(() => import('./pages/Rack3DVisualization')); const ConsumableManagement = lazy(() => import('./pages/ConsumableManagement')); const ConsumableStatistics = lazy(() => import('./pages/ConsumableStatistics')); const ConsumableLogs = lazy(() => import('./pages/ConsumableLogs')); @@ -145,7 +145,8 @@ const AppLayout = ({ children }) => { 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('/visualization-3d')) return 'visualization-3d'; + if (path.startsWith('/rooms') || path.startsWith('/racks')) return 'room-management'; if (path.startsWith('/devices') || path.startsWith('/fields') || path.startsWith('/cables') || path.startsWith('/ports')) 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'; @@ -225,9 +226,9 @@ const AppLayout = ({ children }) => { label: 机柜管理, }, { - key: 'visualization', - icon: , - label: 机柜可视化, + key: 'visualization-3d', + icon: , + label: 3D机柜可视化, }, ], }, @@ -579,7 +580,7 @@ const ThemeConfig = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/assets/3d/env.hdr b/frontend/src/assets/3d/env.hdr new file mode 100644 index 0000000..e7438f8 Binary files /dev/null and b/frontend/src/assets/3d/env.hdr differ diff --git a/frontend/src/assets/fonts/Inter-Regular.woff2 b/frontend/src/assets/fonts/Inter-Regular.woff2 new file mode 100644 index 0000000..2bcd222 Binary files /dev/null and b/frontend/src/assets/fonts/Inter-Regular.woff2 differ diff --git a/frontend/src/components/3d/DeviceModel.jsx b/frontend/src/components/3d/DeviceModel.jsx new file mode 100644 index 0000000..b6b4f08 --- /dev/null +++ b/frontend/src/components/3d/DeviceModel.jsx @@ -0,0 +1,837 @@ +import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react'; +import { useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; + +const PERFORMANCE_MODE = true; + +const InstancedStatusLights = ({ count, positions, colors: statusColors, zOffset }) => { + const meshRef = useRef(); + const colorArray = useMemo(() => { + const arr = new Float32Array(count * 3); + for (let i = 0; i < count; i++) { + const color = new THREE.Color(statusColors[i] || '#22c55e'); + arr[i * 3] = color.r; + arr[i * 3 + 1] = color.g; + arr[i * 3 + 2] = color.b; + } + return arr; + }, [count, statusColors]); + + const dummy = useMemo(() => new THREE.Object3D(), []); + + useEffect(() => { + if (meshRef.current) { + for (let i = 0; i < count; i++) { + dummy.position.set(positions[i].x, positions[i].y, positions[i].z + zOffset); + dummy.updateMatrix(); + meshRef.current.setMatrixAt(i, dummy.matrix); + } + meshRef.current.instanceMatrix.needsUpdate = true; + } + }, [count, positions, zOffset, dummy]); + + return ( + + + + + ); +}; + +const InstancedDriveBays = ({ count, positions, color, hasDetail = true }) => { + const meshRef = useRef(); + const detailRef = useRef(); + const dummy = useMemo(() => new THREE.Object3D(), []); + + useEffect(() => { + if (meshRef.current) { + for (let i = 0; i < count; i++) { + dummy.position.set(positions[i].x, positions[i].y, positions[i].z); + dummy.scale.set(1, 1, 1); + dummy.updateMatrix(); + meshRef.current.setMatrixAt(i, dummy.matrix); + } + meshRef.current.instanceMatrix.needsUpdate = true; + } + if (detailRef.current && hasDetail) { + for (let i = 0; i < count; i++) { + dummy.position.set(positions[i].x + 0.04, positions[i].y, positions[i].z + 0.003); + dummy.scale.set(1, 1, 1); + dummy.updateMatrix(); + detailRef.current.setMatrixAt(i, dummy.matrix); + } + detailRef.current.instanceMatrix.needsUpdate = true; + } + }, [count, positions, hasDetail, dummy]); + + return ( + + + + + + {hasDetail && ( + + + + + )} + + ); +}; + +const InstancedStorageBays = ({ count, positions, color }) => { + const meshRef = useRef(); + const detailRef = useRef(); + const dummy = useMemo(() => new THREE.Object3D(), []); + + useEffect(() => { + if (meshRef.current) { + for (let i = 0; i < count; i++) { + dummy.position.set(positions[i].x, positions[i].y, positions[i].z); + dummy.scale.set(1, 1, 1); + dummy.updateMatrix(); + meshRef.current.setMatrixAt(i, dummy.matrix); + } + meshRef.current.instanceMatrix.needsUpdate = true; + } + if (detailRef.current) { + for (let i = 0; i < count; i++) { + dummy.position.set(positions[i].x + 0.03, positions[i].y, positions[i].z + 0.003); + dummy.scale.set(1, 1, 1); + dummy.updateMatrix(); + detailRef.current.setMatrixAt(i, dummy.matrix); + } + detailRef.current.instanceMatrix.needsUpdate = true; + } + }, [count, positions, dummy]); + + return ( + + + + + + + + + + + ); +}; + +const InstancedRJ45Ports = ({ count, positions, statuses, frontZ }) => { + const meshRef = useRef(); + const innerRef = useRef(); + const tabRef = useRef(); + const ledRef = useRef(); + const dummy = useMemo(() => new THREE.Object3D(), []); + + useEffect(() => { + if (meshRef.current) { + for (let i = 0; i < count; i++) { + dummy.position.set(positions[i].x, positions[i].y + 0.012, frontZ + 0.006); + dummy.scale.set(1, 1, 1); + dummy.updateMatrix(); + meshRef.current.setMatrixAt(i, dummy.matrix); + } + meshRef.current.instanceMatrix.needsUpdate = true; + } + if (innerRef.current) { + for (let i = 0; i < count; i++) { + dummy.position.set(positions[i].x, positions[i].y + 0.012, frontZ + 0.007); + dummy.scale.set(1, 1, 1); + dummy.updateMatrix(); + innerRef.current.setMatrixAt(i, dummy.matrix); + } + innerRef.current.instanceMatrix.needsUpdate = true; + } + if (tabRef.current) { + for (let i = 0; i < count; i++) { + dummy.position.set(positions[i].x, positions[i].y + 0.015, frontZ + 0.008); + dummy.scale.set(1, 1, 1); + dummy.updateMatrix(); + tabRef.current.setMatrixAt(i, dummy.matrix); + } + tabRef.current.instanceMatrix.needsUpdate = true; + } + if (ledRef.current) { + for (let i = 0; i < count; i++) { + const status = statuses[i] || 'disconnected'; + const ledY = positions[i].y + (i % 2 === 0 ? 0.021 : -0.002); + dummy.position.set(positions[i].x - 0.004, ledY, frontZ + 0.006); + dummy.scale.set(1, 1, 1); + dummy.updateMatrix(); + ledRef.current.setMatrixAt(i, dummy.matrix); + } + ledRef.current.instanceMatrix.needsUpdate = true; + } + }, [count, positions, statuses, frontZ, dummy]); + + const ledColors = useMemo(() => { + return statuses.map(s => s !== 'disconnected' ? (s === 'fault' ? '#ef4444' : '#22c55e') : '#475569'); + }, [statuses]); + + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +const InstancedSFPports = ({ count, positions, statuses, frontZ }) => { + const meshRef = useRef(); + const innerRef = useRef(); + const connectorRef = useRef(); + const ledRef = useRef(); + const dummy = useMemo(() => new THREE.Object3D(), []); + + useEffect(() => { + if (meshRef.current) { + for (let i = 0; i < count; i++) { + dummy.position.set(positions[i].x, positions[i].y, frontZ + 0.006); + dummy.scale.set(1, 1, 1); + dummy.updateMatrix(); + meshRef.current.setMatrixAt(i, dummy.matrix); + } + meshRef.current.instanceMatrix.needsUpdate = true; + } + if (innerRef.current) { + for (let i = 0; i < count; i++) { + dummy.position.set(positions[i].x, positions[i].y, frontZ + 0.009); + dummy.scale.set(1, 1, 1); + dummy.updateMatrix(); + innerRef.current.setMatrixAt(i, dummy.matrix); + } + innerRef.current.instanceMatrix.needsUpdate = true; + } + if (connectorRef.current) { + for (let i = 0; i < count; i++) { + const isConnected = statuses[i] !== 'disconnected'; + dummy.position.set(positions[i].x, positions[i].y, frontZ + 0.012); + dummy.scale.set(isConnected ? 1 : 0.001, 1, 1); + dummy.updateMatrix(); + connectorRef.current.setMatrixAt(i, dummy.matrix); + } + connectorRef.current.instanceMatrix.needsUpdate = true; + } + if (ledRef.current) { + for (let i = 0; i < count; i++) { + const isConnected = statuses[i] !== 'disconnected'; + dummy.position.set(positions[i].x, positions[i].y + 0.01, frontZ + 0.009); + dummy.scale.set(1, 1, 1); + dummy.updateMatrix(); + ledRef.current.setMatrixAt(i, dummy.matrix); + } + ledRef.current.instanceMatrix.needsUpdate = true; + } + }, [count, positions, statuses, frontZ, dummy]); + + const ledColors = useMemo(() => { + return positions.map((_, i) => statuses[i] !== 'disconnected' ? '#22c55e' : '#475569'); + }, [positions, statuses]); + + return ( + + + + + + + + + + + + + + + + + + + ); +}; + +const FirewallFace = ({ device, height, frontZ, isSelected }) => { + const halfWidth = 0.4826 / 2; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + {!PERFORMANCE_MODE && Array.from({ length: 4 }).map((_, col) => ( + + + + + ))} + {!PERFORMANCE_MODE && ( + + )} + + + + {['#22c55e', '#22c55e', device.status === 'error' ? '#ef4444' : '#4b5563'].map((color, i) => ( + + + + {i === 2 && device.status === 'error' && ( + + )} + + ))} + + + ); +}; + +const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight: propUHeight, position, rackDepth, slideEnabled = true }) => { + const mesh = useRef(); + const [hovered, setHover] = useState(false); + const [isExtended, setIsExtended] = useState(false); + const [currentZ, setCurrentZ] = useState(0); + + // 使用传入的 uHeight (默认为 0.04445m) + const uHeight = propUHeight || 0.04445; + const depth = rackDepth || 1.0; // 机柜深度,默认1米 + + // 滑轨动画 - 仅在展开时运行且slideEnabled为true + useFrame(() => { + if (!slideEnabled || (!isExtended && Math.abs(currentZ) < 0.01)) { + if (currentZ !== 0) setCurrentZ(0); + return; + } + const targetZ = isExtended ? 0.6 : 0; + const lerpFactor = 0.1; + setCurrentZ(prev => prev + (targetZ - prev) * lerpFactor); + }); + + // 尺寸定义 (适配标准 0.6m 机柜) + // 标准19英寸机柜内部净宽约 0.45m,设备面板宽 0.4826m (19inch) + const chassisWidth = 0.44; // 机身宽度 440mm + const chassisDepth = 0.8; // 机身深度 800mm + const panelWidth = 0.4826; // 前面板宽度 482.6mm (19英寸) + const panelDepth = 0.02; // 前面板厚度 20mm + + // 计算实际位置(防止超出机柜) + // 优先使用父组件传入的 position,如果未传入则内部计算(作为 fallback) + // 注意:父组件 RackModel 已经计算好了 position=[0, yPos, 0] + // 我们只需要处理设备的高度 + + const dHeight = device.height || device.u_height || 1; + const height = dHeight * uHeight; + + // 间隙调整:减少设备上下间隙,使其更饱满,减少视觉偏差 + const gap = 0.002; // 总间隙 2mm + + // Z轴定位 + // 假设机柜中心为0,前沿为 depth/2 + // 设备前面板应该贴近前沿 + const frontZ = depth / 2 - 0.02; + const panelZ = frontZ - panelDepth / 2; + const chassisZ = frontZ - panelDepth - chassisDepth / 2; + + // 颜色定义 (参考 CSS 变量) + const colors = { + server: '#3b82f6', // --color-device-server + switch: '#22c55e', // --color-device-switch + router: '#f59e0b', // --color-device-router + firewall: '#ef4444', // --color-device-firewall + storage: '#8b5cf6', // --color-device-storage + default: '#3b82f6', + status: { + running: '#10b981', // --color-status-running + warning: '#f59e0b', // --color-status-warning + error: '#ef4444', // --color-status-error + offline: '#6b7280' // --color-status-offline + }, + panelBg: '#1e293b', // 深色面板背景 + panelLight: '#334155', + text: '#f1f5f9' + }; + + const getDeviceColor = (type) => { + const t = type?.toLowerCase() || ''; + if (t.includes('server') || t.includes('服务器')) return colors.server; + if (t.includes('switch') || t.includes('交换机')) return colors.switch; + if (t.includes('router') || t.includes('路由器')) return colors.router; + if (t.includes('firewall') || t.includes('防火墙')) return colors.firewall; + if (t.includes('storage') || t.includes('存储')) return colors.storage; + return colors.default; + }; + + const deviceColor = getDeviceColor(device.type); + const statusColor = colors.status[device.status] || colors.status.running; + + // 渲染服务器前面板细节 + const renderServerFace = () => { + const is2U = height > 0.08; + const driveRows = is2U ? 2 : 1; + const driveCols = PERFORMANCE_MODE ? 3 : 4; + const driveWidth = 0.07; + const driveHeight = 0.035; + + const drivePositions = []; + for (let row = 0; row < driveRows; row++) { + for (let col = 0; col < driveCols; col++) { + if (!is2U && row > 0) continue; + const xPos = (col - 1) * (driveWidth + 0.005); + const yPos = is2U ? (row === 0 ? 0.02 : -0.02) : 0; + drivePositions.push({ x: xPos, y: yPos, z: 0 }); + } + } + + return ( + + + + + + + + + + + + + + + + + {!PERFORMANCE_MODE && ( + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + }; + + // 渲染存储设备前面板细节 + const renderStorageFace = () => { + const rows = PERFORMANCE_MODE ? 2 : 3; + const cols = PERFORMANCE_MODE ? 2 : 4; + const bayWidth = 0.10; + const bayHeight = 0.028; + + const bayPositions = []; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const xPos = (col - 1) * (bayWidth + 0.002); + const yStep = (height - 0.02) / rows; + const yPos = (rows - 1 - row) * yStep - (rows - 1) * yStep / 2; + bayPositions.push({ x: xPos, y: yPos, z: 0 }); + } + } + + return ( + + + + + + + + + + + ); + }; + + // 渲染交换机前面板细节 + const renderSwitchFace = () => { + const getPortStatus = (portName) => { + if (!device.cables || !Array.isArray(device.cables)) return 'disconnected'; + const cable = device.cables.find(c => + (c.sourceDeviceId === device.deviceId && c.sourcePort === portName) || + (c.targetDeviceId === device.deviceId && c.targetPort === portName) + ); + return cable ? (cable.status || 'normal') : 'disconnected'; + }; + + const sfpCount = PERFORMANCE_MODE ? 2 : 4; + const rj45GroupCount = PERFORMANCE_MODE ? 2 : 4; + const rj45ColCount = PERFORMANCE_MODE ? 4 : 6; + + const sfpPositions = []; + const sfpStatuses = []; + for (let i = 0; i < sfpCount; i++) { + sfpPositions.push({ x: -0.13 + i * 0.025, y: -0.005, z: 0 }); + sfpStatuses.push(getPortStatus(`SFP+ ${i + 1}`)); + } + + const rj45PortCount = rj45GroupCount * rj45ColCount * 2; + const rj45Positions = []; + const rj45Statuses = []; + for (let groupIndex = 0; groupIndex < rj45GroupCount; groupIndex++) { + const groupX = -0.02 + groupIndex * 0.09; + for (let colIndex = 0; colIndex < rj45ColCount; colIndex++) { + const portX = -0.03 + colIndex * 0.012; + const globalCol = groupIndex * rj45ColCount + colIndex; + const topPortNum = globalCol * 2 + 1; + const bottomPortNum = globalCol * 2 + 2; + const topStatus = getPortStatus(`Port ${topPortNum}`); + const bottomStatus = getPortStatus(`Port ${bottomPortNum}`); + rj45Positions.push({ x: groupX + portX, y: 0, z: 0 }); + rj45Statuses.push(topStatus); + rj45Positions.push({ x: groupX + portX, y: -0.024, z: 0 }); + rj45Statuses.push(bottomStatus); + } + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + {['SYS', 'PWR'].map((label, idx) => ( + + + + + + + ))} + + + + + + + + + + + + ); + }; + + // 渲染设备背板 + const renderBackPanel = () => { + // Back face Z position (flush with back of chassis) + const backZ = chassisZ - chassisDepth / 2; + + // Rotate 180 deg to face backwards + return ( + + {/* 基础背板 - 透明玻璃质感 (Reduced quality for performance) */} + + + + + + {/* 电源模块 (PSU) - 左侧 (从背面看是右侧,但我们旋转了) */} + {/* 2个 PSU 垂直排列或并排 */} + + {[-0.02, 0.02].map((yOffset, i) => ( + 0.15 ? yOffset * 2 : 0, 0]}> + {/* 如果高度够大(>1U),垂直排列,否则只显示一个或者水平排列 */} + {/* 简化:1U设备只显示左边一个,2U显示两个 */} + {(height > 0.15 || i === 0) && ( + + {/* PSU 面板 */} + + 0.15 ? 0.04 : 0.08, 0.004]} /> + + + {/* 把手 */} + + + + + {/* 电源插口 C13 */} + + + + + {/* 状态灯 */} + + + + + + )} + + ))} + + + {/* 风扇模块 (Fan Modules) - 中间 */} + {/* 3-4个风扇阵列 */} + + {Array.from({ length: 3 }).map((_, i) => ( + + {/* 风扇网罩 */} + + + + + {/* 风扇叶片模拟 (纹理) */} + + + + + {/* 红色拉手 (热插拔) */} + + + + + + ))} + + + {/* 网卡/扩展模块 (PCIe/LOM) - 右侧 */} + + {/* 竖向 PCIe 挡板 */} + {Array.from({ length: 2 }).map((_, i) => ( + + + + + + {/* 端口 (SFP+ or RJ45) */} + + + + + + + + + + ))} + + + ); + }; + const renderDeviceFace = () => { + const type = device.type?.toLowerCase() || ''; + if (type.includes('server') || type.includes('服务器')) return renderServerFace(); + if (type.includes('switch') || type.includes('交换机')) return renderSwitchFace(); + if (type.includes('storage') || type.includes('存储')) return renderStorageFace(); + if (type.includes('firewall') || type.includes('防火墙') || type.includes('router') || type.includes('路由器')) { + return ; + } + + // 默认通用设备样式 + return ( + + {/* 默认面板纹理 */} + + + + + {/* 装饰线 */} + + + + + + ); + }; + + return ( + + { + e.stopPropagation(); + setIsExtended(!isExtended); + onClick && onClick(device); + }} + onPointerOver={(e) => { + e.stopPropagation(); + setHover(true); + onHover && onHover(device); + }} + onPointerOut={(e) => { + setHover(false); + onHover && onHover(null); + }} + > + {/* 机身 (Chassis) - 深色金属 */} + + + + + + {/* 前面板底座 (Front Panel Base) */} + + + + + + {/* 告警时的红色辉光 (设备两侧) */} + {(device.status === 'error' || device.status === 'fault') && ( + <> + + + + )} + + {/* 设备特定前面板细节 */} + {renderDeviceFace()} + + {/* 设备背板细节 */} + {renderBackPanel()} + + + {/* 设备名称 (悬浮显示,避免遮挡细节) - 暂时禁用 */} + {/* {hovered && ( + + {device.name} + + )} */} + + {/* 状态指示灯 (统一位置) */} + + {/* 灯座 */} + + + + + {/* 发光体 */} + + + + + {/* 光晕效果 */} + + + + ); +}; + +export default DeviceModel; diff --git a/frontend/src/components/3d/LODManager.jsx b/frontend/src/components/3d/LODManager.jsx new file mode 100644 index 0000000..7158e4e --- /dev/null +++ b/frontend/src/components/3d/LODManager.jsx @@ -0,0 +1,152 @@ +import React, { useMemo, useRef } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import * as THREE from 'three'; + +export const LOD_LEVELS = { + HIGH: 0, + MEDIUM: 1, + LOW: 2 +}; + +export const LOD_DISTANCES = { + HIGH: 5, + MEDIUM: 10 +}; + +const createSimplifiedDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusColor) => { + const depth = rackDepth || 1.0; + const dHeight = device.height || device.u_height || 1; + const height = dHeight * uHeight; + const chassisWidth = 0.44; + const chassisDepth = 0.8; + const panelWidth = 0.4826; + const panelDepth = 0.02; + const frontZ = depth / 2 - 0.02; + const halfHeight = height / 2; + + return ( + + + + + + + + + + + + + + + ); +}; + +const createMediumDeviceMesh = (device, uHeight, rackDepth, deviceColor, statusColor) => { + const depth = rackDepth || 1.0; + const dHeight = device.height || device.u_height || 1; + const height = dHeight * uHeight; + const chassisWidth = 0.44; + const chassisDepth = 0.8; + const panelWidth = 0.4826; + const panelDepth = 0.02; + const frontZ = depth / 2 - 0.02; + const is2U = height > 0.08; + const halfHeight = height / 2; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + {Array.from({ length: is2U ? 4 : 2 }).map((_, i) => ( + + + + + ))} + + + + + + + ); +}; + +const LODManager = ({ + device, + uHeight, + rackDepth, + position, + deviceColor, + statusColor, + children, + level = LOD_LEVELS.HIGH +}) => { + const groupRef = useRef(); + const { camera } = useThree(); + const [lodLevel, setLodLevel] = React.useState(LOD_LEVELS.HIGH); + const distanceRef = useRef(0); + + useFrame(() => { + if (groupRef.current) { + const distance = camera.position.distanceTo(groupRef.current.position); + distanceRef.current = distance; + + let newLevel = LOD_LEVELS.HIGH; + if (distance > LOD_DISTANCES.MEDIUM) { + newLevel = LOD_LEVELS.LOW; + } else if (distance > LOD_DISTANCES.HIGH) { + newLevel = LOD_LEVELS.MEDIUM; + } + + if (newLevel !== lodLevel) { + setLodLevel(newLevel); + } + } + }); + + if (level !== LOD_LEVELS.HIGH) { + return children; + } + + const renderLODMesh = () => { + switch (lodLevel) { + case LOD_LEVELS.LOW: + return createSimplifiedDeviceMesh(device, uHeight, rackDepth, deviceColor, statusColor); + case LOD_LEVELS.MEDIUM: + return createMediumDeviceMesh(device, uHeight, rackDepth, deviceColor, statusColor); + default: + return null; + } + }; + + return ( + + {lodLevel === LOD_LEVELS.HIGH ? children : renderLODMesh()} + + ); +}; + +export default LODManager; diff --git a/frontend/src/components/3d/RackModel.jsx b/frontend/src/components/3d/RackModel.jsx new file mode 100644 index 0000000..4dc30d2 --- /dev/null +++ b/frontend/src/components/3d/RackModel.jsx @@ -0,0 +1,166 @@ +import React, { useMemo } from 'react'; +import DeviceModel from './DeviceModel'; +import LODManager, { LOD_LEVELS } from './LODManager'; + +const RackModel = ({ + rack, + devices = [], + selectedDeviceId, + onDeviceClick, + onDeviceLeave, + onDeviceHover, + onEditDevice, + onAddNic, + onAddPort, + tooltipFields, + deviceSlideEnabled = true +}) => { + const width = 0.6; + const depth = 1.0; + const uHeight = 0.04445; + const postWidth = 0.05; + + const rackHeight = rack?.height || 45; + const height = rackHeight * uHeight + 0.2; + + // 颜色映射 + const colors = { + server: '#3b82f6', + switch: '#22c55e', + router: '#f59e0b', + firewall: '#ef4444', + storage: '#8b5cf6', + default: '#3b82f6', + status: { + running: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + offline: '#6b7280' + } + }; + + const getDeviceColor = (type) => { + const t = type?.toLowerCase() || ''; + if (t.includes('server') || t.includes('服务器')) return colors.server; + if (t.includes('switch') || t.includes('交换机')) return colors.switch; + if (t.includes('router') || t.includes('路由器')) return colors.router; + if (t.includes('firewall') || t.includes('防火墙')) return colors.firewall; + if (t.includes('storage') || t.includes('存储')) return colors.storage; + return colors.default; + }; + + // 生成机柜框架 + const frame = useMemo(() => { + const materialProps = { color: "#333", roughness: 0.5, metalness: 0.8 }; + const postArgs = [postWidth, height, postWidth]; + const topBottomArgs = [width + 0.02, 0.02, depth + 0.02]; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + {[-1, 1].map((side) => ( + + + + + ))} + + + + + + + + + + + + ); + }, [width, height, depth, postWidth]); + + return ( + + {frame} + + + {devices.map((device) => { + const uStart = device.position || device.u_position || 1; + const uSize = device.height || device.u_height || 1; + const yPos = (uStart - 1) * uHeight + (uSize * uHeight) / 2; + + const deviceColor = getDeviceColor(device.type); + const statusColor = colors.status[device.status] || colors.status.running; + + return ( + + + + ); + })} + + + ); +}; + +export default RackModel; \ No newline at end of file diff --git a/frontend/src/components/3d/Scene.jsx b/frontend/src/components/3d/Scene.jsx new file mode 100644 index 0000000..9f19275 --- /dev/null +++ b/frontend/src/components/3d/Scene.jsx @@ -0,0 +1,67 @@ +import React, { Suspense } from 'react'; +import { Canvas } from '@react-three/fiber'; +import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei'; +const envMapUrl = '/assets/3d/env.hdr'; +import RackModel from './RackModel'; + +const Scene = ({ rack, devices, selectedDeviceId, onDeviceClick, onDeviceLeave, onDeviceHover, tooltipFields, deviceSlideEnabled = true }) => { + return ( + + + + + + + + + + + + {/* Models */} + + + + + {/* Controls */} + + + ); +}; + +export default Scene; \ No newline at end of file diff --git a/frontend/src/components/3d/materials/constants.jsx b/frontend/src/components/3d/materials/constants.jsx new file mode 100644 index 0000000..ee9cba6 --- /dev/null +++ b/frontend/src/components/3d/materials/constants.jsx @@ -0,0 +1,158 @@ +export const MATERIAL_TYPES = { + RACK_FRAME: 'rack_frame', + RAIL: 'rail', + RAIL_HOLE: 'rail_hole', + SIDE_PANEL: 'side_panel', + TOP_PLATE: 'top_plate', + BOTTOM_PLATE: 'bottom_plate', + DEVICE_CHASSIS: 'device_chassis', + DEVICE_PANEL: 'device_panel', + DEVICE_PANEL_SELECTED: 'device_panel_selected', + DRIVE_TRAY: 'drive_tray', + DRIVE_TRAY_HANDLE: 'drive_tray_handle', + LED_INDICATOR: 'led_indicator', + LED_ERROR: 'led_error', + SFP_PORT: 'sfp_port', + RJ45_PORT: 'rj45_port', + VENT_HOLE: 'vent_hole', + BACK_PANEL: 'back_panel', + PSU_MODULE: 'psu_module', + FAN_MODULE: 'fan_module', + TEXT_LABEL: 'text_label', +}; + +export const MATERIAL_CONFIGS = { + [MATERIAL_TYPES.RACK_FRAME]: { + color: '#2a2a2a', + metalness: 0.85, + roughness: 0.4, + envMapIntensity: 1.0, + }, + [MATERIAL_TYPES.RAIL]: { + color: '#3a3a3a', + metalness: 0.9, + roughness: 0.25, + envMapIntensity: 1.2, + }, + [MATERIAL_TYPES.RAIL_HOLE]: { + color: '#000000', + metalness: 0.0, + roughness: 0.9, + }, + [MATERIAL_TYPES.SIDE_PANEL]: { + color: '#1a1a1a', + metalness: 0.7, + roughness: 0.6, + envMapIntensity: 0.8, + }, + [MATERIAL_TYPES.TOP_PLATE]: { + color: '#222222', + metalness: 0.8, + roughness: 0.35, + envMapIntensity: 1.0, + }, + [MATERIAL_TYPES.BOTTOM_PLATE]: { + color: '#222222', + metalness: 0.8, + roughness: 0.35, + envMapIntensity: 1.0, + }, + [MATERIAL_TYPES.DEVICE_CHASSIS]: { + color: '#333333', + metalness: 0.4, + roughness: 0.85, + }, + [MATERIAL_TYPES.DEVICE_PANEL]: { + color: '#555555', + metalness: 0.15, + roughness: 0.75, + }, + [MATERIAL_TYPES.DEVICE_PANEL_SELECTED]: { + color: '#666666', + metalness: 0.2, + roughness: 0.7, + }, + [MATERIAL_TYPES.DRIVE_TRAY]: { + color: '#0f172a', + metalness: 0.3, + roughness: 0.8, + }, + [MATERIAL_TYPES.DRIVE_TRAY_HANDLE]: { + color: '#334155', + metalness: 0.5, + roughness: 0.5, + }, + [MATERIAL_TYPES.LED_INDICATOR]: { + color: '#10b981', + emissive: '#10b981', + emissiveIntensity: 0.8, + metalness: 0.0, + roughness: 0.3, + toneMapped: false, + }, + [MATERIAL_TYPES.LED_ERROR]: { + color: '#ef4444', + emissive: '#ef4444', + emissiveIntensity: 1.0, + metalness: 0.0, + roughness: 0.2, + toneMapped: false, + }, + [MATERIAL_TYPES.SFP_PORT]: { + color: '#3b82f6', + metalness: 0.8, + roughness: 0.2, + transparent: true, + opacity: 0.6, + }, + [MATERIAL_TYPES.RJ45_PORT]: { + color: '#1e293b', + metalness: 0.6, + roughness: 0.4, + }, + [MATERIAL_TYPES.VENT_HOLE]: { + color: '#1a202c', + metalness: 0.2, + roughness: 0.9, + }, + [MATERIAL_TYPES.BACK_PANEL]: { + color: '#64748b', + metalness: 0.8, + roughness: 0.3, + transparent: true, + opacity: 0.35, + }, + [MATERIAL_TYPES.PSU_MODULE]: { + color: '#475569', + metalness: 0.85, + roughness: 0.35, + }, + [MATERIAL_TYPES.FAN_MODULE]: { + color: '#1e293b', + metalness: 0.4, + roughness: 0.7, + }, + [MATERIAL_TYPES.TEXT_LABEL]: { + color: '#ffffff', + metalness: 0.0, + roughness: 1.0, + }, +}; + +export const DEVICE_TYPE_COLORS = { + server: '#3b82f6', + switch: '#22c55e', + router: '#f59e0b', + firewall: '#ef4444', + storage: '#8b5cf6', + default: '#3b82f6', +}; + +export const STATUS_COLORS = { + running: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + offline: '#6b7280', + maintenance: '#8b5cf6', + fault: '#ef4444', +}; diff --git a/frontend/src/components/3d/materials/devicePanel.jsx b/frontend/src/components/3d/materials/devicePanel.jsx new file mode 100644 index 0000000..8ac5594 --- /dev/null +++ b/frontend/src/components/3d/materials/devicePanel.jsx @@ -0,0 +1,90 @@ +import * as THREE from 'three'; +import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js'; + +export const createDeviceChassisMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_CHASSIS]; + return ; +}; + +export const createDevicePanelMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_PANEL]; + return ; +}; + +export const createDevicePanelSelectedMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DEVICE_PANEL_SELECTED]; + return ; +}; + +export const createDriveTrayMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DRIVE_TRAY]; + return ; +}; + +export const createDriveTrayHandleMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.DRIVE_TRAY_HANDLE]; + return ; +}; + +export const createLedIndicatorMaterial = (color = '#10b981', options = {}) => { + return ; +}; + +export const createLedErrorMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.LED_ERROR]; + return ; +}; + +export const createSfpPortMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.SFP_PORT]; + return ; +}; + +export const createRj45PortMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RJ45_PORT]; + return ; +}; + +export const createVentHoleMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.VENT_HOLE]; + return ; +}; + +export const createBackPanelMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.BACK_PANEL]; + return ; +}; + +export const createPsuModuleMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.PSU_MODULE]; + return ; +}; + +export const createFanModuleMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.FAN_MODULE]; + return ; +}; + +export const createConsolePortMaterial = (color = '#facc15') => { + return ; +}; + +export const createUsbPortMaterial = (color = '#94a3b8') => { + return ; +}; + +export const createResetButtonMaterial = (color = '#ef4444') => { + return ; +}; + +export const createPcieCoverMaterial = (color = '#cbd5e1') => { + return ; +}; + +export const createPortLedMaterial = (color = '#4ade80') => { + return ; +}; + +export const createPortLinkLedMaterial = (color = '#22c55e') => { + return ; +}; diff --git a/frontend/src/components/3d/materials/index.jsx b/frontend/src/components/3d/materials/index.jsx new file mode 100644 index 0000000..a65184c --- /dev/null +++ b/frontend/src/components/3d/materials/index.jsx @@ -0,0 +1,4 @@ +export * from './constants.js'; +export * from './utils.js'; +export * from './rackFrame.js'; +export * from './devicePanel.js'; diff --git a/frontend/src/components/3d/materials/rackFrame.jsx b/frontend/src/components/3d/materials/rackFrame.jsx new file mode 100644 index 0000000..697a27b --- /dev/null +++ b/frontend/src/components/3d/materials/rackFrame.jsx @@ -0,0 +1,48 @@ +import * as THREE from 'three'; +import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js'; + +export const createRackFrameMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RACK_FRAME]; + return ; +}; + +export const createRailMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RAIL]; + return ; +}; + +export const createRailHoleMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.RAIL_HOLE]; + return ; +}; + +export const createSidePanelMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.SIDE_PANEL]; + return ; +}; + +export const createTopPlateMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.TOP_PLATE]; + return ; +}; + +export const createBottomPlateMaterial = (options = {}) => { + const config = MATERIAL_CONFIGS[MATERIAL_TYPES.BOTTOM_PLATE]; + return ; +}; + +export const createTextLabelMaterial = (color = '#ffffff') => { + return ; +}; + +export const createUMarkMaterial = () => { + return ; +}; + +export const createOutlineMaterial = (color = '#3b82f6', opacity = 0.8) => { + return ; +}; + +export const createSelectionMaterial = (color = '#3b82f6', opacity = 0.3) => { + return ; +}; diff --git a/frontend/src/components/3d/materials/utils.jsx b/frontend/src/components/3d/materials/utils.jsx new file mode 100644 index 0000000..d802a1d --- /dev/null +++ b/frontend/src/components/3d/materials/utils.jsx @@ -0,0 +1,26 @@ +import { MATERIAL_CONFIGS, MATERIAL_TYPES } from './constants.js'; + +export const getMaterialConfig = (type) => { + return MATERIAL_CONFIGS[type] || null; +}; + +export const getMaterialType = (type) => { + return MATERIAL_TYPES[type] || null; +}; + +export const createMaterialConfig = (type, options = {}) => { + const baseConfig = MATERIAL_CONFIGS[type]; + if (!baseConfig) { + console.warn(`Material type "${type}" not found in configurations`); + return null; + } + return { ...baseConfig, ...options }; +}; + +export const mergeMaterialOptions = (type, options = {}) => { + const baseConfig = MATERIAL_CONFIGS[type]; + if (!baseConfig) { + return options; + } + return { ...baseConfig, ...options }; +}; diff --git a/frontend/src/components/CableCreateModal.jsx b/frontend/src/components/CableCreateModal.jsx new file mode 100644 index 0000000..9fed575 --- /dev/null +++ b/frontend/src/components/CableCreateModal.jsx @@ -0,0 +1,255 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Modal, Form, Select, Input, Button, message, Space, Spin } from 'antd'; +import { SwapOutlined } from '@ant-design/icons'; +import axios from 'axios'; + +const { Option } = Select; + +const CableCreateModal = ({ visible, onClose, onSuccess, sourceDevice }) => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [devices, setDevices] = useState([]); + const [sourcePorts, setSourcePorts] = useState([]); + const [targetPorts, setTargetPorts] = useState([]); + const [fetchingDevices, setFetchingDevices] = useState(false); + const prevVisibleRef = useRef(false); + + // Initialize when visible changes from false to true + useEffect(() => { + if (visible && !prevVisibleRef.current) { + form.resetFields(); + if (sourceDevice) { + form.setFieldsValue({ + sourceDeviceId: sourceDevice.deviceId || sourceDevice.id, + }); + fetchDevicePorts(sourceDevice.deviceId || sourceDevice.id, 'source'); + } + fetchDevices(); + } + prevVisibleRef.current = visible; + }, [visible, sourceDevice, form]); + + const fetchDevices = async () => { + try { + setFetchingDevices(true); + const response = await axios.get('/api/devices', { params: { pageSize: 1000 } }); + setDevices(response.data.devices || []); + } catch (error) { + console.error('Failed to fetch devices:', error); + message.error('获取设备列表失败'); + } finally { + setFetchingDevices(false); + } + }; + + const fetchDevicePorts = async (deviceId, type) => { + if (!deviceId) { + if (type === 'source') setSourcePorts([]); + else setTargetPorts([]); + return; + } + + try { + const response = await axios.get(`/api/device-ports/device/${deviceId}`); + const ports = response.data || []; + if (type === 'source') { + setSourcePorts(ports); + } else { + setTargetPorts(ports); + } + } catch (error) { + console.error(`Failed to fetch ${type} ports:`, error); + message.error('获取端口列表失败'); + } + }; + + const handleSourceDeviceChange = (deviceId) => { + form.setFieldsValue({ sourcePort: undefined }); + fetchDevicePorts(deviceId, 'source'); + }; + + const handleTargetDeviceChange = (deviceId) => { + form.setFieldsValue({ targetPort: undefined }); + fetchDevicePorts(deviceId, 'target'); + }; + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + setLoading(true); + + // Logic to ensure Switch is always Source if possible (for Cable Management view consistency) + const sourceDev = devices.find(d => d.deviceId === values.sourceDeviceId); + const targetDev = devices.find(d => d.deviceId === values.targetDeviceId); + + let payload = { ...values }; + + // If Source is NOT Switch AND Target IS Switch, swap them + if (sourceDev && targetDev && + sourceDev.type !== 'switch' && + targetDev.type === 'switch') { + + payload = { + ...values, + sourceDeviceId: values.targetDeviceId, + sourcePort: values.targetPort, + targetDeviceId: values.sourceDeviceId, + targetPort: values.sourcePort + }; + console.log('Swapped source/target to ensure Switch is Source'); + } + + await axios.post('/api/cables', payload); + + message.success('接线创建成功'); + onSuccess?.(); + onClose(); + } catch (error) { + if (error.errorFields) return; + console.error('Failed to create cable:', error); + message.error('接线创建失败'); + } finally { + setLoading(false); + } + }; + + return ( + + + 新增接线 + + } + open={visible} + onCancel={onClose} + onOk={handleSubmit} + confirmLoading={loading} + width={600} + maskClosable={false} + > +
+
+ {/* Source Side */} +
+
源设备 (起点)
+ + + + + + +
+ + {/* Target Side */} +
+
目标设备 (终点)
+ + + + + + +
+
+ +
+
+ + + + + + + + + +
+ + + +
+
+
+ ); +}; + +export default CableCreateModal; diff --git a/frontend/src/components/DeviceComponent.jsx b/frontend/src/components/DeviceComponent.jsx deleted file mode 100644 index 3e6c74e..0000000 --- a/frontend/src/components/DeviceComponent.jsx +++ /dev/null @@ -1,371 +0,0 @@ -import React from 'react'; -import { - CloudServerOutlined, SwitcherOutlined, DatabaseOutlined, - CloudOutlined, LaptopOutlined, MobileOutlined, - PrinterOutlined -} from '@ant-design/icons'; - -// 设备图标映射 -const getDeviceIcon = (deviceType) => { - try { - if (!deviceType) return ; - const type = deviceType.toLowerCase(); - - if (type.includes('server') || type.includes('服务器')) return ; - if (type.includes('switch') || type.includes('交换机')) return ; - if (type.includes('storage') || type.includes('存储')) return ; - if (type.includes('router') || type.includes('路由器')) return ; - if (type.includes('laptop') || type.includes('笔记本')) return ; - if (type.includes('mobile') || type.includes('手机')) return ; - if (type.includes('printer') || type.includes('打印机')) return ; - - return ; - } catch (error) { - console.error('设备图标渲染错误:', error); - return ; - } -}; - -// 设备状态颜色映射 -const getDeviceStatusColor = (status) => { - const statusColorMap = { - 'normal': '#10b981', - 'running': '#10b981', - 'warning': '#f59e0b', - 'error': '#ef4444', - 'fault': '#ef4444', - 'offline': '#6b7280', - 'maintenance': '#3b82f6', - undefined: '#3b82f6', - null: '#3b82f6' - }; - return statusColorMap[status] || '#3b82f6'; -}; - -// 设备状态主题 -const getStatusTheme = (status) => { - const themeMap = { - 'normal': { - bgGradient: 'linear-gradient(180deg, #059669 0%, #047857 50%, #065f46 100%)', - borderColor: '#10b981', - topBorderColor: '#34d399', - glowColor: 'rgba(16, 185, 129, 0.4)', - shadowColor: 'rgba(16, 185, 129, 0.3)', - iconColor: '#10b981', - label: '正常' - }, - 'running': { - bgGradient: 'linear-gradient(180deg, #059669 0%, #047857 50%, #065f46 100%)', - borderColor: '#10b981', - topBorderColor: '#34d399', - glowColor: 'rgba(16, 185, 129, 0.4)', - shadowColor: 'rgba(16, 185, 129, 0.3)', - iconColor: '#10b981', - label: '运行中' - }, - 'warning': { - bgGradient: 'linear-gradient(180deg, #d97706 0%, #b45309 50%, #92400e 100%)', - borderColor: '#f59e0b', - topBorderColor: '#fbbf24', - glowColor: 'rgba(245, 158, 11, 0.4)', - shadowColor: 'rgba(245, 158, 11, 0.3)', - iconColor: '#f59e0b', - label: '警告' - }, - 'error': { - bgGradient: 'linear-gradient(180deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%)', - borderColor: '#ef4444', - topBorderColor: '#f87171', - glowColor: 'rgba(239, 68, 68, 0.5)', - shadowColor: 'rgba(239, 68, 68, 0.4)', - iconColor: '#ef4444', - label: '故障' - }, - 'fault': { - bgGradient: 'linear-gradient(180deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%)', - borderColor: '#ef4444', - topBorderColor: '#f87171', - glowColor: 'rgba(239, 68, 68, 0.5)', - shadowColor: 'rgba(239, 68, 68, 0.4)', - iconColor: '#ef4444', - label: '故障' - }, - 'offline': { - bgGradient: 'linear-gradient(180deg, #4b5563 0%, #374151 50%, #1f2937 100%)', - borderColor: '#6b7280', - topBorderColor: '#9ca3af', - glowColor: 'rgba(107, 114, 128, 0.2)', - shadowColor: 'rgba(0, 0, 0, 0.2)', - iconColor: '#9ca3af', - label: '离线' - }, - 'maintenance': { - bgGradient: 'linear-gradient(180deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%)', - borderColor: '#3b82f6', - topBorderColor: '#60a5fa', - glowColor: 'rgba(59, 130, 246, 0.4)', - shadowColor: 'rgba(59, 130, 246, 0.3)', - iconColor: '#3b82f6', - label: '维护中' - }, - 'default': { - bgGradient: 'linear-gradient(180deg, #3d4451 0%, #2d3139 50%, #252930 100%)', - borderColor: '#4a5568', - topBorderColor: '#565c6b', - glowColor: 'rgba(56, 189, 248, 0.2)', - shadowColor: 'rgba(0, 0, 0, 0.3)', - iconColor: '#38bdf8', - label: '未知' - } - }; - - return themeMap[status] || themeMap['default']; -}; - -// 设备样式计算 -const getDeviceStyle = (device, rackHeight) => { - // 添加参数验证 - if (!device || typeof device.position !== 'number' || typeof device.height !== 'number') { - return {}; - } - - const uHeight = 25; // 调整为更小的U高度以适应屏幕显示,1U=25px - const deviceHeight = Math.max(1, device.height) * uHeight; - - // 设备位置从底部开始计算(U1在底部) - let position = Math.max(1, device.position); - let deviceUHeight = Math.max(1, device.height); - - // 确保设备不会超出机柜范围 - if (position + deviceUHeight - 1 > rackHeight) { - // 如果设备会超出机柜,调整位置或高度 - position = Math.max(1, rackHeight - deviceUHeight + 1); - } - - // 计算设备的顶部位置(从机柜顶部算起) - // 设备占用从 position 到 position + height - 1 的U - // 机柜顶部是U0,所以设备顶部的topPosition是: - const deviceBottomU = position; // 设备底部U数 - const deviceTopU = position + deviceUHeight - 1; // 设备顶部U数 - const topPosition = (rackHeight - deviceTopU) * uHeight; - - return { - height: `${deviceHeight}px`, // 确保设备高度精确等于U位高度 - top: `${topPosition}px`, // 确保设备顶部与U位网格线对齐 - // 移除任何可能影响占满U位的样式 - margin: 0, - padding: 0 - }; -}; - -// 设备组件 -const DeviceComponent = ({ device, rackHeight, isHighlighted, onMouseEnter, onMouseLeave }) => { - try { - // 处理统一化后的设备数据 - const deviceId = device?.deviceId || device?.id || device?.device_id || device?.device || `device-${Math.random()}`; - const deviceName = device?.name || device?.deviceName || device?.device_name || device?.title || '未知设备'; - const position = device?.position || 1; - const height = device?.height || 1; - - // 获取状态主题 - const statusTheme = getStatusTheme(device?.status); - const statusColor = getDeviceStatusColor(device?.status); - - return ( -
{ - const isOneU = (device?.height || 1) === 1; - const isFaultStatus = device?.status === 'error' || device?.status === 'fault'; - if (isOneU && !isFaultStatus) { - e.currentTarget.style.height = '33px'; - e.currentTarget.style.zIndex = '150'; - } - e.currentTarget.style.transform = 'scale(1.008)'; - e.currentTarget.style.boxShadow = ` - 0 4px 12px ${statusTheme.shadowColor}, - 0 2px 6px rgba(0,0,0,0.3), - inset 0 1px 0 rgba(255,255,255,0.15) - `; - e.currentTarget.style.borderColor = statusTheme.topBorderColor; - - if (onMouseEnter) { - const rect = e.currentTarget.getBoundingClientRect(); - onMouseEnter({ device, position: { x: rect.right + 5, y: rect.top + rect.height / 2 } }); - } - }} - onMouseLeave={(e) => { - const isOneU = (device?.height || 1) === 1; - const isFaultStatus = device?.status === 'error' || device?.status === 'fault'; - if (isOneU && !isFaultStatus) { - const originalHeight = (device?.height || 1) * 25; - e.currentTarget.style.height = `${originalHeight}px`; - e.currentTarget.style.zIndex = '100'; - } - e.currentTarget.style.transform = 'scale(1)'; - e.currentTarget.style.boxShadow = ` - 0 1px 2px rgba(0,0,0,0.3), - 0 2px 4px rgba(0,0,0,0.2), - inset 0 1px 0 rgba(255,255,255,0.1), - inset 0 -1px 0 rgba(0,0,0,0.1) - `; - e.currentTarget.style.borderColor = statusTheme.borderColor; - if (onMouseLeave) { - onMouseLeave(); - } - }} - > -
- - {/* 左侧状态指示区域 - 增强版 */} -
- {/* 设备类型标识 */} -
- - {device.type?.toUpperCase() || 'DEV'} - -
- - {/* LED状态指示灯组 */} -
- {/* 主状态灯 */} -
-
-
-
-
- - {/* 电源指示灯 */} -
-
- - PWR - -
-
- - {/* 设备序列号标签 */} -
- - SN:{device.serial?.slice(-4) || '0000'} - -
-
- - {/* 中间设备信息区域 - 增强版 */} -
- {/* 设备品牌/厂商标识 */} -
-
- {getDeviceIcon(device.type)} -
- - {device.brand || 'ENTERPRISE'} - -
- - {/* 设备名称 */} -
- {deviceName} -
- - {/* 型号和规格 */} -
- - {device.model || device.type?.toUpperCase() || 'STD'} - - {device.ip && ( - - {device.ip} - - )} -
- - {/* 散热/通风口装饰 - 根据设备类型显示 */} - {(device.type === 'server' || device.type === 'storage') && ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
- ))} -
- )} -
- - {/* 右侧端口/功能区域 - 增强版 */} -
- {/* 端口指示灯阵列 */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
- ))} -
- - {/* 管理接口标识 */} -
-
- - MGMT - -
- - {/* 设备高度U数标识 */} -
- {device.height}U -
-
-
- ); - } catch (error) { - console.error('设备渲染错误:', error, device); - // 渲染一个简单的错误显示元素 - return ( -
- 设备加载错误 -
- ); - } - }; - -// 默认导出 -export default DeviceComponent; \ No newline at end of file diff --git a/frontend/src/components/DeviceDetailDrawer.jsx b/frontend/src/components/DeviceDetailDrawer.jsx index d5b11ab..947b153 100644 --- a/frontend/src/components/DeviceDetailDrawer.jsx +++ b/frontend/src/components/DeviceDetailDrawer.jsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useMemo } from 'react'; -import { Drawer, Tabs, Tag, Space, Typography, Empty, Card, Tooltip } from 'antd'; -import { ApiOutlined, CloudServerOutlined, EnvironmentOutlined } from '@ant-design/icons'; +import { Drawer, Tabs, Tag, Space, Typography, Empty, Card, Tooltip, Button, Popconfirm } from 'antd'; +import { ApiOutlined, CloudServerOutlined, EnvironmentOutlined, EditOutlined, PlusCircleOutlined, DeleteOutlined } from '@ant-design/icons'; import NetworkCardPanel from './NetworkCardPanel'; const { Text, Title } = Typography; @@ -18,7 +18,7 @@ const designTokens = { } }; -function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables }) { +function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables, onEdit, onAddNic, onAddPort, onAddCable, onDeleteCable, tooltipFields, refreshTrigger }) { const [activeTab, setActiveTab] = useState('ports'); const deviceCables = useMemo(() => { @@ -55,6 +55,34 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables return typeMap[type?.toLowerCase()] || type || '未知设备'; }, []); + const renderFieldValue = useCallback((field, device) => { + const fieldKey = field.field; + if (fieldKey === 'status') return getStatusTag(device.status); + + let value = device[fieldKey]; + + if (fieldKey === 'type') value = getDeviceTypeName(value); + else if (fieldKey === 'position') value = `U${device.position} ${device.height ? `(${device.height}U)` : ''}`; + + return {value || '-'}; + }, [getStatusTag, getDeviceTypeName]); + + const displayFields = useMemo(() => { + if (tooltipFields && Object.keys(tooltipFields).length > 0) { + return Object.values(tooltipFields).filter(f => f.enabled); + } + + // Default fallback fields if no config + return [ + { field: 'deviceId', label: '设备ID' }, + { field: 'type', label: '设备类型' }, + { field: 'status', label: '设备状态' }, + { field: 'position', label: '位置' }, + { field: 'ipAddress', label: 'IP地址' }, + { field: 'brand', label: '品牌' } + ]; + }, [tooltipFields]); + const tabItems = [ { key: 'ports', @@ -69,6 +97,7 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables deviceId={device?.deviceId} deviceName={device?.name} onRefresh={onRefreshCables} + refreshTrigger={refreshTrigger} /> ) }, @@ -91,6 +120,16 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables key={cable.cableId} size="small" style={{ borderRadius: '8px' }} + extra={ + onDeleteCable?.(cable.cableId)} + okText="确定" + cancelText="取消" + > + + + + + + + + + + } styles={{ body: { padding: '16px 20px', overflow: 'auto' } }} >
@@ -165,36 +220,12 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables padding: '16px', borderRadius: '10px' }}> -
- 设备ID - {device.deviceId} -
-
- 设备类型 - {getDeviceTypeName(device.type)} -
-
- 设备状态 - {getStatusTag(device.status)} -
-
- 位置 - - U{device.position} {device.height && `(${device.height}U)`} - -
- {device.ipAddress && ( -
- IP地址 - {device.ipAddress} + {displayFields.map(field => ( +
+ {field.label} + {renderFieldValue(field, device)}
- )} - {device.brand && ( -
- 品牌 - {device.brand} -
- )} + ))}
diff --git a/frontend/src/components/NetworkCardPanel.jsx b/frontend/src/components/NetworkCardPanel.jsx index 3c0a338..d771309 100644 --- a/frontend/src/components/NetworkCardPanel.jsx +++ b/frontend/src/components/NetworkCardPanel.jsx @@ -18,7 +18,7 @@ const designTokens = { } }; -function NetworkCardPanel({ deviceId, deviceName, onRefresh }) { +function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) { const [cards, setCards] = useState([]); const [networkCards, setNetworkCards] = useState([]); const [loading, setLoading] = useState(false); @@ -56,7 +56,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh }) { useEffect(() => { fetchData(); - }, [fetchData]); + }, [fetchData, refreshTrigger]); const handleDeleteCard = useCallback(async (card) => { try { diff --git a/frontend/src/components/PortCreateModal.jsx b/frontend/src/components/PortCreateModal.jsx index 7323735..05aac55 100644 --- a/frontend/src/components/PortCreateModal.jsx +++ b/frontend/src/components/PortCreateModal.jsx @@ -84,9 +84,11 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne const [previewPorts, setPreviewPorts] = useState([]); const [showPreview, setShowPreview] = useState(false); const [nicList, setNicList] = useState([]); + const prevVisibleRef = React.useRef(false); useEffect(() => { - if (visible) { + // 只有当 visible 从 false 变为 true 时才执行初始化 + if (visible && !prevVisibleRef.current) { setPreviewPorts([]); setShowPreview(false); form.resetFields(); @@ -95,12 +97,13 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne form.setFieldsValue({ nicId: defaultNicId }); } - if (device?.deviceId && networkCards.length === 0) { + if (device?.deviceId && (!networkCards || networkCards.length === 0)) { fetchNetworkCards(); - } else if (networkCards.length > 0) { + } else if (networkCards && networkCards.length > 0) { setNicList(networkCards); } } + prevVisibleRef.current = visible; }, [visible, device, defaultNicId, networkCards, form]); const fetchNetworkCards = async () => { @@ -205,6 +208,7 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne okText={portCount > 1 ? `创建 ${portCount} 个端口` : '创建'} cancelText="取消" width={560} + zIndex={1050} styles={{ body: { padding: '20px 24px' } }} >
端口名称 - + @@ -284,7 +288,11 @@ function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, ne > { + // 确保 Form 值更新 + form.setFieldValue('portName', e.target.value); + handlePortNameChange(e); + }} /> diff --git a/frontend/src/pages/Rack3DVisualization.jsx b/frontend/src/pages/Rack3DVisualization.jsx new file mode 100644 index 0000000..921b218 --- /dev/null +++ b/frontend/src/pages/Rack3DVisualization.jsx @@ -0,0 +1,862 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { Layout, Select, Card, Spin, message, Typography, Descriptions, Tag, Button, Space, Empty, Modal, Form, Input, InputNumber, DatePicker, Checkbox, Switch } from 'antd'; +import { CloudServerOutlined, ReloadOutlined, ArrowLeftOutlined, InfoCircleOutlined, UpOutlined, DownOutlined, EditOutlined, SettingOutlined, FullscreenOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import axios from 'axios'; +import dayjs from 'dayjs'; +import Scene from '../components/3d/Scene'; +import NetworkCardCreateModal from '../components/NetworkCardCreateModal'; +import PortCreateModal from '../components/PortCreateModal'; +import CableCreateModal from '../components/CableCreateModal'; +import DeviceDetailDrawer from '../components/DeviceDetailDrawer'; + +const { Header, Content, Sider } = Layout; +const { Title, Text } = Typography; +const { Option } = Select; + +const Rack3DVisualization = () => { + const navigate = useNavigate(); + const [racks, setRacks] = useState([]); + const [selectedRack, setSelectedRack] = useState(null); + const [selectedRoom, setSelectedRoom] = useState(null); + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingDevices, setLoadingDevices] = useState(false); + const [selectedDevice, setSelectedDevice] = useState(null); // Currently selected device in 3D view + const [hoveredDevice, setHoveredDevice] = useState(null); // Currently hovered device + const [isRackInfoCollapsed, setIsRackInfoCollapsed] = useState(false); // Rack Info Card collapsed state + + // Edit Modal State + const [modalVisible, setModalVisible] = useState(false); + const [editingDevice, setEditingDevice] = useState(null); + const [form] = Form.useForm(); + + // Field configuration state + const [tooltipFields, setTooltipFields] = useState({}); + const [showTooltipConfig, setShowTooltipConfig] = useState(false); + const [loadingTooltipFields, setLoadingTooltipFields] = useState(false); + const [savingTooltipConfig, setSavingTooltipConfig] = useState(false); + + // Add Port/NIC/Cable Modal State + const [nicModalVisible, setNicModalVisible] = useState(false); + const [portModalVisible, setPortModalVisible] = useState(false); + const [cableModalVisible, setCableModalVisible] = useState(false); + const [operatingDevice, setOperatingDevice] = useState(null); + const [refreshTrigger, setRefreshTrigger] = useState(0); + + // Device Cables State + const [deviceCables, setDeviceCables] = useState([]); + + // Device Slide Animation Toggle + const [deviceSlideEnabled, setDeviceSlideEnabled] = useState(false); + + const fetchDeviceCables = useCallback(async (deviceId) => { + if (!deviceId) return; + try { + const response = await axios.get(`/api/cables/device/${deviceId}`); + setDeviceCables(response.data || []); + } catch (error) { + console.error('Failed to fetch device cables:', error); + setDeviceCables([]); + } + }, []); + + const handleAddNic = (device) => { + setOperatingDevice(device); + setNicModalVisible(true); + }; + + const handleAddPort = (device) => { + setOperatingDevice(device); + setPortModalVisible(true); + }; + + const handleAddCable = (device) => { + setOperatingDevice(device); + setCableModalVisible(true); + }; + + // Handle Edit Device Click + const handleEditDevice = (device) => { + setEditingDevice(device); + + // Prepare form data (similar to DeviceManagement) + const deviceData = { ...device }; + if (deviceData.purchaseDate) deviceData.purchaseDate = dayjs(deviceData.purchaseDate); + if (deviceData.warrantyExpiry) deviceData.warrantyExpiry = dayjs(deviceData.warrantyExpiry); + + // Fixed fields + const fixedFields = [ + 'deviceId', 'name', 'type', 'model', 'serialNumber', 'rackId', + 'position', 'height', 'powerConsumption', 'status', 'purchaseDate', + 'warrantyExpiry', 'ipAddress', 'description' + ]; + + const cleanDeviceData = {}; + fixedFields.forEach(field => { + if (deviceData[field] !== undefined) { + cleanDeviceData[field] = deviceData[field]; + } + }); + + // Custom fields if any (simplified for 3D view context) + if (deviceData.customFields && typeof deviceData.customFields === 'object') { + Object.entries(deviceData.customFields).forEach(([key, value]) => { + cleanDeviceData[key] = value; + }); + } + + form.setFieldsValue(cleanDeviceData); + setModalVisible(true); + }; + + const handleModalCancel = () => { + setModalVisible(false); + setEditingDevice(null); + form.resetFields(); + }; + + const handleModalSubmit = async (values) => { + try { + const deviceData = { + ...values, + purchaseDate: values.purchaseDate ? values.purchaseDate.format('YYYY-MM-DD') : null, + warrantyExpiry: values.warrantyExpiry ? values.warrantyExpiry.format('YYYY-MM-DD') : null, + }; + + if (editingDevice) { + await axios.put(`/api/devices/${editingDevice.deviceId || editingDevice.id}`, deviceData); + message.success('设备更新成功'); + + // Refresh devices + if (selectedRack) { + fetchDevices(selectedRack.rackId); + } + setModalVisible(false); + setEditingDevice(null); + } + } catch (error) { + message.error('设备更新失败'); + console.error(error); + } + }; + + // Fetch Racks + const fetchRacks = useCallback(async () => { + try { + setLoading(true); + const response = await axios.get('/api/racks', { params: { pageSize: 1000 } }); + const racksArray = response.data.racks || []; + // Filter valid racks + const validRacks = racksArray.filter(r => r && r.rackId && typeof r.height === 'number'); + setRacks(validRacks); + + if (validRacks.length > 0) { + // Default select first rack + const firstRack = validRacks[0]; + setSelectedRack(firstRack); + if (firstRack?.Room) { + const roomKey = firstRack.Room.roomId || firstRack.Room.id || firstRack.Room.name; + setSelectedRoom(roomKey); + } + } + } catch (error) { + console.error('Failed to fetch racks:', error); + message.error('获取机柜列表失败'); + } finally { + setLoading(false); + } + }, []); + + // Fetch Devices for Rack + const fetchDevices = useCallback(async (rackId) => { + if (!rackId) return; + try { + setLoadingDevices(true); + const response = await axios.get(`/api/devices?rackId=${rackId}`); + + let devicesData = []; + if (Array.isArray(response.data)) { + devicesData = response.data; + } else if (response.data && Array.isArray(response.data.data)) { + devicesData = response.data.data; + } else if (response.data && Array.isArray(response.data.devices)) { + devicesData = response.data.devices; + } + + // Process devices (ensure position/height are numbers) + const validDevices = devicesData.filter(d => { + if (!d.deviceId && !d.id) return false; + + // Normalize deviceId + if (!d.deviceId && d.id) d.deviceId = d.id; + + let pos = d.position || d.u_position; + let h = d.height || d.u_height || 1; + if (typeof pos === 'string') pos = parseInt(pos, 10); + if (typeof h === 'string') h = parseInt(h, 10); + + // Mutate for consistency + d.position = pos; + d.height = h; + + return typeof pos === 'number' && !isNaN(pos); + }); + + setDevices(validDevices); + } catch (error) { + console.error('Failed to fetch devices:', error); + message.error('获取设备列表失败'); + setDevices([]); + } finally { + setLoadingDevices(false); + } + }, []); + + // Fetch Tooltip Fields from API + const fetchTooltipDeviceFields = useCallback(async () => { + try { + setLoadingTooltipFields(true); + const response = await axios.get('/api/deviceFields'); + const fields = response.data; + + const newTooltipFields = {}; + if (fields && fields.length > 0) { + fields.forEach(field => { + newTooltipFields[field.fieldName] = { + label: field.displayName, + enabled: field.visible, + field: field.fieldName, + fieldType: field.fieldType || 'text' + }; + }); + } + setTooltipFields(newTooltipFields); + } catch (error) { + console.error('Failed to fetch device fields:', error); + message.error('获取字段配置失败'); + } finally { + setLoadingTooltipFields(false); + } + }, []); + + const saveTooltipConfig = async () => { + try { + setSavingTooltipConfig(true); + + // Transform object to array for backend API + const fieldConfigs = Object.values(tooltipFields).map(field => ({ + fieldName: field.field, + visible: field.enabled, + displayName: field.label, + fieldType: field.fieldType + })); + + await axios.post('/api/deviceFields/config', fieldConfigs); + message.success('字段配置保存成功'); + setShowTooltipConfig(false); + + // Refresh fields to sync with DB + fetchTooltipDeviceFields(); + } catch (error) { + console.error('Failed to save tooltip config:', error); + message.error('保存失败'); + } finally { + setSavingTooltipConfig(false); + } + }; + + useEffect(() => { + fetchRacks(); + fetchTooltipDeviceFields(); + }, [fetchRacks, fetchTooltipDeviceFields]); + + useEffect(() => { + if (selectedRack?.rackId) { + fetchDevices(selectedRack.rackId); + setSelectedDevice(null); // Reset selection on rack change + } + }, [selectedRack, fetchDevices]); + + // Group racks by room + const rooms = useMemo(() => { + const roomMap = new Map(); + racks.forEach(rack => { + if (rack?.Room) { + const roomKey = rack.Room.roomId || rack.Room.id || rack.Room.name; + if (!roomMap.has(roomKey)) { + roomMap.set(roomKey, { ...rack.Room, key: roomKey }); + } + } + }); + return Array.from(roomMap.values()); + }, [racks]); + + const racksInSelectedRoom = useMemo(() => { + if (!selectedRoom) return []; + return racks.filter(r => { + const rKey = r.Room?.roomId || r.Room?.id || r.Room?.name; + return rKey === selectedRoom; + }); + }, [selectedRoom, racks]); + + const handleDeviceClick = (device) => { + setSelectedDevice(device); + if (device) { + fetchDeviceCables(device.deviceId || device.id); + } + }; + + const handleDeviceLeave = (device) => { + // 移除鼠标离开事件,避免抽屉意外关闭 + // setSelectedDevice((current) => { + // const deviceId = device.deviceId || device.id; + // const currentId = current?.deviceId || current?.id; + // return currentId === deviceId ? null : current; + // }); + }; + + const handleDeviceHover = useCallback((device) => { + if (modalVisible || nicModalVisible || portModalVisible) return; + setHoveredDevice(device); + }, [modalVisible, nicModalVisible, portModalVisible]); + + const handleClosePortModal = useCallback(() => { + setPortModalVisible(false); + setOperatingDevice(null); + }, []); + + const handlePortSuccess = useCallback(() => { + message.success('端口添加成功'); + }, []); + + const handleCloseNicModal = useCallback(() => { + setNicModalVisible(false); + setOperatingDevice(null); + }, []); + + const handleNicSuccess = useCallback(() => { + message.success('网卡添加成功'); + }, []); + + const handleCloseCableModal = useCallback(() => { + setCableModalVisible(false); + setOperatingDevice(null); + }, []); + + const handleCableSuccess = useCallback(() => { + message.success('接线添加成功'); + setRefreshTrigger(prev => prev + 1); + if (selectedDevice) { + fetchDeviceCables(selectedDevice.deviceId || selectedDevice.id); + } + }, [selectedDevice, fetchDeviceCables]); + + const handleDeleteCable = useCallback(async (cableId) => { + try { + await axios.delete(`/api/cables/${cableId}`); + message.success('接线删除成功'); + setRefreshTrigger(prev => prev + 1); + if (selectedDevice) { + fetchDeviceCables(selectedDevice.deviceId || selectedDevice.id); + } + } catch (error) { + console.error('删除接线失败:', error); + message.error('删除接线失败'); + } + }, [selectedDevice, fetchDeviceCables]); + + return ( + +
+
+
+ + + + +
+ + 设备弹出 + +
+ +
+
+ + {/* Inject custom styles for glass selects */} + + + + + {loading ? ( +
+ +
加载资源中...
+
+ ) : selectedRack ? ( + <> + + + {/* Rack Info Overlay (New) */} + {selectedRack && ( +
isRackInfoCollapsed && setIsRackInfoCollapsed(false)} + > + {/* Header */} +
+
+
+ +
+
+
+ {selectedRack.name} +
+
+ ID: {selectedRack.rackId} +
+
+
+ +
+ + {/* Content Area - Collapsible */} +
+ {/* Stats Grid */} +
+
+
总高度
+
+ {selectedRack.height}U +
+
+
+
设备数
+
+ {devices.length} +
+
+
+ + {/* Guide Section */} +
+
+ 操作指南 +
+
+ {[ + { icon: '🖱️', text: '左键旋转 / 中键平移' }, + { icon: '🔍', text: '滚轮缩放视图' }, + { icon: '👆', text: '点击设备查看详情' } + ].map((item, i) => ( +
+ {item.icon} + {item.text} +
+ ))} +
+
+
+
+ )} + + {/* Overlay Info for Hovered Device */} + {hoveredDevice && !selectedDevice && ( +
+
{hoveredDevice.name}
+
+
位置: U{hoveredDevice.position}
+
高度: {hoveredDevice.height}U
+
状态: {hoveredDevice.status}
+
+
+ )} + + {/* Device Detail Floating Popup - Removed, now handled in DeviceModel */} + + ) : ( +
+ 请选择一个机柜以查看 3D 视图} /> +
+ )} + + + + 编辑设备信息 +
+ } + open={modalVisible} + onCancel={handleModalCancel} + footer={null} + width={700} + > + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + +
+ + + + {/* Field Configuration Modal */} + + + 设备详情字段配置 +
+ } + open={showTooltipConfig} + onCancel={() => setShowTooltipConfig(false)} + footer={null} + width={520} + > + {loadingTooltipFields ? ( +
+ +
加载配置中...
+
+ ) : ( +
+
选择要在设备详情卡片中显示的字段:
+
+ {Object.entries(tooltipFields).map(([key, field]) => ( +
{ + setTooltipFields(prev => ({ + ...prev, + [key]: { ...prev[key], enabled: !prev[key].enabled } + })); + }} + > + + {field.label} +
+ ))} +
+
+ + + + +
+
+ )} + + + + + + + + + setSelectedDevice(null)} + onEdit={handleEditDevice} + onAddNic={handleAddNic} + onAddPort={handleAddPort} + onAddCable={handleAddCable} + tooltipFields={tooltipFields} + cables={deviceCables} + onRefreshCables={() => selectedDevice && fetchDeviceCables(selectedDevice.deviceId || selectedDevice.id)} + onDeleteCable={handleDeleteCable} + refreshTrigger={refreshTrigger} + /> + + + + + ); +}; + +export default Rack3DVisualization; diff --git a/frontend/src/pages/RackVisualization.css b/frontend/src/pages/RackVisualization.css deleted file mode 100644 index 437fc83..0000000 --- a/frontend/src/pages/RackVisualization.css +++ /dev/null @@ -1,1062 +0,0 @@ -/* 设计令牌 */ -:root { - /* 颜色 */ - --color-primary-main: #667eea; - --color-primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - --color-primary-light: #8b9ff0; - --color-primary-dark: #4f5db8; - --color-success-main: #10b981; - --color-success-gradient: linear-gradient(135deg, #10b981 0%, #059669 100%); - --color-warning-main: #f59e0b; - --color-warning-gradient: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); - --color-error-main: #ef4444; - --color-error-gradient: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); - --color-text-primary: #1e293b; - --color-text-secondary: #64748b; - --color-text-tertiary: #94a3b8; - --color-text-inverse: #ffffff; - --color-background-primary: #ffffff; - --color-background-secondary: #f8fafc; - --color-background-tertiary: #f1f5f9; - --color-border-light: #e2e8f0; - --color-border-medium: #cbd5e1; - --color-border-dark: #94a3b8; - --color-device-server: #3b82f6; - --color-device-switch: #22c55e; - --color-device-router: #f59e0b; - --color-device-storage: #8b5cf6; - --color-device-firewall: #ef4444; - --color-status-normal: #10b981; - --color-status-running: #10b981; - --color-status-warning: #f59e0b; - --color-status-error: #ef4444; - --color-status-offline: #6b7280; - --color-status-maintenance: #3b82f6; - - /* 阴影 */ - --shadow-small: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - --shadow-medium: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); - --shadow-large: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); - - /* 边框半径 */ - --border-radius-small: 6px; - --border-radius-medium: 10px; - --border-radius-large: 16px; - --border-radius-xl: 24px; - --border-radius-round: 50%; - - /* 过渡 */ - --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); - --transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1); - --transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1); - - /* 间距 */ - --spacing-xs: 4px; - --spacing-sm: 8px; - --spacing-md: 16px; - --spacing-lg: 24px; - --spacing-xl: 32px; - - /* U高度 */ - --u-height: 25px; -} - -/* 页面容器样式 */ -.page-container { - min-height: 100vh; - background: var(--color-background-secondary); - padding: var(--spacing-lg); -} - -/* 头部样式 */ -.header { - margin-bottom: var(--spacing-lg); - padding: var(--spacing-lg) var(--spacing-xl); - background: var(--color-primary-gradient); - border-radius: var(--border-radius-large); - box-shadow: var(--shadow-large); - color: var(--color-text-inverse); -} - -.header-content { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: var(--spacing-lg); -} - -/* 统计卡片样式 */ -.stat-card { - background: rgba(255, 255, 255, 0.15); - border-radius: var(--border-radius-medium); - padding: var(--spacing-md); - border: 1px solid rgba(255, 255, 255, 0.2); - backdrop-filter: blur(10px); -} - -/* 卡片样式 */ -.card { - border-radius: var(--border-radius-large); - border: none; - box-shadow: var(--shadow-medium); - background: var(--color-background-primary); - overflow: hidden; -} - -.filter-card { - border-radius: var(--border-radius-medium); - border: none; - box-shadow: var(--shadow-small); - background: var(--color-background-primary); - margin-bottom: var(--spacing-lg); -} - -.filter-card-body { - padding: var(--spacing-md) var(--spacing-lg); -} - -/* 按钮样式 */ -.primary-button { - height: 40px; - border-radius: var(--border-radius-medium); - background: var(--color-primary-gradient); - border: none; - box-shadow: var(--shadow-medium); - font-weight: 500; - transition: all var(--transition-normal); -} - -.secondary-button { - height: 40px; - border-radius: var(--border-radius-medium); - border: 1px solid var(--color-border-light); - background: var(--color-background-primary); - transition: all var(--transition-fast); -} - -/* 动画 */ -@keyframes slideIn { - from { - opacity: 0; - transform: translateY(-50%) translateX(20px) scale(0.95); - } - to { - opacity: 1; - transform: translateY(-50%) translateX(0) scale(1); - } -} - -@keyframes ledBlink { - 0%, 50% { opacity: 1; } - 51%, 100% { opacity: 0.3; } -} - -@keyframes tooltipFadeIn { - from { - opacity: 0; - transform: translateY(-50%) translateX(-10px) scale(0.95); - } - to { - opacity: 1; - transform: translateY(-50%) translateX(0) scale(1); - } -} - -@keyframes highlightPulse { - 0%, 100% { - box-shadow: - 0 0 20px rgba(56, 189, 248, 0.6), - 0 4px 12px rgba(56, 189, 248, 0.4), - 0 1px 2px rgba(0,0,0,0.3), - inset 0 1px 0 rgba(255,255,255,0.2); - } - 50% { - box-shadow: - 0 0 35px rgba(56, 189, 248, 0.9), - 0 6px 20px rgba(56, 189, 248, 0.6), - 0 1px 2px rgba(0,0,0,0.3), - inset 0 1px 0 rgba(255,255,255,0.3); - } -} - -/* 设备高亮 */ -.device-highlighted { - animation: highlightPulse 1.5s ease-in-out infinite; -} - -/* 金属纹理 */ -.metal-texture { - background-image: - linear-gradient(90deg, - transparent 0%, - rgba(255,255,255,0.03) 50%, - transparent 100%), - repeating-linear-gradient(0deg, - transparent 0px, - rgba(255,255,255,0.02) 1px, - transparent 2px, - transparent 3px); - background-size: 100% 100%, 4px 4px; -} - -/* 通风格栅 */ -.ventilation-grille { - background-image: repeating-linear-gradient( - 0deg, - #334155 0px, - #334155 1px, - transparent 1px, - transparent 2px - ); -} - -/* 设备悬停效果 */ -.device-hover { - background: linear-gradient(145deg, #1e293b, #0f172a) !important; - box-shadow: 0 6px 16px rgba(56, 189, 248, 0.3), 0 0 12px rgba(56, 189, 248, 0.2) !important; - border-color: #38bdf8 !important; -} - -/* 设备提示框 */ -.device-tooltip { - background: rgba(0, 0, 0, 0.9); - color: #5eead4; - padding: 8px 12px; - border-radius: 4px; - font-family: 'Roboto Mono', monospace; - font-size: 11px; - border: 1px solid rgba(94, 234, 212, 0.3); - box-shadow: 0 4px 12px rgba(0,0,0,0.5); - white-space: nowrap; -} - -/* 设备数量徽章 */ -.device-count-badge { - background: linear-gradient(135deg, rgba(56, 189, 248, 0.2), rgba(14, 165, 233, 0.2)); - color: #38bdf8; - padding: 6px 14px; - border-radius: 20px; - font-size: 13px; - font-weight: 600; - box-shadow: 0 4px 15px rgba(56, 189, 248, 0.2); - border: 1px solid rgba(56, 189, 248, 0.3); - backdrop-filter: blur(10px); - transition: all 0.3s ease; - white-space: nowrap; - font-family: 'JetBrains Mono', 'Roboto Mono', monospace; -} - -/* 机柜可视化容器 */ -.rack-visualization-container { - width: 100%; - height: calc(100vh - 220px); - display: flex; - flex-direction: column; - justify-content: flex-start; - perspective: 1000px; - overflow: auto; - background-color: #fafafa; - background-size: auto; - background-position: center; - background-repeat: no-repeat; - position: relative; - border-radius: 12px; - border: 1px solid #e8e8e8; - box-shadow: 0 2px 8px rgba(0,0,0,0.06); - transition: all 0.3s ease; - padding: 10px; - box-sizing: border-box; - flex-shrink: 0; -} - -.rack-visualization-container.image-background { - background-size: contain; -} - -/* 机柜容器 */ -.rack-container { - transform: scale(1.7); - transition: transform 0.3s ease; - position: relative; - margin: 0 auto; - padding: 40px 50px; - will-change: transform, scale; -} - -/* 机柜主体 */ -.rack { - position: relative; - width: 600px; - background: linear-gradient(145deg, #1e293b 0%, #0f172a 50%, #1e293b 100%); - border: 2px solid #38bdf8; - border-radius: 12px; - box-shadow: - 0 0 40px rgba(56, 189, 248, 0.2), - 0 0 80px rgba(56, 189, 248, 0.1), - 0 20px 60px rgba(0, 0, 0, 0.5), - inset 0 1px 0 rgba(255, 255, 255, 0.05), - inset 0 0 60px rgba(56, 189, 248, 0.05); - overflow: visible; - backdrop-filter: blur(10px); -} - -/* 玻璃光泽效果 */ -.rack-glass-effect { - position: absolute; - top: 0; - left: 0; - right: 0; - height: 50%; - background: linear-gradient(180deg, rgba(56, 189, 248, 0.03) 0%, transparent 100%); - border-radius: 10px 10px 0 0; - pointer-events: none; -} - -/* U数标记容器 */ -.u-marks-container { - position: absolute; - width: 18px; - top: 0; - bottom: 0; - z-index: 20; - pointer-events: none; -} - -.u-marks-container.left { - left: -18px; -} - -.u-marks-container.right { - right: -18px; -} - -/* U数标记 */ -.u-mark { - position: absolute; - width: 100%; - display: flex; - align-items: center; - justify-content: center; - color: #cbd5e1; - font-size: 10px; - font-weight: 500; - text-shadow: 0 0 4px rgba(0, 0, 0, 0.5); - z-index: 30; - background-color: rgba(30, 41, 59, 0.8); - border-radius: 3px; - border: 1px solid rgba(148, 163, 184, 0.2); - box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.3); - height: var(--u-height); -} - -.u-mark:nth-child(5n) { - color: #38bdf8; - font-weight: 700; - text-shadow: 0 0 10px rgba(56, 189, 248, 0.6); - background-color: rgba(56, 189, 248, 0.15); - border: 1px solid rgba(56, 189, 248, 0.5); - box-shadow: 0 0 8px rgba(56, 189, 248, 0.3); -} - -/* U数标记线 */ -.u-mark-line { - position: absolute; - height: 1px; - width: 100%; - background-color: #a5d6a7; - border-top: 1px solid #a5d6a7; -} - -.u-mark-line.five { - background-color: #81c784; - border-top: 2px solid #81c784; -} - -.u-mark-text { - position: absolute; - left: -40px; - top: -12px; - font-size: 12px; - color: #4caf50; - font-weight: normal; - text-shadow: 1px 1px 1px rgba(255,255,255,0.8); -} - -.u-mark-text.five { - color: #2e7d32; - font-weight: bold; -} - -/* 安装导轨 */ -.rack-rail { - position: absolute; - top: 3px; - width: 15px; - bottom: 3px; - background: linear-gradient(90deg, #374151 0%, #1f2937 50%, #111827 100%); - border-radius: 2px; - box-shadow: inset 0 0 3px rgba(0,0,0,0.5), 0 0 8px rgba(56, 189, 248, 0.1); -} - -.rack-rail.left { - left: 33px; - border-right: 1px solid rgba(56, 189, 248, 0.3); - border-radius: 2px 0 0 2px; -} - -.rack-rail.right { - right: 33px; - background: linear-gradient(90deg, #343a40 0%, #495057 50%, #5a6268 100%); - border-left: 1px solid rgba(0,0,0,0.3); - border-radius: 0 2px 2px 0; - box-shadow: inset 0 0 3px rgba(0,0,0,0.4); -} - -/* 导轨安装孔 */ -.rail-hole { - position: absolute; - width: 11px; - height: 20px; - background: linear-gradient(180deg, #2d3436 0%, #636e72 50%, #2d3436 100%); - border-radius: 1px; - box-shadow: inset 0 1px 2px rgba(255,255,255,0.1), 0 1px 1px rgba(0,0,0,0.3); -} - -.rail-hole.left { - left: 2px; -} - -.rail-hole.right { - right: 2px; -} - -.rail-hole-inner { - position: absolute; - width: 5px; - height: 8px; - background: #1a1a1a; - border-radius: 1px; - top: 6px; -} - -.rail-hole-inner.left { - left: 3px; -} - -.rail-hole-inner.right { - right: 3px; -} - -/* 前面板 */ -.rack-front-panel { - position: absolute; - top: 0; - left: 51px; - right: 51px; - bottom: 0; - background: linear-gradient(145deg, #f5f5f5, #e8e8e8); - border-radius: 4px; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: stretch; - padding: 3px; - border: 1px solid rgba(148, 163, 184, 0.3); - overflow: visible; - box-shadow: inset 0 1px 3px rgba(0,0,0,0.1); -} - -/* 细微网格纹理 */ -.front-panel-grid { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-image: radial-gradient(circle at 1px 1px, rgba(0,0,0,0.03) 1px, transparent 0); - background-size: 20px 20px; - border-radius: 4px; - pointer-events: none; -} - -/* U位网格线 */ -.u-grid-lines { - position: absolute; - top: 3px; - left: 48px; - right: 48px; - bottom: 3px; - pointer-events: none; - z-index: 5; -} - -.u-grid-line { - position: absolute; - left: 0; - right: 0; - height: 1px; - background: linear-gradient(90deg, transparent 0%, rgba(148, 163, 184, 0.15) 10%, rgba(148, 163, 184, 0.2) 50%, rgba(148, 163, 184, 0.15) 90%, transparent 100%); -} - -/* 设备容器 */ -.devices-container { - position: relative; - width: 100%; - height: 100%; -} - -/* 设备 */ -.device { - position: absolute; - left: 3px; - right: 3px; - width: calc(100% - 6px); - border-radius: 3px; - display: flex; - cursor: pointer; - transition: all 0.2s ease; - box-shadow: - 0 1px 2px rgba(0,0,0,0.3), - 0 2px 4px rgba(0,0,0,0.2), - inset 0 1px 0 rgba(255,255,255,0.1), - inset 0 -1px 0 rgba(0,0,0,0.1); - overflow: hidden; - margin: 0; - padding: 0; - z-index: 100; - pointer-events: auto; -} - -.device.highlighted { - box-shadow: - 0 0 20px rgba(56, 189, 248, 0.6), - 0 4px 12px rgba(56, 189, 248, 0.4), - 0 1px 2px rgba(0,0,0,0.3), - inset 0 1px 0 rgba(255,255,255,0.2); - z-index: 200; - animation: highlightPulse 1.5s ease-in-out infinite; -} - -.device.status-warning { - animation: ledBlink 2s ease-in-out infinite; -} - -.device.status-error { - animation: ledBlink 0.8s ease-in-out infinite; -} - -/* 设备状态指示器 */ -.device-status-indicator { - width: 7%; - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; - padding: 4px 3px; - flex-shrink: 0; - overflow: hidden; - position: relative; - border-right: 1px solid rgba(148, 163, 184, 0.4); - border-radius: 3px 0 0 3px; -} - -/* 设备类型标识 */ -.device-type-badge { - width: 100%; - height: 8px; - background: linear-gradient(180deg, rgba(56, 189, 248, 0.6) 0%, rgba(56, 189, 248, 0.3) 100%); - border-radius: 2px; - display: flex; - align-items: center; - justify-content: center; - border: 1px solid rgba(56, 189, 248, 0.4); -} - -.device-type-text { - font-size: 4px; - color: rgba(56, 189, 248, 0.8); - font-weight: 700; - letter-spacing: 0.5px; -} - -/* LED状态指示灯组 */ -.device-leds { - display: flex; - flex-direction: column; - gap: 3px; - align-items: center; -} - -.main-status-leds { - display: flex; - gap: 2px; -} - -.led { - width: 5px; - height: 5px; - border-radius: 50%; -} - -.led.status-normal { - background-color: var(--color-status-normal); - box-shadow: 0 0 8px var(--color-status-normal); -} - -.led.status-running { - background-color: #22c55e; - box-shadow: 0 0 6px #22c55e; - animation: ledBlink 3s ease-in-out infinite; -} - -.led.status-error { - background-color: var(--color-status-error); - box-shadow: 0 0 8px var(--color-status-error); - animation: ledBlink 0.8s ease-in-out infinite; -} - -.led.status-warning { - background-color: var(--color-status-warning); - box-shadow: 0 0 8px var(--color-status-warning); - animation: ledBlink 2s ease-in-out infinite; -} - -.led.offline { - background-color: #4b5563; -} - -/* 电源指示灯 */ -.power-led { - display: flex; - gap: 2px; - align-items: center; -} - -.power-led-indicator { - width: 4px; - height: 4px; - border-radius: 1px; - background-color: #475569; -} - -.power-led-indicator.on { - background-color: #38bdf8; - box-shadow: 0 0 4px #38bdf8; -} - -.power-led-text { - font-size: 4px; - color: #64748b; - font-weight: 600; -} - -.power-led-text.on { - color: #38bdf8; -} - -/* 设备序列号标签 */ -.device-serial { - width: 100%; - text-align: center; - border-top: 1px solid rgba(148, 163, 184, 0.2); - padding-top: 2px; -} - -.device-serial-text { - font-size: 4px; - color: #475569; - font-family: monospace; -} - -/* 设备信息区域 */ -.device-info { - flex: 1; - background: linear-gradient(180deg, #0f172a 0%, #020617 100%); - display: flex; - flex-direction: column; - justify-content: center; - padding: 3px 8px; - overflow: hidden; - border-left: 1px solid rgba(148, 163, 184, 0.15); - border-right: 1px solid rgba(148, 163, 184, 0.15); - position: relative; -} - -/* 设备品牌/厂商标识 */ -.device-brand { - display: flex; - align-items: center; - gap: 4px; - margin-bottom: 2px; -} - -.device-brand-icon { - font-size: 10px; - color: #38bdf8; - display: flex; - align-items: center; -} - -.device-brand-text { - font-size: 5px; - color: #64748b; - font-weight: 600; - letter-spacing: 0.5px; -} - -/* 设备名称 */ -.device-name { - color: #f1f5f9; - font-size: 8px; - font-weight: 600; - font-family: "Roboto Mono", "Consolas", monospace; - text-align: left; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-shadow: 0 0 10px rgba(56, 189, 248, 0.3); -} - -/* 设备型号和规格 */ -.device-model { - display: flex; - gap: 8px; - align-items: center; - margin-top: 2px; -} - -.device-model-text { - color: #64748b; - font-size: 6px; - font-family: "Roboto Mono", monospace; - background: rgba(56, 189, 248, 0.1); - padding: 1px 4px; - border-radius: 2px; - border: 1px solid rgba(56, 189, 248, 0.2); -} - -.device-ip { - color: #22c55e; - font-size: 6px; - font-family: "Roboto Mono", monospace; -} - -/* 散热/通风口装饰 */ -.device-ventilation { - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - display: flex; - gap: 2px; -} - -.ventilation-fin { - width: 3px; - height: 12px; - background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); - border-radius: 1px; - border: 1px solid rgba(148, 163, 184, 0.2); -} - -/* 设备右侧端口/功能区域 */ -.device-ports { - width: 12%; - background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); - border-radius: 0 3px 3px 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex-shrink: 0; - padding: 4px 3px; - gap: 4px; - overflow: hidden; - position: relative; - border-left: 1px solid rgba(148, 163, 184, 0.2); -} - -/* 端口指示灯阵列 */ -.port-leds { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 2px; -} - -.port-led { - width: 4px; - height: 4px; - border-radius: 1px; -} - -.port-led.active { - background-color: #22c55e; - box-shadow: 0 0 3px rgba(34, 197, 94, 0.6); - animation: ledBlink 2s ease-in-out infinite; -} - -.port-led.inactive { - background-color: #475569; -} - -/* 管理接口标识 */ -.management-interface { - display: flex; - align-items: center; - gap: 2px; - margin-top: 2px; -} - -.management-icon { - width: 0; - height: 0; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-bottom: 6px solid #64748b; -} - -.management-text { - font-size: 5px; - color: #64748b; - font-weight: 600; -} - -/* 设备高度U数标识 */ -.device-height { - font-size: 5px; - color: #38bdf8; - font-weight: 700; - font-family: "Roboto Mono", monospace; - background: rgba(56, 189, 248, 0.15); - padding: 1px 4px; - border-radius: 2px; - border: 1px solid rgba(56, 189, 248, 0.3); -} - -/* 机柜顶部名称和设备数量 */ -.rack-header { - position: absolute; - top: -45px; - left: 0; - width: 100%; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} - -/* 机柜标题 */ -.rack-title { - font-size: 16px; - font-weight: 600; - color: #0f172a; - text-shadow: 0 1px 2px rgba(255,255,255,0.5); - padding: 6px 14px; - background: linear-gradient(145deg, rgba(255,255,255,0.95), rgba(241, 245, 249, 0.95)); - border-radius: 10px; - border: 1px solid rgba(56, 189, 248, 0.4); - box-shadow: 0 3px 10px rgba(56, 189, 248, 0.2); - transition: all 0.3s ease; - white-space: nowrap; - max-width: 60%; - overflow: hidden; - text-overflow: ellipsis; -} - -/* 机柜侧面 */ -.rack-side { - position: absolute; - top: 0; - left: 100%; - width: 30px; - height: 100%; - background-color: #7f8c8d; - transform-origin: left center; - transform: rotateY(90deg); - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; -} - -/* 机柜顶部 */ -.rack-top { - position: absolute; - top: -20px; - left: 0; - width: 100%; - height: 20px; - background-color: #666; - transform-origin: bottom center; - transform: rotateX(90deg); - border-top-left-radius: 4px; - border-top-right-radius: 4px; -} - -/* 加载状态 */ -.loading-container { - width: 100%; - height: 600px; - display: flex; - justify-content: center; - align-items: center; - background-color: #fafafa; - font-size: 16px; - color: #666; - border-radius: 12px; -} - -/* 无数据状态 */ -.no-data-container { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - color: #81c784; - font-size: 16px; - text-align: center; - padding: 16px 24px; - background-color: rgba(240, 255, 240, 0.95); - border-radius: 12px; - border: 1px solid rgba(129, 199, 132, 0.3); - box-shadow: 0 4px 12px rgba(129, 199, 132, 0.2); - font-weight: 500; -} - -/* 错误边界样式 */ -.error-boundary { - width: 100%; - height: 600px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - background-color: #f5f5f5; - padding: 20px; -} - -.error-boundary-title { - color: #f44336; -} - -.error-boundary-message { - color: #666; - margin: 10px 0; -} - -.error-boundary-reload-button { - padding: 8px 16px; - background-color: #1890ff; - color: #fff; - border: none; - border-radius: 4px; - cursor: pointer; -} - -/* 搜索结果卡片 */ -.search-result-card { - background: var(--color-success-gradient); - border-radius: var(--border-radius-medium); -} - -.search-result-content { - display: flex; - align-items: center; - gap: var(--spacing-md); - flex-wrap: wrap; - padding: var(--spacing-md) var(--spacing-lg); -} - -/* 搜索结果按钮 */ -.search-result-button { - size: small; - height: 32px; - border-radius: var(--border-radius-small); - border-color: rgba(255,255,255,0.5); - background: rgba(255,255,255,0.1); - color: #fff; -} - -.search-result-button.highlighted { - background: rgba(255,255,255,0.2); -} - -/* 设备加载状态 */ -.device-loading-container { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 1000; -} - -.device-loading-content { - padding: 20px; - background-color: rgba(255, 255, 255, 0.8); - border-radius: 4px; - font-size: 14px; - color: #666; -} - -/* 设备加载错误 */ -.device-error { - position: absolute; - left: 35px; - right: 35px; - top: 50%; - height: 30px; - transform: translateY(-50%); - background-color: #ff4d4f; - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - border-radius: 4px; -} - -/* 选择机柜提示 */ -.select-rack-prompt { - width: 100%; - height: 600px; - display: flex; - justify-content: center; - align-items: center; - background-color: #f5f5f5; - font-size: 16px; - color: #666; -} - -/* 响应式设计 */ -@media (max-width: 768px) { - .device-tooltip { - font-size: 10px; - padding: 6px 10px; - } - - .device-count-badge { - padding: 4px 10px; - font-size: 11px; - } - - .header-content { - flex-direction: column; - align-items: flex-start; - } -} - -@media (max-width: 480px) { - .device-tooltip { - font-size: 9px; - padding: 4px 8px; - } - - .device-count-badge { - padding: 3px 8px; - font-size: 10px; - } - - .rack-container { - transform: scale(0.8); - } -} \ No newline at end of file diff --git a/frontend/src/pages/RackVisualization.jsx b/frontend/src/pages/RackVisualization.jsx deleted file mode 100644 index 26d06ed..0000000 --- a/frontend/src/pages/RackVisualization.jsx +++ /dev/null @@ -1,2018 +0,0 @@ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -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, ApiOutlined -} from '@ant-design/icons'; -import axios from 'axios'; -import DeviceComponent from '../components/DeviceComponent'; -import DeviceDetailDrawer from '../components/DeviceDetailDrawer'; -import './RackVisualization.css'; - -const { Option } = Select; -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' - }, - 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' - } - }, - spacing: { - xs: 4, - sm: 8, - md: 16, - lg: 24, - xl: 32 - }, - borderRadius: { - small: 6, - medium: 10, - large: 16, - xl: 24 - }, - 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)' - }, - 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)' - } -}; - -// 移除内联样式函数,使用CSS类 - -// 卡片样式定义 -const cardStyle = { - borderRadius: designTokens.borderRadius.large, - border: 'none', - boxShadow: designTokens.shadows.medium, - background: '#fff', - overflow: 'hidden' -}; - -// 按钮样式定义 -const primaryButtonStyle = { - height: '42px', - borderRadius: '10px', - background: designTokens.colors.primary.gradient, - border: 'none', - boxShadow: '0 4px 16px rgba(102, 126, 234, 0.35)', - fontWeight: '500' -}; - -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 getDeviceIcon = (deviceType) => { - try { - if (!deviceType) return ; - const type = deviceType.toLowerCase(); - - if (type.includes('server') || type.includes('服务器')) return ; - if (type.includes('switch') || type.includes('交换机')) return ; - if (type.includes('storage') || type.includes('存储')) return ; - if (type.includes('router') || type.includes('路由器')) return ; - if (type.includes('laptop') || type.includes('笔记本')) return ; - if (type.includes('mobile') || type.includes('手机')) return ; - if (type.includes('printer') || type.includes('打印机')) return ; - - return ; - } catch (error) { - console.error('设备图标渲染错误:', error); - return ; - } -}; - -const getDeviceColor = (deviceType) => { - if (!deviceType) return '#1890ff'; - const type = deviceType.toLowerCase(); - - if (type.includes('server') || type.includes('服务器')) return '#1890ff'; - if (type.includes('switch') || type.includes('交换机')) return '#52c41a'; - if (type.includes('storage') || type.includes('存储')) return '#faad14'; - if (type.includes('router') || type.includes('路由器')) return '#f5222d'; - if (type.includes('laptop') || type.includes('笔记本')) return '#722ed1'; - if (type.includes('mobile') || type.includes('手机')) return '#eb2f96'; - if (type.includes('printer') || type.includes('打印机')) return '#13c2c2'; - - return '#1890ff'; -}; - -const getDeviceStatusColor = (status) => { - const statusColorMap = { - 'normal': '#10b981', - 'running': '#10b981', - 'warning': '#f59e0b', - 'error': '#ef4444', - 'fault': '#ef4444', - 'offline': '#6b7280', - 'maintenance': '#3b82f6', - undefined: '#3b82f6', - null: '#3b82f6' - }; - return statusColorMap[status] || '#3b82f6'; -}; - -const getStatusTheme = (status) => { - const themeMap = { - 'normal': { - bgGradient: 'linear-gradient(180deg, #059669 0%, #047857 50%, #065f46 100%)', - borderColor: '#10b981', - topBorderColor: '#34d399', - glowColor: 'rgba(16, 185, 129, 0.4)', - shadowColor: 'rgba(16, 185, 129, 0.3)', - iconColor: '#10b981', - label: '正常' - }, - 'running': { - bgGradient: 'linear-gradient(180deg, #059669 0%, #047857 50%, #065f46 100%)', - borderColor: '#10b981', - topBorderColor: '#34d399', - glowColor: 'rgba(16, 185, 129, 0.4)', - shadowColor: 'rgba(16, 185, 129, 0.3)', - iconColor: '#10b981', - label: '运行中' - }, - 'warning': { - bgGradient: 'linear-gradient(180deg, #d97706 0%, #b45309 50%, #92400e 100%)', - borderColor: '#f59e0b', - topBorderColor: '#fbbf24', - glowColor: 'rgba(245, 158, 11, 0.4)', - shadowColor: 'rgba(245, 158, 11, 0.3)', - iconColor: '#f59e0b', - label: '警告' - }, - 'error': { - bgGradient: 'linear-gradient(180deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%)', - borderColor: '#ef4444', - topBorderColor: '#f87171', - glowColor: 'rgba(239, 68, 68, 0.5)', - shadowColor: 'rgba(239, 68, 68, 0.4)', - iconColor: '#ef4444', - label: '故障' - }, - 'fault': { - bgGradient: 'linear-gradient(180deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%)', - borderColor: '#ef4444', - topBorderColor: '#f87171', - glowColor: 'rgba(239, 68, 68, 0.5)', - shadowColor: 'rgba(239, 68, 68, 0.4)', - iconColor: '#ef4444', - label: '故障' - }, - 'offline': { - bgGradient: 'linear-gradient(180deg, #4b5563 0%, #374151 50%, #1f2937 100%)', - borderColor: '#6b7280', - topBorderColor: '#9ca3af', - glowColor: 'rgba(107, 114, 128, 0.2)', - shadowColor: 'rgba(0, 0, 0, 0.2)', - iconColor: '#9ca3af', - label: '离线' - }, - 'maintenance': { - bgGradient: 'linear-gradient(180deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%)', - borderColor: '#3b82f6', - topBorderColor: '#60a5fa', - glowColor: 'rgba(59, 130, 246, 0.4)', - shadowColor: 'rgba(59, 130, 246, 0.3)', - iconColor: '#3b82f6', - label: '维护中' - }, - 'default': { - bgGradient: 'linear-gradient(180deg, #3d4451 0%, #2d3139 50%, #252930 100%)', - borderColor: '#4a5568', - topBorderColor: '#565c6b', - glowColor: 'rgba(56, 189, 248, 0.2)', - shadowColor: 'rgba(0, 0, 0, 0.3)', - iconColor: '#38bdf8', - label: '未知' - } - }; - - return themeMap[status] || themeMap['default']; -}; - -const getDeviceTypeTheme = (type) => { - const themeMap = { - 'server': { - borderColor: '#38bdf8', - accentColor: '#0ea5e9', - glowColor: 'rgba(56, 189, 248, 0.3)', - iconColor: '#38bdf8', - label: '服务器' - }, - 'switch': { - borderColor: '#22c55e', - accentColor: '#16a34a', - glowColor: 'rgba(34, 197, 94, 0.3)', - iconColor: '#22c55e', - label: '交换机' - }, - 'router': { - borderColor: '#f59e0b', - accentColor: '#d97706', - glowColor: 'rgba(245, 158, 11, 0.3)', - iconColor: '#f59e0b', - label: '路由器' - }, - 'storage': { - borderColor: '#8b5cf6', - accentColor: '#7c3aed', - glowColor: 'rgba(139, 92, 246, 0.3)', - iconColor: '#8b5cf6', - label: '存储' - }, - 'firewall': { - borderColor: '#ef4444', - accentColor: '#dc2626', - glowColor: 'rgba(239, 68, 68, 0.3)', - iconColor: '#ef4444', - label: '防火墙' - }, - 'ups': { - borderColor: '#14b8a6', - accentColor: '#0d9488', - glowColor: 'rgba(20, 184, 166, 0.3)', - iconColor: '#14b8a6', - label: 'UPS' - }, - 'pdus': { - borderColor: '#64748b', - accentColor: '#475569', - glowColor: 'rgba(100, 116, 139, 0.3)', - iconColor: '#64748b', - label: 'PDU' - }, - 'other': { - borderColor: '#94a3b8', - accentColor: '#64748b', - glowColor: 'rgba(148, 163, 184, 0.3)', - iconColor: '#94a3b8', - label: '其他设备' - } - }; - - const normalizedType = type?.toLowerCase(); - - if (normalizedType?.includes('server') || normalizedType?.includes('服务器')) return themeMap.server; - if (normalizedType?.includes('switch') || normalizedType?.includes('交换机')) return themeMap.switch; - if (normalizedType?.includes('router') || normalizedType?.includes('路由器')) return themeMap.router; - if (normalizedType?.includes('storage') || normalizedType?.includes('存储')) return themeMap.storage; - if (normalizedType?.includes('firewall') || normalizedType?.includes('防火墙')) return themeMap.firewall; - if (normalizedType?.includes('ups') || normalizedType?.includes('不间断电源')) return themeMap.ups; - if (normalizedType?.includes('pdu') || normalizedType?.includes('电源分配')) return themeMap.pdus; - - return themeMap.other; -}; - -// 错误边界组件 -class ErrorBoundary extends React.Component { - constructor(props) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error) { - return { hasError: true, error }; - } - - componentDidCatch(error, errorInfo) { - console.error('机柜可视化组件错误:', error, errorInfo); - } - - render() { - if (this.state.hasError) { - return ( -
-

可视化组件出错了

-

- 错误信息: {this.state.error?.toString()} -

- -
- ); - } - - return this.props.children; - } -} - -function RackVisualization() { - const [racks, setRacks] = useState([]); - const [selectedRack, setSelectedRack] = useState(null); - const [selectedRoom, setSelectedRoom] = useState(null); - const [loading, setLoading] = useState(true); - const [devices, setDevices] = useState([]); - const [loadingDevices, setLoadingDevices] = useState(false); - const [zoom, setZoom] = useState(1.7); - const [rotation, setRotation] = useState(0); - const [error, setError] = useState(null); - const [deviceTooltip, setDeviceTooltip] = useState(null); // 设备详情tooltip - const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); // tooltip位置 - const [backgroundImage, setBackgroundImage] = useState(null); - const [backgroundType, setBackgroundType] = useState('gradient'); // gradient or image - const [backgroundSize, setBackgroundSize] = useState('contain'); // cover, contain, auto - const [uploading, setUploading] = useState(false); // 上传状态 - const [showTooltipConfig, setShowTooltipConfig] = useState(false); // 显示tooltip配置面板 - const [tooltipDeviceFields, setTooltipDeviceFields] = useState([]); // 设备字段配置 - const [loadingTooltipFields, setLoadingTooltipFields] = useState(false); // 加载字段配置状态 - const [savingTooltipConfig, setSavingTooltipConfig] = useState(false); // 保存配置状态 - const [deviceCache, setDeviceCache] = useState({}); // 设备数据缓存,键为rackId,值为设备数据 - - // 接线管理相关状态 - const [cables, setCables] = useState([]); // 接线列表 - const [showCables, setShowCables] = useState(true); // 是否显示接线 - - // 设备详情抽屉状态 - const [selectedDevice, setSelectedDevice] = useState(null); // 当前选中设备 - const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); // 详情抽屉显示状态 - - // 设备搜索功能 - const [searchKeyword, setSearchKeyword] = useState(''); // 搜索关键词 - const [searchResults, setSearchResults] = useState([]); // 搜索结果 - const [highlightedDevice, setHighlightedDevice] = useState(null); // 高亮的设备 - const [searchMatchCount, setSearchMatchCount] = useState(0); // 匹配数量 - const [searching, setSearching] = useState(false); // 搜索中状态 - - // 设备详情tooltip字段配置 - const [tooltipFields, setTooltipFields] = useState({}); - - // 默认设备字段配置 - const defaultTooltipFields = useMemo(() => ({ - name: { label: '设备名称', enabled: true, field: 'name', fieldType: 'string' }, - deviceId: { label: '设备ID', enabled: true, field: 'deviceId', fieldType: 'string' }, - type: { label: '设备类型', enabled: true, field: 'type', fieldType: 'string' }, - brand: { label: '品牌', enabled: true, field: 'brand', fieldType: 'string' }, - model: { label: '型号', enabled: true, field: 'model', fieldType: 'string' }, - status: { label: '状态', enabled: true, field: 'status', fieldType: 'string' }, - position: { label: '位置', enabled: true, field: 'position', fieldType: 'number' }, - height: { label: '高度', enabled: true, field: 'height', fieldType: 'number' }, - ipAddress: { label: 'IP地址', enabled: true, field: 'ipAddress', fieldType: 'string' }, - power: { label: '功率', enabled: true, field: 'power', fieldType: 'number' } - }), []); - - // 移除初始化样式的useEffect,因为样式已移至CSS文件 - - // 获取设备字段配置 - 使用 useCallback 避免重复创建 - const fetchTooltipDeviceFields = useCallback(async () => { - try { - setLoadingTooltipFields(true); - console.log('开始获取设备字段配置...'); - const response = await axios.get('/api/deviceFields'); - console.log('API响应:', response.data); - const fieldsData = response.data; - - if (Array.isArray(fieldsData) && fieldsData.length > 0) { - console.log('使用API返回的字段配置'); - const sortedFields = fieldsData.sort((a, b) => a.order - b.order); - setTooltipDeviceFields(sortedFields); - - // 根据字段配置初始化tooltipFields - const initialFields = {}; - sortedFields.forEach(field => { - console.log('字段:', field.fieldName, field.displayName, field.visible); - initialFields[field.fieldName] = { - label: field.displayName, - enabled: field.visible, - field: field.fieldName, - fieldType: field.fieldType - }; - }); - setTooltipFields(initialFields); - } else { - console.log('使用默认字段配置'); - // 使用默认字段配置 - setTooltipFields(defaultTooltipFields); - } - } catch (error) { - console.error('获取设备字段配置失败,使用默认配置:', error); - // 使用默认字段配置 - setTooltipFields(defaultTooltipFields); - } finally { - setLoadingTooltipFields(false); - } - }, [defaultTooltipFields]); - - // 初始化获取保存的字段配置 - useEffect(() => { - fetchTooltipDeviceFields(); - }, [fetchTooltipDeviceFields]); - - // 保存tooltip字段配置 - const saveTooltipConfig = useCallback(async () => { - try { - setSavingTooltipConfig(true); - - const fieldConfigs = Object.entries(tooltipFields).map(([key, field]) => ({ - fieldName: field.field, - visible: field.enabled, - displayName: field.label, - fieldType: field.fieldType - })); - - await axios.post('/api/deviceFields/config', fieldConfigs); - message.success('字段配置已保存'); - setShowTooltipConfig(false); - - // 重新获取配置以确保数据一致 - await fetchTooltipDeviceFields(); - } catch (error) { - console.error('保存字段配置失败:', error); - message.error('保存字段配置失败'); - } finally { - setSavingTooltipConfig(false); - } - }, [tooltipFields, fetchTooltipDeviceFields]); - - // 获取机柜内的设备 - 使用 useCallback 避免重复创建 - const fetchDevices = useCallback(async (rackId) => { - // 先检查缓存中是否已有数据 - if (deviceCache[rackId]) { - console.log(`=== 从缓存获取机柜 ${rackId} 的设备数据 ===`); - setDevices(deviceCache[rackId]); - setLoadingDevices(false); - return; - } - - try { - setLoadingDevices(true); - console.log(`=== 开始获取机柜 ${rackId} 的设备数据 ===`); - console.log('API URL:', `/api/devices?rackId=${rackId}`); - - const response = await axios.get(`/api/devices?rackId=${rackId}`); - - console.log('=== API响应完整信息 ==='); - console.log('HTTP状态码:', response.status); - console.log('响应头:', response.headers); - console.log('响应数据类型:', typeof response.data); - console.log('响应数据:', JSON.stringify(response.data, null, 2)); - - // 处理不同的响应格式 - let devicesData; - if (Array.isArray(response.data)) { - devicesData = response.data; - console.log('响应是数组格式'); - } else if (response.data && Array.isArray(response.data.data)) { - devicesData = response.data.data; - console.log('响应是对象格式,数组在data字段中'); - } else if (response.data && Array.isArray(response.data.devices)) { - devicesData = response.data.devices; - console.log('响应是对象格式,数组在devices字段中'); - } else { - devicesData = []; - console.log('响应不是预期格式,设置为空数组'); - } - - console.log('=== 处理后的设备数据 ==='); - console.log('设备数组长度:', devicesData.length); - console.log('设备数据:', devicesData); - - // 展开customFields中的字段到设备对象的直接属性 - const processedDevices = devicesData.map(device => { - const deviceWithFields = { ...device }; - - if (device.customFields && typeof device.customFields === 'object') { - Object.entries(device.customFields).forEach(([fieldName, value]) => { - deviceWithFields[fieldName] = value; - }); - } - - return deviceWithFields; - }); - - console.log('展开customFields后的设备数据:', processedDevices); - - // 如果没有数据,显示警告 - if (processedDevices.length === 0) { - console.warn('=== 没有找到设备数据 ==='); - console.warn('可能的原因:'); - console.warn('1. 数据库中该机柜确实没有设备'); - console.warn('2. API路由配置错误'); - console.warn('3. 数据库查询条件问题'); - message.warning(`机柜 ${rackId} 暂无设备数据`); - setDevices([]); - // 将空数据也存入缓存,避免重复请求 - setDeviceCache(prev => ({ ...prev, [rackId]: [] })); - return; - } - - // 显示原始数据结构以供调试 - console.log('=== 数据结构分析 ==='); - if (processedDevices.length > 0) { - const firstDevice = processedDevices[0]; - console.log('第一个设备完整数据:', JSON.stringify(firstDevice, null, 2)); - console.log('第一个设备的字段:', Object.keys(firstDevice)); - console.log('第一个设备的字段类型:'); - Object.keys(firstDevice).forEach(key => { - console.log(` ${key}: ${typeof firstDevice[key]} = ${JSON.stringify(firstDevice[key])}`); - }); - } - - // 过滤有效的设备数据 - 放宽验证条件 - const validDevices = processedDevices.filter(device => { - if (!device) { - console.log('跳过空设备'); - return false; - } - - console.log('设备数据:', device); - - // 只要求有任意ID字段或标识符 - const hasId = device.deviceId || device.id || device.device_id || device.device; - if (!hasId) { - console.log('跳过缺少ID的设备:', device); - return false; - } - - // 放宽名称验证 - const hasName = device.name || device.deviceName || device.device_name || device.title; - if (!hasName) { - console.log('跳过缺少名称的设备:', device); - return false; - } - - // 检查位置和高度,允许字符串类型并转换 - let position = device.position || device.u_position || device.uPos || device.u; - let height = device.height || device.u_height || device.uHeight || device.size || 1; - - // 如果是字符串,尝试转换为数字 - if (typeof position === 'string') { - position = parseInt(position, 10); - } - if (typeof height === 'string') { - height = parseInt(height, 10); - } - - if (typeof position !== 'number' || isNaN(position) || position <= 0) { - console.log('跳过位置无效的设备:', device); - return false; - } - - if (typeof height !== 'number' || isNaN(height) || height <= 0) { - console.log('跳过高度无效的设备:', device); - return false; - } - - // 统一化数据格式 - device.position = position; - device.height = height; - device.name = hasName; - device.deviceId = hasId; - - console.log('有效的设备:', device); - return true; - }); - - console.log('最终有效设备列表:', validDevices); - setDevices(validDevices); - - // 将处理后的数据存入缓存 - setDeviceCache(prev => ({ ...prev, [rackId]: validDevices })); - - if (validDevices.length === 0) { - console.warn('未找到有效设备数据'); - message.warning('该机柜内暂无设备数据'); - } - } catch (error) { - message.error('获取设备列表失败'); - console.error('获取设备列表失败:', error); - setDevices([]); - } finally { - setLoadingDevices(false); - } - }, [deviceCache]); - - // 搜索设备 - 全局搜索,支持跨机柜 - const handleSearch = useCallback(async (keyword) => { - setSearchKeyword(keyword); - - if (!keyword || !keyword.trim()) { - setSearchResults([]); - setHighlightedDevice(null); - setSearchMatchCount(0); - return; - } - - const searchLower = keyword.toLowerCase().trim(); - setSearching(true); - - try { - // 调用全局搜索API,搜索所有机柜的设备 - const response = await axios.get('/api/devices', { - params: { keyword: searchLower, pageSize: 100 } - }); - - const devicesData = response.data.devices || []; - - // 构建搜索结果,包含机柜信息 - const results = devicesData.map(device => ({ - deviceId: device.deviceId || device.id, - name: device.name, - type: device.type, - rackId: device.rackId, - rackName: device.Rack?.name || '未知机柜', - roomName: device.Rack?.Room?.name || '未知机房', - position: device.position - })); - - setSearchResults(results); - setSearchMatchCount(results.length); - - // 如果有搜索结果,自动定位到第一个设备所在的机柜 - if (results.length > 0) { - const firstResult = results[0]; - setHighlightedDevice(firstResult.deviceId); - - // 如果设备不在当前机柜,自动切换机柜 - if (selectedRack && firstResult.rackId !== selectedRack.rackId) { - const targetRack = racks.find(r => r.rackId === firstResult.rackId); - if (targetRack) { - // 找到设备所在的机房 - if (targetRack?.Room) { - const roomKey = targetRack.Room.roomId || targetRack.Room.id || targetRack.Room.name; - if (roomKey !== selectedRoom) { - setSelectedRoom(roomKey); - } - } - - // 切换到设备所在的机柜 - setSelectedRack(targetRack); - // 不再直接调用fetchDevices,由useEffect处理 - message.info(`已跳转到机柜: ${firstResult.rackName}`); - } - } else { - message.info(`已定位到设备: ${firstResult.name}`); - } - } else { - setHighlightedDevice(null); - message.warning('未找到匹配的设备'); - } - } catch (error) { - console.error('全局搜索失败:', error); - message.error('搜索失败,请重试'); - setSearchResults([]); - setSearchMatchCount(0); - } finally { - setSearching(false); - } - }, [selectedRack, selectedRoom, racks, fetchDevices]); - - // 清除搜索 - const clearSearch = useCallback(() => { - setSearchKeyword(''); - setSearchResults([]); - setHighlightedDevice(null); - setSearchMatchCount(0); - }, []); - - // 跳转到指定设备 - const jumpToDevice = useCallback((deviceId) => { - setHighlightedDevice(deviceId); - message.info(`已定位到设备: ${deviceId}`); - }, []); - - // 获取所有机柜 - 使用 useCallback 避免重复创建 - const fetchRacks = useCallback(async () => { - try { - setLoading(true); - setError(null); - // 为机柜可视化页面传递较大的pageSize以获取所有机柜 - const response = await axios.get('/api/racks', { params: { pageSize: 1000 } }); - - // 验证数据结构并过滤有效机柜 - // 机柜API返回的是包含total和racks数组的对象 - const racksArray = response.data.racks || []; - const validRacks = racksArray.filter(rack => - rack && - typeof rack === 'object' && - rack.rackId && - typeof rack.height === 'number' - ); - - setRacks(validRacks); - if (validRacks.length > 0) { - // 自动选择第一个机柜 - const firstRack = validRacks[0]; - setSelectedRack(firstRack); - - // 设置对应的机房 - if (firstRack?.Room) { - const roomKey = firstRack.Room.roomId || firstRack.Room.id || firstRack.Room.name; - setSelectedRoom(roomKey); - } - - // 不再直接调用fetchDevices,由useEffect处理 - } else { - setSelectedRack(null); - setSelectedRoom(null); - setDevices([]); - message.warning('未找到有效的机柜数据'); - } - } catch (error) { - message.error('获取机柜列表失败'); - console.error('获取机柜列表失败:', error); - setError(error); - setRacks([]); - setSelectedRack(null); - setDevices([]); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchRacks(); - }, [fetchRacks]); - - // 使用useMemo缓存处理后的设备数据,只有当rackId变化时才重新处理 - const processedDevices = useMemo(() => { - if (!devices || !devices.length) { - return []; - } - - // 处理设备数据,添加必要的计算属性 - return devices.map(device => { - // 计算设备在机柜中的位置(用于可视化) - const calculatedPosition = { - top: device.position, - height: device.height - }; - - return { - ...device, - calculatedPosition - }; - }); - }, [devices]); - - // 当selectedRack变化时,获取对应机柜的设备数据 - useEffect(() => { - if (selectedRack?.rackId) { - fetchDevices(selectedRack.rackId); - fetchCablesForRack(selectedRack.rackId); - } else { - setDevices([]); - setCables([]); - } - }, [selectedRack, fetchDevices]); - - // 获取机柜的接线数据 - const fetchCablesForRack = useCallback(async (rackId) => { - try { - const response = await axios.get('/api/cables'); - const rackCables = (response.data.cables || []).filter(cable => - cable.sourceDevice?.rackId === rackId || cable.targetDevice?.rackId === rackId - ); - setCables(rackCables); - } catch (error) { - console.error('获取接线数据失败:', error); - } - }, []); - - // 打开字段配置模态框时获取数据 - const handleOpenTooltipConfig = () => { - if (Object.keys(tooltipFields).length === 0) { - fetchTooltipDeviceFields(); - } - setShowTooltipConfig(true); - }; - - // 生成模拟监控数据 - const generateMonitoringData = (device) => { - const baseTemp = device.type?.includes('服务器') ? 65 : - device.type?.includes('存储') ? 55 : - device.type?.includes('网络') ? 45 : 40; - - const baseVoltage = 220; - const baseUptime = 24 * 365; // 假设运行了1年 - - return { - temperature: (baseTemp + Math.random() * 20 - 10).toFixed(1), // 温度 - voltage: (baseVoltage + Math.random() * 10 - 5).toFixed(1), // 电压 - uptime: Math.floor(baseUptime + Math.random() * 24 * 30), // 运行时长(小时) - power: (50 + Math.random() * 200).toFixed(1), // 功耗 - load: Math.floor(Math.random() * 80 + 10) // 负载百分比 - }; - }; - - const getDeviceStyle = (device, rackHeight) => { - // 添加参数验证 - if (!device || typeof device.position !== 'number' || typeof device.height !== 'number') { - return {}; - } - - const uHeight = 25; // 调整为更小的U高度以适应屏幕显示,1U=25px - const deviceHeight = Math.max(1, device.height) * uHeight; - - // 设备位置从底部开始计算(U1在底部) - let position = Math.max(1, device.position); - let deviceUHeight = Math.max(1, device.height); - - // 确保设备不会超出机柜范围 - if (position + deviceUHeight - 1 > rackHeight) { - // 如果设备会超出机柜,调整位置或高度 - position = Math.max(1, rackHeight - deviceUHeight + 1); - } - - // 计算设备的顶部位置(从机柜顶部算起) - // 设备占用从 position 到 position + height - 1 的U - // 机柜顶部是U0,所以设备顶部的topPosition是: - const deviceBottomU = position; // 设备底部U数 - const deviceTopU = position + deviceUHeight - 1; // 设备顶部U数 - const topPosition = (rackHeight - deviceTopU) * uHeight; - - return { - height: `${deviceHeight}px`, // 确保设备高度精确等于U位高度 - top: `${topPosition}px`, // 确保设备顶部与U位网格线对齐 - // 移除任何可能影响占满U位的样式 - margin: 0, - padding: 0 - }; - }; - - // 格式化日期 - const formatDate = (dateString) => { - if (!dateString) return '未知'; - try { - const date = new Date(dateString); - return date.toLocaleDateString('zh-CN'); - } catch (error) { - return '无效日期'; - } - }; - - // 获取设备类型中文名称 - const getDeviceTypeName = (type) => { - const typeMap = { - 'server': '服务器', - 'switch': '交换机', - 'router': '路由器', - 'storage': '存储设备', - 'ups': 'UPS', - 'firewall': '防火墙', - 'other': '其他设备' - }; - return typeMap[type?.toLowerCase()] || '未知设备'; - }; - - // 生成U数标记(每个U都有标记) - const generateUMarks = (height) => { - const marks = []; - const uHeight = 25; // 调整为更小的U高度以适应屏幕显示 - - for (let i = 1; i <= height; i++) { - marks.push( -
- - {i} - -
- ); - } - - return marks; - }; - - // 生成左侧U数标记(从U1开始显示) - const generateLeftUMarks = (height) => { - const marks = []; - const uHeight = 25; - - for (let u = 1; u <= height; u++) { - // 调整U位标记定位,确保U1在机柜底部,U42在顶部 - const topPosition = (height - u) * uHeight; - - marks.push( -
- {u} -
- ); - } - - return ( -
- {marks} -
- ); - }; - - // 生成右侧U数标记(从U1开始显示) - const generateRightUMarks = (height) => { - const marks = []; - const uHeight = 25; - - for (let u = 1; u <= height; u++) { - // 调整U位标记定位,确保U1在机柜底部,U42在顶部 - const topPosition = (height - u) * uHeight; - - marks.push( -
- {u} -
- ); - } - - return ( -
- {marks} -
- ); - }; - - // 获取机房列表 - const getRooms = () => { - const roomMap = new Map(); - racks.forEach(rack => { - if (rack?.Room) { - const roomKey = rack.Room.roomId || rack.Room.id || rack.Room.name; - if (!roomMap.has(roomKey)) { - roomMap.set(roomKey, { - ...rack.Room, - key: roomKey - }); - } - } - }); - return Array.from(roomMap.values()); - }; - - // 获取指定机房下的机柜 - const getRacksByRoom = (roomId) => { - return racks.filter(rack => { - const rackRoomId = rack?.Room?.roomId || rack?.Room?.id; - return rackRoomId === roomId; - }); - }; - - // 选择机房 - const handleRoomChange = (roomId) => { - console.log('选择机房:', roomId); - setSelectedRoom(roomId); - - // 重置机柜选择 - setSelectedRack(null); - setDevices([]); - - // 如果选择了机房,获取该机房下的机柜 - if (roomId) { - const roomRacks = getRacksByRoom(roomId); - if (roomRacks.length > 0) { - // 自动选择第一个机柜 - const firstRack = roomRacks[0]; - setSelectedRack(firstRack); - fetchDevices(firstRack.rackId); - } - } - }; - - // 选择机柜 - const handleRackChange = (rackId) => { - const rack = racks.find(r => r.rackId === rackId); - if (rack) { - setSelectedRack(rack); - // 不再直接调用fetchDevices,由useEffect处理 - } - }; - - // 重新加载数据 - const handleReload = () => { - setLoading(true); - fetchRacks().then(() => { - message.success('数据已刷新'); - }); - }; - - // 保存背景设置到服务器 - const saveBackgroundSettings = async (type, image, size) => { - try { - await axios.post('/api/background/settings', { - type, - image, - size - }); - } catch (error) { - console.error('保存背景设置失败:', error); - } - }; - - // 从服务器加载背景设置 - const loadBackgroundSettings = async () => { - try { - const response = await axios.get('/api/background/settings'); - if (response.data) { - setBackgroundType(response.data.type || 'gradient'); - setBackgroundImage(response.data.image || null); - setBackgroundSize(response.data.size || 'contain'); - } - } catch (error) { - console.error('加载背景设置失败:', error); - } - }; - - // 重置视角 - const handleResetView = () => { - setZoom(1); - setRotation(0); - }; - - // 放大 - const handleZoomIn = () => { - setZoom(prev => Math.min(prev + 0.3, 3.0)); - }; - - const handleZoomOut = () => { - setZoom(prev => Math.max(prev - 0.3, 0.7)); - }; - - // 无需额外的初始化,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: 240, height: '40px', borderRadius: '10px' }} - allowClear - prefix={} - suffix={ - searchMatchCount > 0 ? ( - - ) : null - } - /> - {searchKeyword && ( - - - -
-
- - {searchKeyword && ( - -
- - - {searchResults.length > 0 - ? `找到 ${searchResults.length} 个设备` - : searching ? '搜索中...' : '未找到匹配的设备'} - - {searching && ( - - )} - {searchResults.length > 0 && ( - - {searchResults.slice(0, 5).map(result => { - const isCurrentRack = selectedRack && result.rackId === selectedRack.rackId; - return ( - - ); - })} - {searchResults.length > 5 && ( - + 还有 {searchResults.length - 5} 个 - )} - - )} -
-
- )} - - -
- - - - - - {backgroundType === 'image' && ( - - { - const image = e.target.value; - setBackgroundImage(image); - await saveBackgroundSettings(backgroundType, image, backgroundSize); - }} - value={backgroundImage || ''} - /> - { - const file = e.target.files[0]; - if (file) { - try { - setUploading(true); - 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('背景图片上传成功'); - await saveBackgroundSettings('image', response.data.path, backgroundSize); - } else { - message.error('上传失败:服务器返回格式不正确'); - } - } catch (error) { - console.error('上传失败:', error); - message.error('背景图片上传失败,请重试'); - } finally { - setUploading(false); - } - } - }} - style={{ display: 'none' }} - id="backgroundFileInput" - /> - - - {backgroundImage && ( - - )} - - )} - -
- - - {loading ? ( -
- -
- ) : selectedRack ? ( -
- {loadingDevices && ( -
- -
- )} -
- {/* 机柜主体 */} -
- {/* 扫描线效果 */} -
- - {/* 玻璃光泽效果 */} -
- {/* 左侧U数标记 - 贴紧机架边缘 */} -
- {generateLeftUMarks(selectedRack.height)} -
- - {/* 右侧U数标记 - 贴紧机架边缘 */} -
- {generateRightUMarks(selectedRack.height)} -
- - {/* 左侧U型安装导轨 */} -
- {/* 导轨安装孔 - 左侧 */} - {Array.from({ length: selectedRack.height }).map((_, index) => { - // 计算安装孔的top位置,确保与U位标记对齐 - // U1在底部,所以index对应的top位置是:(selectedRack.height - 1 - index) * 25 + 2 - const topPosition = (selectedRack.height - 1 - index) * 25 + 2; - return ( -
-
-
- ); - })} -
- - {/* 右侧U型安装导轨 */} -
- {/* 导轨安装孔 - 右侧 */} - {Array.from({ length: selectedRack.height }).map((_, index) => { - // 计算安装孔的top位置,确保与U位标记对齐 - // U1在底部,所以index对应的top位置是:(selectedRack.height - 1 - index) * 25 + 2 - const topPosition = (selectedRack.height - 1 - index) * 25 + 2; - return ( -
-
-
- ); - })} -
- - {/* 前面板 */} -
- {/* 细微网格纹理 */} -
- - {/* U位网格线 */} -
- {Array.from({ length: Math.max(0, selectedRack.height - 1) }).map((_, index) => ( -
- ))} -
- - {/* 移除了内侧U数标记,因为外侧已有U数显示 */} - - {/* 设备 */} - {/* 移除了设备数量显示,现在在机柜标题旁边 */} - - {devices.length > 0 ? devices.map(device => { - try { - // 处理统一化后的设备数据 - const deviceId = device?.deviceId || device?.id || device?.device_id || device?.device || `device-${Math.random()}`; - const deviceName = device?.name || device?.deviceName || device?.device_name || device?.title || '未知设备'; - const position = device?.position || 1; - const height = device?.height || 1; - - // 检查是否是高亮的设备 - const isHighlighted = highlightedDevice === deviceId; - - // 获取状态主题 - const statusTheme = getStatusTheme(device?.status); - const statusColor = getDeviceStatusColor(device?.status); - - return ( -
{ - setSelectedDevice(device); - setDetailDrawerVisible(true); - }} - onMouseEnter={(e) => { - const isOneU = (device?.height || 1) === 1; - const isFaultStatus = device?.status === 'error' || device?.status === 'fault'; - if (isOneU && !isFaultStatus) { - e.currentTarget.style.height = '33px'; - e.currentTarget.style.zIndex = '150'; - } - e.currentTarget.style.transform = 'scale(1.008)'; - e.currentTarget.style.boxShadow = ` - 0 4px 12px ${statusTheme.shadowColor}, - 0 2px 6px rgba(0,0,0,0.3), - inset 0 1px 0 rgba(255,255,255,0.15) - `; - e.currentTarget.style.borderColor = statusTheme.topBorderColor; - - const rect = e.currentTarget.getBoundingClientRect(); - setTooltipPosition({ x: rect.right + 5, y: rect.top + rect.height / 2 }); - setDeviceTooltip({ device: device }); - }} - onMouseLeave={(e) => { - const isOneU = (device?.height || 1) === 1; - const isFaultStatus = device?.status === 'error' || device?.status === 'fault'; - if (isOneU && !isFaultStatus) { - const originalHeight = (device?.height || 1) * 25; - e.currentTarget.style.height = `${originalHeight}px`; - e.currentTarget.style.zIndex = '100'; - } - e.currentTarget.style.transform = 'scale(1)'; - e.currentTarget.style.boxShadow = ` - 0 1px 2px rgba(0,0,0,0.3), - 0 2px 4px rgba(0,0,0,0.2), - inset 0 1px 0 rgba(255,255,255,0.1), - inset 0 -1px 0 rgba(0,0,0,0.1) - `; - e.currentTarget.style.borderColor = statusTheme.borderColor; - setDeviceTooltip(null); - }} - > -
- - {/* 左侧状态指示区域 - 增强版 */} -
- {/* 设备类型标识 */} -
- - {device.type?.toUpperCase() || 'DEV'} - -
- - {/* LED状态指示灯组 */} -
- {/* 主状态灯 */} -
-
-
-
-
- - {/* 电源指示灯 */} -
-
- - PWR - -
-
- - {/* 设备序列号标签 */} -
- - SN:{device.serial?.slice(-4) || '0000'} - -
-
- - {/* 中间设备信息区域 - 增强版 */} -
- {/* 设备品牌/厂商标识 */} -
-
- {getDeviceIcon(device.type)} -
- - {device.brand || 'ENTERPRISE'} - -
- - {/* 设备名称 */} -
- {deviceName} -
- - {/* 型号和规格 */} -
- - {device.model || device.type?.toUpperCase() || 'STD'} - - {device.ip && ( - - {device.ip} - - )} -
- - {/* 散热/通风口装饰 - 根据设备类型显示 */} - {(device.type === 'server' || device.type === 'storage') && ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
- ))} -
- )} -
- - {/* 右侧端口/功能区域 - 增强版 */} -
- {/* 端口指示灯阵列 */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
- ))} -
- - {/* 管理接口标识 */} -
-
- - MGMT - -
- - {/* 设备高度U数标识 */} -
- {device.height}U -
-
-
- ); - } catch (error) { - console.error('设备渲染错误:', error, device); - // 渲染一个简单的错误显示元素 - return ( -
- 设备加载错误 -
- ); - } - }) : ( -
- 暂无设备数据 -
- )} -
- - {/* 侧面 */} -
- - {/* 机柜顶部 */} -
- - {/* 接线连线层 */} - {showCables && cables.length > 0 && ( - - {cables.map((cable, index) => { - const sourceDevice = devices.find(d => d.deviceId === cable.sourceDeviceId); - const targetDevice = devices.find(d => d.deviceId === cable.targetDeviceId); - - if (!sourceDevice || !targetDevice) return null; - - const sourceStyle = getDeviceStyle(sourceDevice, selectedRack.height); - const targetStyle = getDeviceStyle(targetDevice, selectedRack.height); - - const sourceTop = parseInt(sourceStyle.top); - const sourceHeight = parseInt(sourceStyle.height); - const targetTop = parseInt(targetStyle.top); - const targetHeight = parseInt(targetStyle.height); - - const sourceX = 50; - const sourceY = sourceTop + sourceHeight / 2; - const targetX = 50; - const targetY = targetTop + targetHeight / 2; - - const cableColor = cable.status === 'normal' ? '#10b981' : - cable.status === 'fault' ? '#ef4444' : '#6b7280'; - const cableDash = cable.cableType === 'fiber' ? '5,5' : 'none'; - - return ( - - - - - - ); - })} - - )} - - {/* 机柜名称和设备数量 */} -
- {/* 机柜标题 */} -
- {selectedRack.name} ({selectedRack.rackId}) -
- - {/* 设备数量badge */} -
- {devices.length} - 设备 -
-
-
-
-
- ) : ( -
- 请选择机柜 -
- )} - - - {/* 设备详情字段配置模态框 */} - - - 设备详情字段配置 -
- } - open={showTooltipConfig} - onCancel={() => setShowTooltipConfig(false)} - footer={null} - width={520} - styles={{ body: { padding: designTokens.spacing.lg } }} - > - {loadingTooltipFields ? ( -
- -
加载字段配置中...
-
- ) : Object.keys(tooltipFields).length === 0 ? ( -
- - - 请先在设备字段管理中添加字段 - -
- ) : ( -
-
-
- 选择要在设备详情中显示的字段: ({Object.keys(tooltipFields).length} 个字段) -
-
- {Object.entries(tooltipFields).map(([key, field]) => ( -
{ - setTooltipFields(prev => ({ - ...prev, - [key]: { ...prev[key], enabled: !prev[key].enabled } - })); - }} - > - - - {field.label} - -
- ))} -
-
- -
- - - - - -
-
- )} - - - {/* 设备详情tooltip - 放在容器外部确保不被遮挡 */} - {deviceTooltip && ( -
-
- 设备详情 -
-
- {console.log('tooltipFields:', tooltipFields)} - {Object.entries(tooltipFields).map(([key, field]) => { - if (!field.enabled) return null; - - let value = deviceTooltip.device[field.field]; - let displayValue = '-'; - - if (value !== undefined && value !== null && value !== '') { - // 特殊字段格式化 - if (field.field === 'status') { - const statusMap = { - 'running': '运行中', - 'maintenance': '维护中', - 'offline': '离线', - 'fault': '故障', - 'warning': '警告', - 'error': '故障' - }; - displayValue = statusMap[value] || value; - } else if (field.field === 'type') { - const typeMap = { - 'server': '服务器', - 'switch': '交换机', - 'router': '路由器', - 'storage': '存储设备', - 'other': '其他设备' - }; - displayValue = typeMap[value] || value; - } else if (field.field === 'position') { - displayValue = `U${value}`; - } else if (field.field === 'height') { - displayValue = `${value}U`; - } else if (field.field === 'powerConsumption' || field.field === 'power') { - displayValue = `${value}W`; - } else if (field.fieldType === 'date') { - try { - displayValue = new Date(value).toLocaleDateString('zh-CN'); - } catch (e) { - displayValue = value; - } - } else if (field.field === 'rackId') { - displayValue = value; - } else { - displayValue = value; - } - } - - // 获取字段标签(优先使用配置中的label,否则使用fieldName) - const label = field.label || field.fieldName; - - // 状态字段使用特殊颜色 - let valueColor = '#e2e8f0'; - if (field.field === 'status') { - if (value === 'running') valueColor = '#4ade80'; - else if (value === 'warning') valueColor = '#fbbf24'; - else if (value === 'error' || value === 'fault') valueColor = '#f87171'; - } - - return ( -
- {label}: {displayValue} -
- ); - })} -
-
- )} - - {/* 设备详情抽屉 */} - { - setDetailDrawerVisible(false); - setSelectedDevice(null); - }} - cables={cables} - onRefreshCables={() => selectedRack?.rackId && fetchCablesForRack(selectedRack.rackId)} - /> - -
- ); -} - -export default React.memo(RackVisualization); \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 74a0542..61292fd 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], + assetsInclude: ['**/*.hdr', '**/*.woff2'], server: { host: '0.0.0.0', port: 3000,