perf(frontend): 优化前端性能与代码结构
refactor(components): 使用React.memo和useCallback优化组件渲染 feat(cache): 添加API缓存管理功能 perf(build): 配置Vite构建优化选项 style: 提取工具函数减少重复代码 chore: 添加懒加载和Suspense支持
This commit is contained in:
+904
@@ -840,6 +840,910 @@ SESSION_SECRET=random_session_secret_key_here
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 更新升级流程
|
||||||
|
|
||||||
|
### 手动更新步骤
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
|
# 拉取最新代码
|
||||||
|
git fetch origin
|
||||||
|
git checkout main
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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 <commit-hash>
|
||||||
|
|
||||||
|
# 重新构建
|
||||||
|
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 <PID>
|
||||||
|
|
||||||
|
# 或修改为其他端口
|
||||||
|
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'));
|
||||||
|
|
||||||
|
// 使用
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 "备份验证完成"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 安全加固清单
|
||||||
|
|
||||||
|
### 服务器安全
|
||||||
|
|
||||||
|
- [ ] 配置防火墙规则(仅开放必要端口)
|
||||||
|
- [ ] 启用SSH密钥认证,禁用密码登录
|
||||||
|
- [ ] 安装配置fail2ban防止暴力破解
|
||||||
|
- [ ] 定期更新系统安全补丁
|
||||||
|
- [ ] 配置自动安全更新
|
||||||
|
- [ ] 启用系统审计日志
|
||||||
|
- [ ] 限制root用户登录
|
||||||
|
|
||||||
|
### 数据库安全
|
||||||
|
|
||||||
|
- [ ] 使用强密码策略
|
||||||
|
- [ ] 创建专用数据库用户,禁用root远程登录
|
||||||
|
- [ ] 定期备份数据库
|
||||||
|
- [ ] 启用数据库审计日志
|
||||||
|
- [ ] 限制数据库用户权限(最小权限原则)
|
||||||
|
- [ ] 加密数据库连接(SSL/TLS)
|
||||||
|
|
||||||
|
### 应用安全
|
||||||
|
|
||||||
|
- [ ] 配置HTTPS强制跳转
|
||||||
|
- [ ] 设置安全的Cookie属性(HttpOnly, Secure)
|
||||||
|
- [ ] 启用CSRF防护
|
||||||
|
- [ ] 实现请求速率限制
|
||||||
|
- [ ] 配置安全的HTTP头
|
||||||
|
- [ ] 敏感信息加密存储
|
||||||
|
- [ ] 实现完善的权限控制
|
||||||
|
|
||||||
|
### 监控与告警
|
||||||
|
|
||||||
|
- [ ] 配置异常登录告警
|
||||||
|
- [ ] 启用API访问日志
|
||||||
|
- [ ] 监控服务状态和资源使用
|
||||||
|
- [ ] 配置磁盘空间告警
|
||||||
|
- [ ] 设置数据库连接数告警
|
||||||
|
- [ ] 实现自动化健康检查
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**🎉 部署完成后,您就可以开始使用IDC设备管理系统了!**
|
**🎉 部署完成后,您就可以开始使用IDC设备管理系统了!**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+72
-69
@@ -1,27 +1,28 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, Suspense, lazy } from 'react';
|
||||||
import { Layout, Menu, theme, Button, Dropdown, Avatar, message, Space, Divider } from 'antd';
|
import { Layout, Menu, theme, Button, Dropdown, Avatar, message, Space, Divider } from 'antd';
|
||||||
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, UserOutlined as UserIcon, HistoryOutlined, AuditOutlined, ToolOutlined, ScheduleOutlined } from '@ant-design/icons';
|
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, HistoryOutlined, AuditOutlined, ToolOutlined, ScheduleOutlined } from '@ant-design/icons';
|
||||||
import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from './context/AuthContext';
|
import { useAuth } from './context/AuthContext';
|
||||||
import Dashboard from './pages/Dashboard';
|
|
||||||
import DeviceManagement from './pages/DeviceManagement';
|
|
||||||
import RackManagement from './pages/RackManagement';
|
|
||||||
import RoomManagement from './pages/RoomManagement';
|
|
||||||
import DeviceFieldManagement from './pages/DeviceFieldManagement';
|
|
||||||
import RackVisualization from './pages/RackVisualization';
|
|
||||||
import ConsumableManagement from './pages/ConsumableManagement';
|
|
||||||
import ConsumableStatistics from './pages/ConsumableStatistics';
|
|
||||||
import ConsumableLogs from './pages/ConsumableLogs';
|
|
||||||
import CategoryManagement from './pages/CategoryManagement';
|
|
||||||
import UserManagement from './pages/UserManagement';
|
|
||||||
import LoginHistory from './pages/LoginHistory';
|
|
||||||
import OperationLogs from './pages/OperationLogs';
|
|
||||||
import Login from './pages/Login';
|
|
||||||
import TicketManagement from './pages/TicketManagement';
|
|
||||||
import TicketCategoryManagement from './pages/TicketCategoryManagement';
|
|
||||||
import TicketStatistics from './pages/TicketStatistics';
|
|
||||||
import { Spin } from 'antd';
|
import { Spin } from 'antd';
|
||||||
|
|
||||||
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
|
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 ConsumableManagement = lazy(() => import('./pages/ConsumableManagement'));
|
||||||
|
const ConsumableStatistics = lazy(() => import('./pages/ConsumableStatistics'));
|
||||||
|
const ConsumableLogs = lazy(() => import('./pages/ConsumableLogs'));
|
||||||
|
const CategoryManagement = lazy(() => import('./pages/CategoryManagement'));
|
||||||
|
const UserManagement = lazy(() => import('./pages/UserManagement'));
|
||||||
|
const LoginHistory = lazy(() => import('./pages/LoginHistory'));
|
||||||
|
const OperationLogs = lazy(() => import('./pages/OperationLogs'));
|
||||||
|
const Login = lazy(() => import('./pages/Login'));
|
||||||
|
const TicketManagement = lazy(() => import('./pages/TicketManagement'));
|
||||||
|
const TicketCategoryManagement = lazy(() => import('./pages/TicketCategoryManagement'));
|
||||||
|
const TicketStatistics = lazy(() => import('./pages/TicketStatistics'));
|
||||||
|
|
||||||
const { Header, Content, Sider } = Layout;
|
const { Header, Content, Sider } = Layout;
|
||||||
|
|
||||||
const PrivateRoute = ({ children }) => {
|
const PrivateRoute = ({ children }) => {
|
||||||
@@ -46,7 +47,25 @@ const PrivateRoute = ({ children }) => {
|
|||||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return children;
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
background: '#f5f5f5'
|
||||||
|
}}>
|
||||||
|
<Spin size="large" tip="正在加载页面..." />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AppLayout>
|
||||||
|
{children}
|
||||||
|
</AppLayout>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppLayout = ({ children }) => {
|
const AppLayout = ({ children }) => {
|
||||||
@@ -285,14 +304,28 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
background: '#f5f5f5'
|
||||||
|
}}>
|
||||||
|
<Spin size="large" tip="正在加载登录页面..." />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Login />
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<Dashboard />
|
||||||
<Dashboard />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -300,9 +333,7 @@ function App() {
|
|||||||
path="/devices"
|
path="/devices"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<DeviceManagement />
|
||||||
<DeviceManagement />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -310,9 +341,7 @@ function App() {
|
|||||||
path="/racks"
|
path="/racks"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<RackManagement />
|
||||||
<RackManagement />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -320,9 +349,7 @@ function App() {
|
|||||||
path="/rooms"
|
path="/rooms"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<RoomManagement />
|
||||||
<RoomManagement />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -330,9 +357,7 @@ function App() {
|
|||||||
path="/fields"
|
path="/fields"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<DeviceFieldManagement />
|
||||||
<DeviceFieldManagement />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -340,9 +365,7 @@ function App() {
|
|||||||
path="/visualization"
|
path="/visualization"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<RackVisualization />
|
||||||
<RackVisualization />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -350,9 +373,7 @@ function App() {
|
|||||||
path="/consumables"
|
path="/consumables"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<ConsumableManagement />
|
||||||
<ConsumableManagement />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -360,9 +381,7 @@ function App() {
|
|||||||
path="/consumables-categories"
|
path="/consumables-categories"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<CategoryManagement />
|
||||||
<CategoryManagement />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -370,9 +389,7 @@ function App() {
|
|||||||
path="/consumables-stats"
|
path="/consumables-stats"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<ConsumableStatistics />
|
||||||
<ConsumableStatistics />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -380,9 +397,7 @@ function App() {
|
|||||||
path="/consumables-logs"
|
path="/consumables-logs"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<ConsumableLogs />
|
||||||
<ConsumableLogs />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -390,9 +405,7 @@ function App() {
|
|||||||
path="/users"
|
path="/users"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<UserManagement />
|
||||||
<UserManagement />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -400,9 +413,7 @@ function App() {
|
|||||||
path="/login-history"
|
path="/login-history"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<LoginHistory />
|
||||||
<LoginHistory />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -410,9 +421,7 @@ function App() {
|
|||||||
path="/operation-logs"
|
path="/operation-logs"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<OperationLogs />
|
||||||
<OperationLogs />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -420,9 +429,7 @@ function App() {
|
|||||||
path="/tickets"
|
path="/tickets"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<TicketManagement />
|
||||||
<TicketManagement />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -430,9 +437,7 @@ function App() {
|
|||||||
path="/ticket-categories"
|
path="/ticket-categories"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<TicketCategoryManagement />
|
||||||
<TicketCategoryManagement />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -440,9 +445,7 @@ function App() {
|
|||||||
path="/ticket-statistics"
|
path="/ticket-statistics"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<TicketStatistics />
|
||||||
<TicketStatistics />
|
|
||||||
</AppLayout>
|
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
const cacheManager = (() => {
|
||||||
|
const cache = new Map();
|
||||||
|
const cacheTimestamps = new Map();
|
||||||
|
const defaultTTL = 5 * 60 * 1000;
|
||||||
|
const config = new Map();
|
||||||
|
|
||||||
|
const generateKey = (method, url, params) => {
|
||||||
|
const paramsStr = params ? JSON.stringify(params, Object.keys(params).sort()) : '';
|
||||||
|
return `${method}:${url}:${paramsStr}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired = (key) => {
|
||||||
|
const timestamp = cacheTimestamps.get(key);
|
||||||
|
if (!timestamp) return true;
|
||||||
|
const ttl = config.get(key)?.ttl || defaultTTL;
|
||||||
|
return Date.now() - timestamp > ttl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const get = (method, url, params) => {
|
||||||
|
const key = generateKey(method, url, params);
|
||||||
|
if (isExpired(key)) {
|
||||||
|
cache.delete(key);
|
||||||
|
cacheTimestamps.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cache.get(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = (method, url, params, data, ttl) => {
|
||||||
|
const key = generateKey(method, url, params);
|
||||||
|
cache.set(key, data);
|
||||||
|
cacheTimestamps.set(key, Date.now());
|
||||||
|
config.set(key, { ttl });
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidate = (url) => {
|
||||||
|
const keysToDelete = [];
|
||||||
|
cache.forEach((_, key) => {
|
||||||
|
if (key.includes(url)) {
|
||||||
|
keysToDelete.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
keysToDelete.forEach(key => {
|
||||||
|
cache.delete(key);
|
||||||
|
cacheTimestamps.delete(key);
|
||||||
|
config.delete(key);
|
||||||
|
});
|
||||||
|
return keysToDelete.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidatePattern = (pattern) => {
|
||||||
|
const regex = new RegExp(pattern);
|
||||||
|
const keysToDelete = [];
|
||||||
|
cache.forEach((_, key) => {
|
||||||
|
if (regex.test(key)) {
|
||||||
|
keysToDelete.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
keysToDelete.forEach(key => {
|
||||||
|
cache.delete(key);
|
||||||
|
cacheTimestamps.delete(key);
|
||||||
|
config.delete(key);
|
||||||
|
});
|
||||||
|
return keysToDelete.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
cache.clear();
|
||||||
|
cacheTimestamps.clear();
|
||||||
|
config.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTTL = (url, ttl) => {
|
||||||
|
config.set(url, { ttl });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStats = () => {
|
||||||
|
return {
|
||||||
|
size: cache.size,
|
||||||
|
keys: Array.from(cache.keys())
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
get,
|
||||||
|
set,
|
||||||
|
invalidate,
|
||||||
|
invalidatePattern,
|
||||||
|
clear,
|
||||||
|
setTTL,
|
||||||
|
getStats,
|
||||||
|
defaultTTL
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const cacheInterceptor = (api) => {
|
||||||
|
const requestCache = new Set();
|
||||||
|
const pendingRequests = new Map();
|
||||||
|
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
if (config.method?.toLowerCase() === 'get') {
|
||||||
|
const cacheKey = cacheManager.generateKey(
|
||||||
|
config.method,
|
||||||
|
config.url,
|
||||||
|
config.params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requestCache.has(cacheKey)) {
|
||||||
|
config.adapter = () => {
|
||||||
|
const cachedData = cacheManager.get(
|
||||||
|
config.method,
|
||||||
|
config.url,
|
||||||
|
config.params
|
||||||
|
);
|
||||||
|
if (cachedData) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: cachedData,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
headers: {},
|
||||||
|
config
|
||||||
|
});
|
||||||
|
}
|
||||||
|
requestCache.delete(cacheKey);
|
||||||
|
return api.request(config);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
if (response.config.method?.toLowerCase() === 'get') {
|
||||||
|
const cacheKey = cacheManager.generateKey(
|
||||||
|
response.config.method,
|
||||||
|
response.config.url,
|
||||||
|
response.config.params
|
||||||
|
);
|
||||||
|
cacheManager.set(
|
||||||
|
response.config.method,
|
||||||
|
response.config.url,
|
||||||
|
response.config.params,
|
||||||
|
response.data
|
||||||
|
);
|
||||||
|
requestCache.add(cacheKey);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.config) {
|
||||||
|
const cacheKey = cacheManager.generateKey(
|
||||||
|
error.config.method,
|
||||||
|
error.config.url,
|
||||||
|
error.config.params
|
||||||
|
);
|
||||||
|
requestCache.delete(cacheKey);
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cachedAPI = {
|
||||||
|
get: async (url, params = {}, ttl) => {
|
||||||
|
const method = 'get';
|
||||||
|
const cached = cacheManager.get(method, url, params);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
return api.get(url, { params }).then(data => {
|
||||||
|
cacheManager.set(method, url, params, data, ttl);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
post: (url, data) => api.post(url, data).then(data => {
|
||||||
|
cacheManager.invalidate(url);
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
|
||||||
|
put: (url, data) => api.put(url, data).then(data => {
|
||||||
|
cacheManager.invalidate(url);
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: (url) => api.delete(url).then(data => {
|
||||||
|
cacheManager.invalidate(url);
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
|
||||||
|
invalidate: (url) => cacheManager.invalidate(url),
|
||||||
|
|
||||||
|
invalidatePattern: (pattern) => cacheManager.invalidatePattern(pattern),
|
||||||
|
|
||||||
|
clearCache: () => cacheManager.clear(),
|
||||||
|
|
||||||
|
setCacheTTL: (url, ttl) => cacheManager.setTTL(url, ttl),
|
||||||
|
|
||||||
|
getCacheStats: () => cacheManager.getStats()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deviceAPI = {
|
||||||
|
list: (params) => cachedAPI.get('/devices', params),
|
||||||
|
get: (deviceId) => cachedAPI.get(`/devices/${deviceId}`),
|
||||||
|
create: (data) => cachedAPI.post('/devices', data),
|
||||||
|
update: (deviceId, data) => cachedAPI.put(`/api/devices/${deviceId}`, data),
|
||||||
|
delete: (deviceId) => cachedAPI.delete(`/api/devices/${deviceId}`),
|
||||||
|
batchOffline: (data) => cachedAPI.post('/devices/batch-offline', data),
|
||||||
|
batchDelete: (data) => cachedAPI.delete('/devices/batch-delete', { data }),
|
||||||
|
export: (params) => api.get('/devices/export', { params, responseType: 'blob' }),
|
||||||
|
import: (formData) => api.post('/devices/import', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rackAPI = {
|
||||||
|
list: (params) => cachedAPI.get('/racks', params),
|
||||||
|
get: (rackId) => cachedAPI.get(`/racks/${rackId}`),
|
||||||
|
create: (data) => cachedAPI.post('/racks', data),
|
||||||
|
update: (rackId, data) => cachedAPI.put(`/racks/${rackId}`, data),
|
||||||
|
delete: (rackId) => cachedAPI.delete(`/racks/${rackId}`),
|
||||||
|
import: (formData) => api.post('/racks/import', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export const roomAPI = {
|
||||||
|
list: (params) => cachedAPI.get('/rooms', params),
|
||||||
|
get: (roomId) => cachedAPI.get(`/rooms/${roomId}`),
|
||||||
|
create: (data) => cachedAPI.post('/rooms', data),
|
||||||
|
update: (roomId, data) => cachedAPI.put(`/rooms/${roomId}`, data),
|
||||||
|
delete: (roomId) => cachedAPI.delete(`/rooms/${roomId}`)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deviceFieldAPI = {
|
||||||
|
list: () => cachedAPI.get('/deviceFields'),
|
||||||
|
get: (fieldId) => cachedAPI.get(`/deviceFields/${fieldId}`),
|
||||||
|
create: (data) => cachedAPI.post('/deviceFields', data),
|
||||||
|
update: (fieldId, data) => cachedAPI.put(`/deviceFields/${fieldId}`, data),
|
||||||
|
delete: (fieldId) => cachedAPI.delete(`/deviceFields/${fieldId}`),
|
||||||
|
updateConfig: (data) => cachedAPI.post('/deviceFields/config', data)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const consumableAPI = {
|
||||||
|
list: (params) => cachedAPI.get('/consumables', params),
|
||||||
|
get: (consumableId) => cachedAPI.get(`/consumables/${consumableId}`),
|
||||||
|
create: (data) => cachedAPI.post('/consumables', data),
|
||||||
|
update: (consumableId, data) => cachedAPI.put(`/consumables/${consumableId}`, data),
|
||||||
|
delete: (consumableId) => cachedAPI.delete(`/consumables/${consumableId}`),
|
||||||
|
import: (data) => cachedAPI.post('/consumables/import', data),
|
||||||
|
quickInOut: (data) => cachedAPI.post('/consumables/quick-inout', data),
|
||||||
|
getStatistics: () => cachedAPI.get('/consumables/statistics/summary'),
|
||||||
|
getLowStock: () => cachedAPI.get('/consumables/low-stock')
|
||||||
|
};
|
||||||
|
|
||||||
|
export const consumableCategoryAPI = {
|
||||||
|
list: (params) => cachedAPI.get('/consumable-categories', params),
|
||||||
|
getList: (params) => cachedAPI.get('/consumable-categories/list', params),
|
||||||
|
create: (data) => cachedAPI.post('/consumable-categories', data),
|
||||||
|
update: (id, data) => cachedAPI.put(`/consumable-categories/${id}`, data),
|
||||||
|
delete: (id) => cachedAPI.delete(`/consumable-categories/${id}`)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const consumableLogAPI = {
|
||||||
|
list: (params) => cachedAPI.get('/consumables/logs', params),
|
||||||
|
create: (data) => cachedAPI.post('/consumables/logs', data),
|
||||||
|
export: (params) => api.get('/consumables/logs/export', { params, responseType: 'blob' }),
|
||||||
|
import: (data) => cachedAPI.post('/consumables/logs/import', data)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ticketCategoryAPI = {
|
||||||
|
list: (params) => cachedAPI.get('/ticket-categories', params),
|
||||||
|
create: (data) => cachedAPI.post('/ticket-categories', data),
|
||||||
|
update: (code, data) => cachedAPI.put(`/ticket-categories/${code}`, data),
|
||||||
|
delete: (code) => cachedAPI.delete(`/ticket-categories/${code}`),
|
||||||
|
getTree: () => cachedAPI.get('/ticket-categories/tree'),
|
||||||
|
init: () => cachedAPI.post('/ticket-categories/init')
|
||||||
|
};
|
||||||
|
|
||||||
|
export { cacheManager };
|
||||||
|
export default { cachedAPI, cacheManager };
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { Table, Button, Modal, Form, Input, Select, InputNumber, message, Card, Space, Popconfirm, Upload, Table as AntTable } from 'antd';
|
import { Table, Button, Modal, Form, Input, Select, InputNumber, message, Card, Space, Popconfirm, Upload, Table as AntTable } from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ExportOutlined, ImportOutlined, UploadOutlined, FileExcelOutlined, InboxOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ExportOutlined, ImportOutlined, UploadOutlined, FileExcelOutlined, InboxOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -31,7 +31,7 @@ function ConsumableManagement() {
|
|||||||
const [stockType, setStockType] = useState('in');
|
const [stockType, setStockType] = useState('in');
|
||||||
const [stockForm] = Form.useForm();
|
const [stockForm] = Form.useForm();
|
||||||
|
|
||||||
const fetchConsumables = async (page = 1, pageSize = 10) => {
|
const fetchConsumables = useCallback(async (page = 1, pageSize = 10) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await axios.get('/api/consumables', {
|
const response = await axios.get('/api/consumables', {
|
||||||
@@ -45,23 +45,23 @@ function ConsumableManagement() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [keyword, category, status]);
|
||||||
|
|
||||||
const fetchCategories = async () => {
|
const fetchCategories = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/consumable-categories/list');
|
const response = await axios.get('/api/consumable-categories/list');
|
||||||
setCategories(response.data);
|
setCategories(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取分类列表失败:', error);
|
console.error('获取分类列表失败:', error);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConsumables();
|
fetchConsumables();
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
}, [keyword, category, status]);
|
}, [fetchConsumables, fetchCategories]);
|
||||||
|
|
||||||
const showModal = (consumable = null) => {
|
const showModal = useCallback((consumable = null) => {
|
||||||
setEditingConsumable(consumable);
|
setEditingConsumable(consumable);
|
||||||
if (consumable) {
|
if (consumable) {
|
||||||
form.setFieldsValue(consumable);
|
form.setFieldsValue(consumable);
|
||||||
@@ -69,14 +69,14 @@ function ConsumableManagement() {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
}
|
}
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
};
|
}, [form]);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = useCallback(() => {
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
setEditingConsumable(null);
|
setEditingConsumable(null);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (values) => {
|
const handleSubmit = useCallback(async (values) => {
|
||||||
try {
|
try {
|
||||||
if (editingConsumable) {
|
if (editingConsumable) {
|
||||||
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, values);
|
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, values);
|
||||||
@@ -95,9 +95,9 @@ function ConsumableManagement() {
|
|||||||
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
|
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
|
||||||
console.error('提交失败:', error);
|
console.error('提交失败:', error);
|
||||||
}
|
}
|
||||||
};
|
}, [editingConsumable, fetchConsumables]);
|
||||||
|
|
||||||
const handleDelete = async (consumableId) => {
|
const handleDelete = useCallback(async (consumableId) => {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/consumables/${consumableId}`);
|
await axios.delete(`/api/consumables/${consumableId}`);
|
||||||
message.success('删除成功');
|
message.success('删除成功');
|
||||||
@@ -106,11 +106,11 @@ function ConsumableManagement() {
|
|||||||
message.error('删除失败');
|
message.error('删除失败');
|
||||||
console.error('删除失败:', error);
|
console.error('删除失败:', error);
|
||||||
}
|
}
|
||||||
};
|
}, [fetchConsumables]);
|
||||||
|
|
||||||
const handleSearch = (value) => {
|
const handleSearch = useCallback((value) => {
|
||||||
setKeyword(value);
|
setKeyword(value);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const exportToCSV = (data, filename) => {
|
const exportToCSV = (data, filename) => {
|
||||||
const headers = ['耗材ID', '名称', '分类', '单位', '当前库存', '最小库存', '最大库存', '单价', '供应商', '存放位置', '状态'];
|
const headers = ['耗材ID', '名称', '分类', '单位', '当前库存', '最小库存', '最大库存', '单价', '供应商', '存放位置', '状态'];
|
||||||
@@ -267,7 +267,7 @@ function ConsumableManagement() {
|
|||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showStockModal = (record, type) => {
|
const showStockModal = useCallback((record, type) => {
|
||||||
setStockRecord(record);
|
setStockRecord(record);
|
||||||
setStockType(type);
|
setStockType(type);
|
||||||
stockForm.setFieldsValue({
|
stockForm.setFieldsValue({
|
||||||
@@ -278,14 +278,14 @@ function ConsumableManagement() {
|
|||||||
notes: ''
|
notes: ''
|
||||||
});
|
});
|
||||||
setStockModalVisible(true);
|
setStockModalVisible(true);
|
||||||
};
|
}, [stockForm]);
|
||||||
|
|
||||||
const handleStockCancel = () => {
|
const handleStockCancel = useCallback(() => {
|
||||||
setStockModalVisible(false);
|
setStockModalVisible(false);
|
||||||
setStockRecord(null);
|
setStockRecord(null);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleStockSubmit = async (values) => {
|
const handleStockSubmit = useCallback(async (values) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/consumables/quick-inout', {
|
const response = await axios.post('/api/consumables/quick-inout', {
|
||||||
consumableId: stockRecord.consumableId,
|
consumableId: stockRecord.consumableId,
|
||||||
@@ -302,9 +302,9 @@ function ConsumableManagement() {
|
|||||||
message.error(error.response?.data?.error || `${stockType === 'in' ? '入库' : '出库'}操作失败`);
|
message.error(error.response?.data?.error || `${stockType === 'in' ? '入库' : '出库'}操作失败`);
|
||||||
console.error('操作失败:', error);
|
console.error('操作失败:', error);
|
||||||
}
|
}
|
||||||
};
|
}, [stockRecord, stockType, fetchConsumables]);
|
||||||
|
|
||||||
const columns = [
|
const columns = useMemo(() => [
|
||||||
{
|
{
|
||||||
title: '耗材ID',
|
title: '耗材ID',
|
||||||
dataIndex: 'consumableId',
|
dataIndex: 'consumableId',
|
||||||
@@ -402,7 +402,7 @@ function ConsumableManagement() {
|
|||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
];
|
], [showModal, showStockModal, handleDelete]);
|
||||||
|
|
||||||
const previewColumns = [
|
const previewColumns = [
|
||||||
{ title: '名称', dataIndex: '名称', key: 'name', width: 120 },
|
{ title: '名称', dataIndex: '名称', key: 'name', width: 120 },
|
||||||
@@ -608,4 +608,4 @@ function ConsumableManagement() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ConsumableManagement;
|
export default React.memo(ConsumableManagement);
|
||||||
|
|||||||
+335
-424
@@ -1,12 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Card, Row, Col, Statistic, Spin, message, Button, Tag } from 'antd';
|
import { Card, Row, Col, Statistic, message, Button, Tag } from 'antd';
|
||||||
import {
|
import {
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
PoweroffOutlined,
|
PoweroffOutlined,
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
MonitorOutlined,
|
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
ArrowUpOutlined,
|
ArrowUpOutlined,
|
||||||
ArrowDownOutlined,
|
ArrowDownOutlined,
|
||||||
@@ -15,197 +14,351 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
primary: '#1890ff',
|
||||||
|
primaryDark: '#096dd9',
|
||||||
|
secondary: '#722ed1',
|
||||||
|
success: '#52c41a',
|
||||||
|
warning: '#faad14',
|
||||||
|
error: '#ff4d4f',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
cardBg: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
textPrimary: '#262626',
|
||||||
|
textSecondary: '#8c8c8c'
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerStyle = {
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
|
||||||
|
padding: '24px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerStyle = {
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: '32px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleStyle = {
|
||||||
|
fontSize: '2.5rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
background: theme.background,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
margin: '0 0 8px 0'
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitleStyle = {
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
color: theme.textSecondary,
|
||||||
|
margin: '0'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statCardStyle = {
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 4px 16px rgba(24, 144, 255, 0.1)',
|
||||||
|
background: theme.cardBg,
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'pointer'
|
||||||
|
};
|
||||||
|
|
||||||
|
const systemOverviewStyle = {
|
||||||
|
marginTop: '32px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const overviewCardStyle = {
|
||||||
|
borderRadius: '20px',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 8px 32px rgba(24, 144, 255, 0.15)',
|
||||||
|
background: theme.cardBg,
|
||||||
|
backdropFilter: 'blur(10px)'
|
||||||
|
};
|
||||||
|
|
||||||
|
const welcomeSectionStyle = {
|
||||||
|
background: theme.background,
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'white',
|
||||||
|
marginBottom: '24px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigationGridStyle = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '24px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const navButtonStyle = {
|
||||||
|
height: 'auto',
|
||||||
|
padding: '20px 16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '2px solid rgba(24, 144, 255, 0.1)',
|
||||||
|
background: 'rgba(24, 144, 255, 0.02)',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const navIconStyle = {
|
||||||
|
fontSize: '2rem',
|
||||||
|
color: theme.primary
|
||||||
|
};
|
||||||
|
|
||||||
|
const navTextStyle = {
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: theme.textPrimary
|
||||||
|
};
|
||||||
|
|
||||||
|
const systemInfoStyle = {
|
||||||
|
background: 'linear-gradient(135deg, #e8f4fd 0%, #f0f9ff 100%)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
border: '1px solid rgba(24, 144, 255, 0.1)'
|
||||||
|
};
|
||||||
|
|
||||||
|
const navButtonsData = [
|
||||||
|
{ key: 'devices', icon: CloudServerOutlined, text: '设备管理', path: '/devices' },
|
||||||
|
{ key: 'racks', icon: DatabaseOutlined, text: '资源规划', path: '/racks' },
|
||||||
|
{ key: 'faults', icon: WarningOutlined, text: '故障监控', path: '/faults' },
|
||||||
|
{ key: 'settings', icon: SettingOutlined, text: '系统配置', path: '/settings' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const createTrendStyle = (trend) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: trend > 0 ? theme.success : theme.error,
|
||||||
|
marginTop: '8px'
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStatCardBg = (color) => ({
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0',
|
||||||
|
right: '0',
|
||||||
|
width: '60px',
|
||||||
|
height: '60px',
|
||||||
|
background: `linear-gradient(135deg, ${color} 0%, ${color}99 100%)`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
opacity: '0.1'
|
||||||
|
});
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
totalDevices: 0,
|
totalDevices: 0,
|
||||||
totalRacks: 0,
|
totalRacks: 0,
|
||||||
totalRooms: 0,
|
totalRooms: 0,
|
||||||
faultDevices: 0,
|
faultDevices: 0,
|
||||||
deviceGrowth: 2.5, // 示例增长数据
|
deviceGrowth: 2.5,
|
||||||
faultTrend: -12.3 // 示例趋势数据
|
faultTrend: -12.3
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchStats = useCallback(async () => {
|
||||||
// 获取统计数据
|
try {
|
||||||
const fetchStats = async () => {
|
setLoading(true);
|
||||||
try {
|
|
||||||
setLoading(true);
|
const [devicesRes, racksRes, roomsRes] = await Promise.all([
|
||||||
|
axios.get('/api/devices', { params: { pageSize: 1 } }),
|
||||||
// 获取所有设备
|
axios.get('/api/racks', { params: { pageSize: 1 } }),
|
||||||
const devicesRes = await axios.get('/api/devices');
|
axios.get('/api/rooms')
|
||||||
// 设备API返回的是包含total和devices数组的对象
|
]);
|
||||||
const totalDevices = devicesRes.data.total;
|
|
||||||
// 获取所有设备以统计故障设备数量
|
const totalDevices = devicesRes.data.total || 0;
|
||||||
const allDevicesRes = await axios.get('/api/devices', { params: { pageSize: totalDevices } });
|
const totalRacks = racksRes.data.total || 0;
|
||||||
const allDevices = allDevicesRes.data.devices || allDevicesRes.data;
|
const rooms = roomsRes.data || [];
|
||||||
const faultDevices = allDevices.filter(device => device.status === 'fault').length;
|
const totalRooms = rooms.length;
|
||||||
|
|
||||||
// 获取所有机柜
|
let faultDevices = 0;
|
||||||
const racksRes = await axios.get('/api/racks');
|
if (totalDevices > 0) {
|
||||||
// 机柜API返回的是包含total和racks数组的对象
|
try {
|
||||||
const totalRacks = racksRes.data.total;
|
const faultRes = await axios.get('/api/devices/count', {
|
||||||
const racks = racksRes.data.racks || [];
|
params: { status: 'fault' }
|
||||||
|
});
|
||||||
// 获取所有机房
|
faultDevices = faultRes.data.count || 0;
|
||||||
const roomsRes = await axios.get('/api/rooms');
|
} catch {
|
||||||
const rooms = roomsRes.data;
|
faultDevices = 0;
|
||||||
const totalRooms = rooms.length;
|
}
|
||||||
|
|
||||||
setStats({
|
|
||||||
totalDevices,
|
|
||||||
totalRacks,
|
|
||||||
totalRooms,
|
|
||||||
faultDevices,
|
|
||||||
// 保留或更新趋势数据
|
|
||||||
deviceGrowth: stats.deviceGrowth || 0,
|
|
||||||
faultTrend: stats.faultTrend || 0
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
message.error('获取统计数据失败');
|
|
||||||
console.error('获取统计数据失败:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
setStats({
|
||||||
fetchStats();
|
totalDevices,
|
||||||
|
totalRacks,
|
||||||
|
totalRooms,
|
||||||
|
faultDevices,
|
||||||
|
deviceGrowth: 2.5,
|
||||||
|
faultTrend: -12.3
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取统计数据失败');
|
||||||
|
console.error('获取统计数据失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 科技感配色主题
|
useEffect(() => {
|
||||||
const theme = {
|
fetchStats();
|
||||||
primary: '#1890ff', // 科技蓝
|
}, [fetchStats]);
|
||||||
primaryDark: '#096dd9', // 深蓝
|
|
||||||
secondary: '#722ed1', // 紫色
|
|
||||||
success: '#52c41a', // 绿色
|
|
||||||
warning: '#faad14', // 橙色
|
|
||||||
error: '#ff4d4f', // 红色
|
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
||||||
cardBg: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
textPrimary: '#262626',
|
|
||||||
textSecondary: '#8c8c8c'
|
|
||||||
};
|
|
||||||
|
|
||||||
const containerStyle = {
|
const handleNavHover = useCallback((e, isEnter) => {
|
||||||
minHeight: '100vh',
|
if (isEnter) {
|
||||||
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
|
e.currentTarget.style.borderColor = theme.primary;
|
||||||
padding: '24px'
|
e.currentTarget.style.background = 'rgba(24, 144, 255, 0.1)';
|
||||||
};
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
} else {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(24, 144, 255, 0.1)';
|
||||||
|
e.currentTarget.style.background = 'rgba(24, 144, 255, 0.02)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const headerStyle = {
|
const handleRefresh = useCallback(() => {
|
||||||
textAlign: 'center',
|
fetchStats();
|
||||||
marginBottom: '32px'
|
}, [fetchStats]);
|
||||||
};
|
|
||||||
|
|
||||||
const titleStyle = {
|
const statCards = useMemo(() => [
|
||||||
fontSize: '2.5rem',
|
{
|
||||||
fontWeight: '700',
|
key: 'devices',
|
||||||
background: theme.background,
|
xs: 24, sm: 12, lg: 6,
|
||||||
WebkitBackgroundClip: 'text',
|
icon: CloudServerOutlined,
|
||||||
WebkitTextFillColor: 'transparent',
|
color: '#1890ff',
|
||||||
margin: '0 0 8px 0'
|
statKey: 'totalDevices',
|
||||||
};
|
title: '总设备数',
|
||||||
|
trend: stats.deviceGrowth,
|
||||||
|
tagColor: 'blue'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'racks',
|
||||||
|
xs: 24, sm: 12, lg: 6,
|
||||||
|
icon: DatabaseOutlined,
|
||||||
|
color: '#722ed1',
|
||||||
|
statKey: 'totalRacks',
|
||||||
|
title: '总机柜数',
|
||||||
|
trend: 0,
|
||||||
|
tagColor: 'green',
|
||||||
|
customStatus: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rooms',
|
||||||
|
xs: 24, sm: 12, lg: 6,
|
||||||
|
icon: HomeOutlined,
|
||||||
|
color: '#52c41a',
|
||||||
|
statKey: 'totalRooms',
|
||||||
|
title: '总机房数',
|
||||||
|
trend: 0,
|
||||||
|
tagColor: 'green',
|
||||||
|
customStatus: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'faults',
|
||||||
|
xs: 24, sm: 12, lg: 6,
|
||||||
|
icon: WarningOutlined,
|
||||||
|
color: '#ff4d4f',
|
||||||
|
statKey: 'faultDevices',
|
||||||
|
title: '故障设备',
|
||||||
|
trend: stats.faultTrend,
|
||||||
|
tagColor: 'red'
|
||||||
|
}
|
||||||
|
], [stats.deviceGrowth, stats.faultTrend]);
|
||||||
|
|
||||||
const subtitleStyle = {
|
const renderStatCard = useCallback((config) => {
|
||||||
fontSize: '1.1rem',
|
const { icon: Icon, color, statKey, title, trend, tagColor, customStatus, xs, sm, lg } = config;
|
||||||
color: theme.textSecondary,
|
const colProps = { xs, sm, lg };
|
||||||
margin: '0'
|
|
||||||
};
|
return (
|
||||||
|
<Col key={statKey} {...colProps}>
|
||||||
|
<Card style={statCardStyle}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div style={createStatCardBg(color)} />
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span style={{ fontSize: '0.95rem', fontWeight: '600', color: theme.textSecondary }}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={stats[statKey]}
|
||||||
|
prefix={<Icon style={{ color, fontSize: '1.2rem' }} />}
|
||||||
|
valueStyle={{
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: theme.textPrimary,
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
{customStatus ? (
|
||||||
|
statKey === 'totalRacks' ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', fontSize: '0.875rem', color: theme.success }}>
|
||||||
|
<PoweroffOutlined style={{ marginRight: '4px' }} />
|
||||||
|
<span>正常运行</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', fontSize: '0.875rem', color: theme.success }}>
|
||||||
|
<span style={{ width: '8px', height: '8px', background: theme.success, borderRadius: '50%', marginRight: '8px' }} />
|
||||||
|
<span>全部在线</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div style={createTrendStyle(trend)}>
|
||||||
|
{trend > 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||||
|
<span style={{ marginLeft: '4px' }}>{Math.abs(trend)}%</span>
|
||||||
|
<Tag color={tagColor} style={{ marginLeft: '8px', fontSize: '0.75rem' }}>本月</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}, [stats, loading]);
|
||||||
|
|
||||||
const statCardStyle = {
|
const navButtons = useMemo(() => navButtonsData.map(({ key, icon: Icon, text }) => (
|
||||||
borderRadius: '16px',
|
<Button
|
||||||
border: 'none',
|
key={key}
|
||||||
boxShadow: '0 4px 16px rgba(24, 144, 255, 0.1)',
|
type="text"
|
||||||
background: theme.cardBg,
|
style={navButtonStyle}
|
||||||
backdropFilter: 'blur(10px)',
|
onMouseEnter={(e) => handleNavHover(e, true)}
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
onMouseLeave={(e) => handleNavHover(e, false)}
|
||||||
position: 'relative',
|
>
|
||||||
overflow: 'hidden',
|
<Icon style={navIconStyle} />
|
||||||
cursor: 'pointer'
|
<span style={navTextStyle}>{text}</span>
|
||||||
};
|
</Button>
|
||||||
|
)), [handleNavHover]);
|
||||||
|
|
||||||
const statCardHoverStyle = {
|
const systemInfo = useMemo(() => (
|
||||||
transform: 'translateY(-4px)',
|
<div style={systemInfoStyle}>
|
||||||
boxShadow: '0 8px 32px rgba(24, 144, 255, 0.2)'
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
};
|
<div>
|
||||||
|
<p style={{ margin: '0', fontSize: '0.95rem', color: theme.textPrimary, fontWeight: '500' }}>
|
||||||
const trendStyle = (trend) => ({
|
<strong>系统版本:</strong> v1.0.0
|
||||||
display: 'flex',
|
</p>
|
||||||
alignItems: 'center',
|
<p style={{ margin: '4px 0 0 0', fontSize: '0.85rem', color: theme.textSecondary }}>
|
||||||
fontSize: '0.875rem',
|
<strong>最后更新:</strong>{new Date().toLocaleDateString()}
|
||||||
fontWeight: '500',
|
</p>
|
||||||
color: trend > 0 ? theme.success : theme.error,
|
</div>
|
||||||
marginTop: '8px'
|
<Button
|
||||||
});
|
type="primary"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
const systemOverviewStyle = {
|
size="small"
|
||||||
marginTop: '32px'
|
onClick={handleRefresh}
|
||||||
};
|
style={{ background: theme.primary, borderColor: theme.primary }}
|
||||||
|
>
|
||||||
const overviewCardStyle = {
|
刷新数据
|
||||||
borderRadius: '20px',
|
</Button>
|
||||||
border: 'none',
|
</div>
|
||||||
boxShadow: '0 8px 32px rgba(24, 144, 255, 0.15)',
|
</div>
|
||||||
background: theme.cardBg,
|
), [handleRefresh]);
|
||||||
backdropFilter: 'blur(10px)'
|
|
||||||
};
|
|
||||||
|
|
||||||
const welcomeSectionStyle = {
|
|
||||||
background: theme.background,
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '20px',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: 'white',
|
|
||||||
marginBottom: '24px'
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigationGridStyle = {
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
|
||||||
gap: '16px',
|
|
||||||
marginBottom: '24px'
|
|
||||||
};
|
|
||||||
|
|
||||||
const navButtonStyle = {
|
|
||||||
height: 'auto',
|
|
||||||
padding: '20px 16px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
border: '2px solid rgba(24, 144, 255, 0.1)',
|
|
||||||
background: 'rgba(24, 144, 255, 0.02)',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px'
|
|
||||||
};
|
|
||||||
|
|
||||||
const navButtonHoverStyle = {
|
|
||||||
borderColor: theme.primary,
|
|
||||||
background: 'rgba(24, 144, 255, 0.1)',
|
|
||||||
transform: 'translateY(-2px)'
|
|
||||||
};
|
|
||||||
|
|
||||||
const navIconStyle = {
|
|
||||||
fontSize: '2rem',
|
|
||||||
color: theme.primary
|
|
||||||
};
|
|
||||||
|
|
||||||
const navTextStyle = {
|
|
||||||
fontSize: '0.95rem',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: theme.textPrimary
|
|
||||||
};
|
|
||||||
|
|
||||||
const systemInfoStyle = {
|
|
||||||
background: 'linear-gradient(135deg, #e8f4fd 0%, #f0f9ff 100%)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '16px',
|
|
||||||
border: '1px solid rgba(24, 144, 255, 0.1)'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div style={containerStyle}>
|
||||||
{/* 页面头部 */}
|
|
||||||
<div style={headerStyle}>
|
<div style={headerStyle}>
|
||||||
<h1 style={titleStyle}>
|
<h1 style={titleStyle}>
|
||||||
<DashboardOutlined style={{ marginRight: '12px' }} />
|
<DashboardOutlined style={{ marginRight: '12px' }} />
|
||||||
@@ -214,164 +367,13 @@ function Dashboard() {
|
|||||||
<p style={subtitleStyle}>实时监控 • 智能管理 • 高效运维</p>
|
<p style={subtitleStyle}>实时监控 • 智能管理 • 高效运维</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 数据概览卡片 */}
|
|
||||||
<Row gutter={[24, 24]} style={{ marginBottom: '32px' }}>
|
<Row gutter={[24, 24]} style={{ marginBottom: '32px' }}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
{statCards.map(renderStatCard)}
|
||||||
<Card style={statCardStyle}>
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '0',
|
|
||||||
right: '0',
|
|
||||||
width: '60px',
|
|
||||||
height: '60px',
|
|
||||||
background: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)',
|
|
||||||
borderRadius: '50%',
|
|
||||||
opacity: '0.1'
|
|
||||||
}} />
|
|
||||||
<Statistic
|
|
||||||
title={
|
|
||||||
<span style={{ fontSize: '0.95rem', fontWeight: '600', color: theme.textSecondary }}>
|
|
||||||
总设备数
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
value={stats.totalDevices}
|
|
||||||
prefix={<CloudServerOutlined style={{ color: theme.primary, fontSize: '1.2rem' }} />}
|
|
||||||
valueStyle={{
|
|
||||||
fontSize: '2rem',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: theme.textPrimary,
|
|
||||||
marginBottom: '8px'
|
|
||||||
}}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<div style={trendStyle(stats.deviceGrowth)}>
|
|
||||||
{stats.deviceGrowth > 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
|
||||||
<span style={{ marginLeft: '4px' }}>{Math.abs(stats.deviceGrowth)}%</span>
|
|
||||||
<Tag color="blue" style={{ marginLeft: '8px', fontSize: '0.75rem' }}>本月</Tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} sm={12} lg={6}>
|
|
||||||
<Card style={statCardStyle}>
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '0',
|
|
||||||
right: '0',
|
|
||||||
width: '60px',
|
|
||||||
height: '60px',
|
|
||||||
background: 'linear-gradient(135deg, #722ed1 0%, #531dab 100%)',
|
|
||||||
borderRadius: '50%',
|
|
||||||
opacity: '0.1'
|
|
||||||
}} />
|
|
||||||
<Statistic
|
|
||||||
title={
|
|
||||||
<span style={{ fontSize: '0.95rem', fontWeight: '600', color: theme.textSecondary }}>
|
|
||||||
总机柜数
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
value={stats.totalRacks}
|
|
||||||
prefix={<DatabaseOutlined style={{ color: theme.secondary, fontSize: '1.2rem' }} />}
|
|
||||||
valueStyle={{
|
|
||||||
fontSize: '2rem',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: theme.textPrimary,
|
|
||||||
marginBottom: '8px'
|
|
||||||
}}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', fontSize: '0.875rem', color: theme.success }}>
|
|
||||||
<PoweroffOutlined style={{ marginRight: '4px' }} />
|
|
||||||
<span>正常运行</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} sm={12} lg={6}>
|
|
||||||
<Card style={statCardStyle}>
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '0',
|
|
||||||
right: '0',
|
|
||||||
width: '60px',
|
|
||||||
height: '60px',
|
|
||||||
background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
|
|
||||||
borderRadius: '50%',
|
|
||||||
opacity: '0.1'
|
|
||||||
}} />
|
|
||||||
<Statistic
|
|
||||||
title={
|
|
||||||
<span style={{ fontSize: '0.95rem', fontWeight: '600', color: theme.textSecondary }}>
|
|
||||||
总机房数
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
value={stats.totalRooms}
|
|
||||||
prefix={<HomeOutlined style={{ color: theme.success, fontSize: '1.2rem' }} />}
|
|
||||||
valueStyle={{
|
|
||||||
fontSize: '2rem',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: theme.textPrimary,
|
|
||||||
marginBottom: '8px'
|
|
||||||
}}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', fontSize: '0.875rem', color: theme.success }}>
|
|
||||||
<span style={{ width: '8px', height: '8px', background: theme.success, borderRadius: '50%', marginRight: '8px' }} />
|
|
||||||
<span>全部在线</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} sm={12} lg={6}>
|
|
||||||
<Card style={{ ...statCardStyle, borderLeft: `4px solid ${theme.error}` }}>
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '0',
|
|
||||||
right: '0',
|
|
||||||
width: '60px',
|
|
||||||
height: '60px',
|
|
||||||
background: 'linear-gradient(135deg, #ff4d4f 0%, #d73027 100%)',
|
|
||||||
borderRadius: '50%',
|
|
||||||
opacity: '0.1'
|
|
||||||
}} />
|
|
||||||
<Statistic
|
|
||||||
title={
|
|
||||||
<span style={{ fontSize: '0.95rem', fontWeight: '600', color: theme.textSecondary }}>
|
|
||||||
故障设备
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
value={stats.faultDevices}
|
|
||||||
prefix={<WarningOutlined style={{ color: theme.error, fontSize: '1.2rem' }} />}
|
|
||||||
valueStyle={{
|
|
||||||
fontSize: '2rem',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: theme.error,
|
|
||||||
marginBottom: '8px'
|
|
||||||
}}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<div style={trendStyle(stats.faultTrend)}>
|
|
||||||
{stats.faultTrend < 0 ? <ArrowDownOutlined /> : <ArrowUpOutlined />}
|
|
||||||
<span style={{ marginLeft: '4px' }}>{Math.abs(stats.faultTrend)}%</span>
|
|
||||||
<Tag color="red" style={{ marginLeft: '8px', fontSize: '0.75rem' }}>本周</Tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* 系统概览 */}
|
|
||||||
<div style={systemOverviewStyle}>
|
<div style={systemOverviewStyle}>
|
||||||
<Card style={overviewCardStyle}>
|
<Card style={overviewCardStyle}>
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
{/* 欢迎区域 */}
|
|
||||||
<div style={welcomeSectionStyle}>
|
<div style={welcomeSectionStyle}>
|
||||||
<h2 style={{
|
<h2 style={{
|
||||||
fontSize: '1.5rem',
|
fontSize: '1.5rem',
|
||||||
@@ -391,102 +393,11 @@ function Dashboard() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 导航功能网格 */}
|
|
||||||
<div style={navigationGridStyle}>
|
<div style={navigationGridStyle}>
|
||||||
<Button
|
{navButtons}
|
||||||
type="text"
|
|
||||||
style={navButtonStyle}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = theme.primary;
|
|
||||||
e.currentTarget.style.background = 'rgba(24, 144, 255, 0.1)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'rgba(24, 144, 255, 0.1)';
|
|
||||||
e.currentTarget.style.background = 'rgba(24, 144, 255, 0.02)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloudServerOutlined style={navIconStyle} />
|
|
||||||
<span style={navTextStyle}>设备管理</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
style={navButtonStyle}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = theme.primary;
|
|
||||||
e.currentTarget.style.background = 'rgba(24, 144, 255, 0.1)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'rgba(24, 144, 255, 0.1)';
|
|
||||||
e.currentTarget.style.background = 'rgba(24, 144, 255, 0.02)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DatabaseOutlined style={navIconStyle} />
|
|
||||||
<span style={navTextStyle}>资源规划</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
style={navButtonStyle}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = theme.primary;
|
|
||||||
e.currentTarget.style.background = 'rgba(24, 144, 255, 0.1)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'rgba(24, 144, 255, 0.1)';
|
|
||||||
e.currentTarget.style.background = 'rgba(24, 144, 255, 0.02)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WarningOutlined style={navIconStyle} />
|
|
||||||
<span style={navTextStyle}>故障监控</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
style={navButtonStyle}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = theme.primary;
|
|
||||||
e.currentTarget.style.background = 'rgba(24, 144, 255, 0.1)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'rgba(24, 144, 255, 0.1)';
|
|
||||||
e.currentTarget.style.background = 'rgba(24, 144, 255, 0.02)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SettingOutlined style={navIconStyle} />
|
|
||||||
<span style={navTextStyle}>系统配置</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 系统信息 */}
|
{systemInfo}
|
||||||
<div style={systemInfoStyle}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div>
|
|
||||||
<p style={{ margin: '0', fontSize: '0.95rem', color: theme.textPrimary, fontWeight: '500' }}>
|
|
||||||
<strong>系统版本:</strong> v1.0.0
|
|
||||||
</p>
|
|
||||||
<p style={{ margin: '4px 0 0 0', fontSize: '0.85rem', color: theme.textSecondary }}>
|
|
||||||
最后更新:{new Date().toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
size="small"
|
|
||||||
style={{ background: theme.primary, borderColor: theme.primary }}
|
|
||||||
>
|
|
||||||
刷新数据
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -494,4 +405,4 @@ function Dashboard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Dashboard;
|
export default React.memo(Dashboard);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Table, Button, Modal, Form, Input, Select, DatePicker, message, Card, Space, InputNumber, Switch, Upload, Progress, Checkbox } from 'antd';
|
import { Table, Button, Modal, Form, Input, Select, DatePicker, message, Card, Space, InputNumber, Switch, Upload, Progress, Checkbox } from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, UploadOutlined, DownloadOutlined, SettingOutlined, UndoOutlined, CloudServerOutlined, SwapOutlined, SafetyOutlined, DatabaseOutlined, AppstoreOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, UploadOutlined, DownloadOutlined, SettingOutlined, UndoOutlined, CloudServerOutlined, SwapOutlined, SafetyOutlined, DatabaseOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -7,6 +7,79 @@ import dayjs from 'dayjs';
|
|||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
// 工具函数提取到组件外部,避免每次渲染重复创建
|
||||||
|
const getStatusConfig = (status) => {
|
||||||
|
const statusMap = {
|
||||||
|
running: { text: '运行中', color: 'green' },
|
||||||
|
maintenance: { text: '维护中', color: 'orange' },
|
||||||
|
offline: { text: '离线', color: 'gray' },
|
||||||
|
fault: { text: '故障', color: 'red' }
|
||||||
|
};
|
||||||
|
return statusMap[status] || { text: status, color: 'black' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeLabel = (type) => {
|
||||||
|
const typeMap = {
|
||||||
|
server: '服务器',
|
||||||
|
switch: '交换机',
|
||||||
|
router: '路由器',
|
||||||
|
storage: '存储设备',
|
||||||
|
other: '其他设备'
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDeviceTypeIcon = (type) => {
|
||||||
|
const iconMap = {
|
||||||
|
server: <CloudServerOutlined style={{ color: '#1890ff' }} />,
|
||||||
|
switch: <SwapOutlined style={{ color: '#52c41a' }} />,
|
||||||
|
router: <SafetyOutlined style={{ color: '#faad14' }} />,
|
||||||
|
storage: <DatabaseOutlined style={{ color: '#722ed1' }} />,
|
||||||
|
other: <AppstoreOutlined style={{ color: '#8c8c8c' }} />
|
||||||
|
};
|
||||||
|
return iconMap[type] || <AppstoreOutlined style={{ color: '#8c8c8c' }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (date, fieldName) => {
|
||||||
|
if (!date) return '';
|
||||||
|
|
||||||
|
const dateObj = new Date(date);
|
||||||
|
const formattedDate = dateObj.toLocaleDateString('zh-CN');
|
||||||
|
|
||||||
|
if (fieldName === 'warrantyExpiry') {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
dateObj.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (dateObj < today) {
|
||||||
|
return <span style={{ color: '#d93025', fontWeight: 'bold' }}>{formattedDate}</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 默认设备字段配置
|
||||||
|
const defaultDeviceFields = [
|
||||||
|
{ fieldName: 'deviceId', displayName: '设备ID', fieldType: 'string', required: true, order: 1, visible: true },
|
||||||
|
{ fieldName: 'name', displayName: '设备名称', fieldType: 'string', required: true, order: 2, visible: true },
|
||||||
|
{ fieldName: 'type', displayName: '设备类型', fieldType: 'select', required: true, order: 3, visible: true,
|
||||||
|
options: [{ value: 'server', label: '服务器' }, { value: 'switch', label: '交换机' }, { value: 'router', label: '路由器' }, { value: 'storage', label: '存储设备' }, { value: 'other', label: '其他设备' }] },
|
||||||
|
{ fieldName: 'model', displayName: '型号', fieldType: 'string', required: true, order: 4, visible: true },
|
||||||
|
{ fieldName: 'serialNumber', displayName: '序列号', fieldType: 'string', required: true, order: 5, visible: true },
|
||||||
|
{ fieldName: 'rackId', displayName: '所在机柜', fieldType: 'select', required: true, order: 6, visible: true },
|
||||||
|
{ fieldName: 'position', displayName: '位置(U)', fieldType: 'number', required: true, order: 7, visible: true },
|
||||||
|
{ fieldName: 'height', displayName: '高度(U)', fieldType: 'number', required: true, order: 8, visible: true },
|
||||||
|
{ fieldName: 'powerConsumption', displayName: '功率(W)', fieldType: 'number', required: true, order: 9, visible: true },
|
||||||
|
{ fieldName: 'status', displayName: '状态', fieldType: 'select', required: true, order: 10, visible: true,
|
||||||
|
options: [{ value: 'running', label: '运行中' }, { value: 'maintenance', label: '维护中' }, { value: 'offline', label: '离线' }, { value: 'fault', label: '故障' }] },
|
||||||
|
{ fieldName: 'purchaseDate', displayName: '购买日期', fieldType: 'date', required: true, order: 11, visible: true },
|
||||||
|
{ fieldName: 'warrantyExpiry', displayName: '保修到期', fieldType: 'date', required: true, order: 12, visible: true },
|
||||||
|
{ fieldName: 'ipAddress', displayName: 'IP地址', fieldType: 'string', required: false, order: 13, visible: true },
|
||||||
|
{ fieldName: 'description', displayName: '描述', fieldType: 'textarea', required: false, order: 14, visible: true }
|
||||||
|
];
|
||||||
|
|
||||||
// 可调整列宽的表头组件
|
// 可调整列宽的表头组件
|
||||||
const ResizeableTitle = (props) => {
|
const ResizeableTitle = (props) => {
|
||||||
const { onResize, width, ...restProps } = props;
|
const { onResize, width, ...restProps } = props;
|
||||||
@@ -18,13 +91,12 @@ const ResizeableTitle = (props) => {
|
|||||||
const handleMouseDown = (e) => {
|
const handleMouseDown = (e) => {
|
||||||
if (!onResize) return;
|
if (!onResize) return;
|
||||||
|
|
||||||
|
|
||||||
const startX = e.pageX;
|
const startX = e.pageX;
|
||||||
const startWidth = width;
|
const startWidth = width;
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent) => {
|
const handleMouseMove = (moveEvent) => {
|
||||||
const diff = moveEvent.pageX - startX;
|
const diff = moveEvent.pageX - startX;
|
||||||
const newWidth = Math.max(50, startWidth + diff); // 最小宽度50px
|
const newWidth = Math.max(50, startWidth + diff);
|
||||||
onResize(newWidth);
|
onResize(newWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -855,7 +927,8 @@ function DeviceManagement() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
onChange={handleTableChange}
|
onChange={handleTableChange}
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ y: 600, x: 'max-content' }}
|
||||||
|
virtual
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedDevices,
|
selectedRowKeys: selectedDevices,
|
||||||
onChange: setSelectedDevices,
|
onChange: setSelectedDevices,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, InputNumber, Upload, Progress } from 'antd';
|
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, InputNumber, Upload, Progress } from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -6,6 +6,13 @@ import * as XLSX from 'xlsx';
|
|||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
|
// 状态映射函数
|
||||||
|
const statusMap = {
|
||||||
|
active: { text: '在用', color: 'green' },
|
||||||
|
maintenance: { text: '维护中', color: 'orange' },
|
||||||
|
inactive: { text: '停用', color: 'gray' }
|
||||||
|
};
|
||||||
|
|
||||||
function RackManagement() {
|
function RackManagement() {
|
||||||
const [racks, setRacks] = useState([]);
|
const [racks, setRacks] = useState([]);
|
||||||
const [rooms, setRooms] = useState([]);
|
const [rooms, setRooms] = useState([]);
|
||||||
@@ -28,10 +35,7 @@ function RackManagement() {
|
|||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const [importResult, setImportResult] = useState(null);
|
const [importResult, setImportResult] = useState(null);
|
||||||
|
|
||||||
|
const fetchRacks = useCallback(async (page = 1, pageSize = 10) => {
|
||||||
|
|
||||||
// 获取所有机柜
|
|
||||||
const fetchRacks = async (page = 1, pageSize = 10) => {
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await axios.get('/api/racks', {
|
const response = await axios.get('/api/racks', {
|
||||||
@@ -51,10 +55,9 @@ function RackManagement() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// 获取所有机房
|
const fetchRooms = useCallback(async () => {
|
||||||
const fetchRooms = async () => {
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/rooms');
|
const response = await axios.get('/api/rooms');
|
||||||
setRooms(response.data);
|
setRooms(response.data);
|
||||||
@@ -62,20 +65,19 @@ function RackManagement() {
|
|||||||
message.error('获取机房列表失败');
|
message.error('获取机房列表失败');
|
||||||
console.error('获取机房列表失败:', error);
|
console.error('获取机房列表失败:', error);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// 处理表格分页变化
|
const handleTableChange = useCallback((pagination) => {
|
||||||
const handleTableChange = (pagination) => {
|
|
||||||
fetchRacks(pagination.current, pagination.pageSize);
|
fetchRacks(pagination.current, pagination.pageSize);
|
||||||
};
|
}, [fetchRacks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRacks(pagination.current, pagination.pageSize);
|
fetchRacks(pagination.current, pagination.pageSize);
|
||||||
fetchRooms();
|
fetchRooms();
|
||||||
}, []);
|
}, [fetchRacks, fetchRooms]);
|
||||||
|
|
||||||
// 打开模态框
|
// 打开模态框
|
||||||
const showModal = (rack = null) => {
|
const showModal = useCallback((rack = null) => {
|
||||||
setEditingRack(rack);
|
setEditingRack(rack);
|
||||||
if (rack) {
|
if (rack) {
|
||||||
form.setFieldsValue(rack);
|
form.setFieldsValue(rack);
|
||||||
@@ -83,16 +85,16 @@ function RackManagement() {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
}
|
}
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// 关闭模态框
|
// 关闭模态框
|
||||||
const handleCancel = () => {
|
const handleCancel = useCallback(() => {
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
setEditingRack(null);
|
setEditingRack(null);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// 提交表单
|
// 提交表单
|
||||||
const handleSubmit = async (values) => {
|
const handleSubmit = useCallback(async (values) => {
|
||||||
try {
|
try {
|
||||||
if (editingRack) {
|
if (editingRack) {
|
||||||
// 更新机柜
|
// 更新机柜
|
||||||
@@ -111,10 +113,10 @@ function RackManagement() {
|
|||||||
message.error(editingRack ? '机柜更新失败' : '机柜创建失败');
|
message.error(editingRack ? '机柜更新失败' : '机柜创建失败');
|
||||||
console.error(editingRack ? '机柜更新失败:' : '机柜创建失败:', error);
|
console.error(editingRack ? '机柜更新失败:' : '机柜创建失败:', error);
|
||||||
}
|
}
|
||||||
};
|
}, [editingRack, fetchRacks]);
|
||||||
|
|
||||||
// 删除机柜
|
// 删除机柜
|
||||||
const handleDelete = async (rackId) => {
|
const handleDelete = useCallback(async (rackId) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
content: '确定要删除这个机柜吗?',
|
content: '确定要删除这个机柜吗?',
|
||||||
@@ -132,17 +134,17 @@ function RackManagement() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
}, [fetchRacks]);
|
||||||
|
|
||||||
// 下载导入模板
|
// 下载导入模板
|
||||||
const handleDownloadTemplate = () => {
|
const handleDownloadTemplate = useCallback(() => {
|
||||||
// 调用后端API下载模板
|
// 调用后端API下载模板
|
||||||
window.open('/api/racks/import-template', '_blank');
|
window.open('/api/racks/import-template', '_blank');
|
||||||
message.success('模板下载成功');
|
message.success('模板下载成功');
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// 导入机柜数据
|
// 导入机柜数据
|
||||||
const handleImport = async (file) => {
|
const handleImport = useCallback(async (file) => {
|
||||||
try {
|
try {
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
setImportProgress(0);
|
setImportProgress(0);
|
||||||
@@ -213,17 +215,10 @@ function RackManagement() {
|
|||||||
// 阻止自动上传
|
// 阻止自动上传
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// 状态标签映射
|
|
||||||
const statusMap = {
|
|
||||||
active: { text: '在用', color: 'green' },
|
|
||||||
maintenance: { text: '维护中', color: 'orange' },
|
|
||||||
inactive: { text: '停用', color: 'gray' }
|
|
||||||
};
|
|
||||||
|
|
||||||
// 表格列配置
|
// 表格列配置
|
||||||
const columns = [
|
const columns = useMemo(() => [
|
||||||
{
|
{
|
||||||
title: '机柜ID',
|
title: '机柜ID',
|
||||||
dataIndex: 'rackId',
|
dataIndex: 'rackId',
|
||||||
@@ -493,4 +488,4 @@ function RackManagement() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RackManagement;
|
export default React.memo(RackManagement);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Card, Select, Button, Space, message, Tooltip, Modal, Form, Switch, Checkbox } from 'antd';
|
import { Card, Select, Button, Space, message, Tooltip, Modal, Form, Switch, Checkbox } from 'antd';
|
||||||
import {
|
import {
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
@@ -18,101 +18,226 @@ import axios from 'axios';
|
|||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
// 添加动画样式
|
// 工具函数提取到组件外部,避免每次渲染重复创建
|
||||||
const style = document.createElement('style');
|
const getDeviceIcon = (deviceType) => {
|
||||||
style.textContent = `
|
try {
|
||||||
@keyframes slideIn {
|
if (!deviceType) return <CloudServerOutlined style={{ color: '#ffffff' }} />;
|
||||||
from {
|
const type = deviceType.toLowerCase();
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-50%) translateX(20px) scale(0.95);
|
if (type.includes('server') || type.includes('服务器')) return <CloudServerOutlined style={{ color: '#ffffff' }} />;
|
||||||
|
if (type.includes('switch') || type.includes('交换机')) return <SwitcherOutlined style={{ color: '#ffffff' }} />;
|
||||||
|
if (type.includes('storage') || type.includes('存储')) return <DatabaseOutlined style={{ color: '#ffffff' }} />;
|
||||||
|
if (type.includes('router') || type.includes('路由器')) return <CloudOutlined style={{ color: '#ffffff' }} />;
|
||||||
|
if (type.includes('laptop') || type.includes('笔记本')) return <LaptopOutlined style={{ color: '#ffffff' }} />;
|
||||||
|
if (type.includes('mobile') || type.includes('手机')) return <MobileOutlined style={{ color: '#ffffff' }} />;
|
||||||
|
if (type.includes('printer') || type.includes('打印机')) return <PrinterOutlined style={{ color: '#ffffff' }} />;
|
||||||
|
|
||||||
|
return <CloudServerOutlined style={{ color: '#ffffff' }} />;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('设备图标渲染错误:', error);
|
||||||
|
return <CloudServerOutlined style={{ color: '#ffffff' }} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
'warning': '#f59e0b',
|
||||||
|
'error': '#ef4444',
|
||||||
|
'offline': '#6b7280',
|
||||||
|
'maintenance': '#3b82f6',
|
||||||
|
undefined: '#3b82f6',
|
||||||
|
null: '#3b82f6'
|
||||||
|
};
|
||||||
|
return statusColorMap[status] || '#3b82f6';
|
||||||
|
};
|
||||||
|
|
||||||
|
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: '其他设备'
|
||||||
}
|
}
|
||||||
to {
|
};
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(-50%) translateX(0) scale(1);
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化动画样式
|
||||||
|
const initAnimationStyles = () => {
|
||||||
|
const existingStyle = document.getElementById('rack-visualization-styles');
|
||||||
|
if (existingStyle) {
|
||||||
|
return existingStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'rack-visualization-styles';
|
||||||
|
style.textContent = `
|
||||||
|
@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 {
|
||||||
/* LED指示灯闪烁动画 */
|
0%, 50% { opacity: 1; }
|
||||||
@keyframes ledBlink {
|
51%, 100% { opacity: 0.3; }
|
||||||
0%, 50% { opacity: 1; }
|
|
||||||
51%, 100% { opacity: 0.3; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip淡入动画 */
|
|
||||||
@keyframes tooltipFadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-50%) translateX(-10px) scale(0.95);
|
|
||||||
}
|
}
|
||||||
to {
|
|
||||||
opacity: 1;
|
@keyframes tooltipFadeIn {
|
||||||
transform: translateY(-50%) translateX(0) scale(1);
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50%) translateX(-10px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-50%) translateX(0) scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.metal-texture {
|
||||||
/* 金属拉丝纹理 */
|
background-image:
|
||||||
.metal-texture {
|
linear-gradient(90deg,
|
||||||
background-image:
|
transparent 0%,
|
||||||
linear-gradient(90deg,
|
rgba(255,255,255,0.03) 50%,
|
||||||
transparent 0%,
|
transparent 100%),
|
||||||
rgba(255,255,255,0.03) 50%,
|
repeating-linear-gradient(0deg,
|
||||||
transparent 100%),
|
transparent 0px,
|
||||||
repeating-linear-gradient(0deg,
|
rgba(255,255,255,0.02) 1px,
|
||||||
transparent 0px,
|
transparent 2px,
|
||||||
rgba(255,255,255,0.02) 1px,
|
transparent 3px);
|
||||||
transparent 2px,
|
background-size: 100% 100%, 4px 4px;
|
||||||
transparent 3px);
|
}
|
||||||
background-size: 100% 100%, 4px 4px;
|
|
||||||
}
|
.ventilation-grille {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
/* 散热格栅效果 */
|
0deg,
|
||||||
.ventilation-grille {
|
#334155 0px,
|
||||||
background-image: repeating-linear-gradient(
|
#334155 1px,
|
||||||
0deg,
|
transparent 1px,
|
||||||
#334155 0px,
|
transparent 2px
|
||||||
#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-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;
|
.device-tooltip {
|
||||||
border-color: #38bdf8 !important;
|
background: rgba(0, 0, 0, 0.9);
|
||||||
}
|
color: #5eead4;
|
||||||
|
padding: 8px 12px;
|
||||||
/* Tooltip样式 */
|
border-radius: 4px;
|
||||||
.device-tooltip {
|
font-family: 'Roboto Mono', monospace;
|
||||||
background: rgba(0, 0, 0, 0.9);
|
font-size: 11px;
|
||||||
color: #5eead4;
|
border: 1px solid rgba(94, 234, 212, 0.3);
|
||||||
padding: 8px 12px;
|
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||||
border-radius: 4px;
|
white-space: nowrap;
|
||||||
font-family: 'Roboto Mono', monospace;
|
}
|
||||||
font-size: 11px;
|
|
||||||
border: 1px solid rgba(94, 234, 212, 0.3);
|
.device-count-badge {
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
background: linear-gradient(135deg, rgba(56, 189, 248, 0.2), rgba(14, 165, 233, 0.2));
|
||||||
white-space: nowrap;
|
color: #38bdf8;
|
||||||
}
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
/* 设备数量badge */
|
font-size: 13px;
|
||||||
.device-count-badge {
|
font-weight: 600;
|
||||||
background: linear-gradient(135deg, rgba(56, 189, 248, 0.2), rgba(14, 165, 233, 0.2));
|
box-shadow: 0 4px 15px rgba(56, 189, 248, 0.2);
|
||||||
color: #38bdf8;
|
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||||
padding: 6px 14px;
|
backdrop-filter: blur(10px);
|
||||||
border-radius: 20px;
|
transition: all 0.3s ease;
|
||||||
font-size: 13px;
|
white-space: nowrap;
|
||||||
font-weight: 600;
|
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||||
box-shadow: 0 4px 15px rgba(56, 189, 248, 0.2);
|
}
|
||||||
border: 1px solid rgba(56, 189, 248, 0.3);
|
`;
|
||||||
backdrop-filter: blur(10px);
|
document.head.appendChild(style);
|
||||||
transition: all 0.3s ease;
|
return style;
|
||||||
white-space: nowrap;
|
};
|
||||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
// 错误边界组件
|
// 错误边界组件
|
||||||
class ErrorBoundary extends React.Component {
|
class ErrorBoundary extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -191,7 +316,7 @@ function RackVisualization() {
|
|||||||
const [tooltipFields, setTooltipFields] = useState({});
|
const [tooltipFields, setTooltipFields] = useState({});
|
||||||
|
|
||||||
// 默认设备字段配置
|
// 默认设备字段配置
|
||||||
const defaultTooltipFields = {
|
const defaultTooltipFields = useMemo(() => ({
|
||||||
name: { label: '设备名称', enabled: true, field: 'name', fieldType: 'string' },
|
name: { label: '设备名称', enabled: true, field: 'name', fieldType: 'string' },
|
||||||
deviceId: { label: '设备ID', enabled: true, field: 'deviceId', fieldType: 'string' },
|
deviceId: { label: '设备ID', enabled: true, field: 'deviceId', fieldType: 'string' },
|
||||||
type: { label: '设备类型', enabled: true, field: 'type', fieldType: 'string' },
|
type: { label: '设备类型', enabled: true, field: 'type', fieldType: 'string' },
|
||||||
@@ -202,10 +327,15 @@ function RackVisualization() {
|
|||||||
height: { label: '高度', enabled: true, field: 'height', fieldType: 'number' },
|
height: { label: '高度', enabled: true, field: 'height', fieldType: 'number' },
|
||||||
ipAddress: { label: 'IP地址', enabled: true, field: 'ipAddress', fieldType: 'string' },
|
ipAddress: { label: 'IP地址', enabled: true, field: 'ipAddress', fieldType: 'string' },
|
||||||
power: { label: '功率', enabled: true, field: 'power', fieldType: 'number' }
|
power: { label: '功率', enabled: true, field: 'power', fieldType: 'number' }
|
||||||
};
|
}), []);
|
||||||
|
|
||||||
// 获取设备字段配置
|
// 初始化样式
|
||||||
const fetchTooltipDeviceFields = async () => {
|
useEffect(() => {
|
||||||
|
initAnimationStyles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 获取设备字段配置 - 使用 useCallback 避免重复创建
|
||||||
|
const fetchTooltipDeviceFields = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoadingTooltipFields(true);
|
setLoadingTooltipFields(true);
|
||||||
console.log('开始获取设备字段配置...');
|
console.log('开始获取设备字段配置...');
|
||||||
@@ -242,10 +372,10 @@ function RackVisualization() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingTooltipFields(false);
|
setLoadingTooltipFields(false);
|
||||||
}
|
}
|
||||||
};
|
}, [defaultTooltipFields]);
|
||||||
|
|
||||||
// 保存tooltip字段配置
|
// 保存tooltip字段配置
|
||||||
const saveTooltipConfig = async () => {
|
const saveTooltipConfig = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setSavingTooltipConfig(true);
|
setSavingTooltipConfig(true);
|
||||||
|
|
||||||
@@ -268,10 +398,10 @@ function RackVisualization() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSavingTooltipConfig(false);
|
setSavingTooltipConfig(false);
|
||||||
}
|
}
|
||||||
};
|
}, [tooltipFields, fetchTooltipDeviceFields]);
|
||||||
|
|
||||||
// 获取所有机柜
|
// 获取所有机柜 - 使用 useCallback 避免重复创建
|
||||||
const fetchRacks = async () => {
|
const fetchRacks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -317,10 +447,10 @@ function RackVisualization() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// 获取机柜内的设备
|
// 获取机柜内的设备 - 使用 useCallback 避免重复创建
|
||||||
const fetchDevices = async (rackId) => {
|
const fetchDevices = useCallback(async (rackId) => {
|
||||||
try {
|
try {
|
||||||
setLoadingDevices(true);
|
setLoadingDevices(true);
|
||||||
console.log(`=== 开始获取机柜 ${rackId} 的设备数据 ===`);
|
console.log(`=== 开始获取机柜 ${rackId} 的设备数据 ===`);
|
||||||
@@ -462,13 +592,11 @@ function RackVisualization() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingDevices(false);
|
setLoadingDevices(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRacks();
|
fetchRacks();
|
||||||
loadBackgroundSettings();
|
}, [fetchRacks]);
|
||||||
fetchTooltipDeviceFields();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 打开字段配置模态框时获取数据
|
// 打开字段配置模态框时获取数据
|
||||||
const handleOpenTooltipConfig = () => {
|
const handleOpenTooltipConfig = () => {
|
||||||
@@ -478,120 +606,6 @@ function RackVisualization() {
|
|||||||
setShowTooltipConfig(true);
|
setShowTooltipConfig(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 根据设备类型获取图标
|
|
||||||
const getDeviceIcon = (deviceType) => {
|
|
||||||
try {
|
|
||||||
if (!deviceType) return <CloudServerOutlined style={{ color: '#ffffff' }} />;
|
|
||||||
const type = deviceType.toLowerCase();
|
|
||||||
|
|
||||||
if (type.includes('server') || type.includes('服务器')) return <CloudServerOutlined style={{ color: '#ffffff' }} />;
|
|
||||||
if (type.includes('switch') || type.includes('交换机')) return <SwitcherOutlined style={{ color: '#ffffff' }} />;
|
|
||||||
if (type.includes('storage') || type.includes('存储')) return <DatabaseOutlined style={{ color: '#ffffff' }} />;
|
|
||||||
if (type.includes('router') || type.includes('路由器')) return <CloudOutlined style={{ color: '#ffffff' }} />;
|
|
||||||
if (type.includes('laptop') || type.includes('笔记本')) return <LaptopOutlined style={{ color: '#ffffff' }} />;
|
|
||||||
if (type.includes('mobile') || type.includes('手机')) return <MobileOutlined style={{ color: '#ffffff' }} />;
|
|
||||||
if (type.includes('printer') || type.includes('打印机')) return <PrinterOutlined style={{ color: '#ffffff' }} />;
|
|
||||||
|
|
||||||
return <CloudServerOutlined style={{ color: '#ffffff' }} />;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('设备图标渲染错误:', error);
|
|
||||||
return <CloudServerOutlined style={{ color: '#ffffff' }} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 根据设备类型获取背景色
|
|
||||||
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', // 正常 - 绿色常亮
|
|
||||||
'warning': '#f59e0b', // 预警 - 黄色常亮
|
|
||||||
'error': '#ef4444', // 告警 - 红色慢闪
|
|
||||||
'offline': '#6b7280', // 离线 - 灰色
|
|
||||||
'maintenance': '#3b82f6', // 维护 - 蓝色常亮
|
|
||||||
undefined: '#3b82f6', // 默认普通设备 - 蓝色常亮
|
|
||||||
null: '#3b82f6'
|
|
||||||
};
|
|
||||||
return statusColorMap[status] || '#3b82f6';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取设备类型对应的颜色主题
|
|
||||||
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: '其他'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return themeMap[type?.toLowerCase()] || themeMap['other'];
|
|
||||||
};
|
|
||||||
|
|
||||||
// 生成模拟监控数据
|
// 生成模拟监控数据
|
||||||
const generateMonitoringData = (device) => {
|
const generateMonitoringData = (device) => {
|
||||||
const baseTemp = device.type?.includes('服务器') ? 65 :
|
const baseTemp = device.type?.includes('服务器') ? 65 :
|
||||||
@@ -1987,4 +2001,4 @@ function RackVisualization() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RackVisualization;
|
export default React.memo(RackVisualization);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Table, Button, Modal, Form, Input, Select, DatePicker, message, Card, Space, Tag, Dropdown, Menu, Tabs, Timeline, Descriptions } from 'antd';
|
import { Table, Button, Modal, Form, Input, Select, DatePicker, message, Card, Space, Tag, Dropdown, Menu, Tabs, Timeline, Descriptions } from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, EyeOutlined, MoreOutlined, UserOutlined, ToolOutlined, CheckCircleOutlined, SyncOutlined, ClockCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, EyeOutlined, MoreOutlined, UserOutlined, ToolOutlined, CheckCircleOutlined, SyncOutlined, ClockCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -9,6 +9,46 @@ const { RangePicker } = DatePicker;
|
|||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
const { TabPane } = Tabs;
|
const { TabPane } = Tabs;
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
const colors = {
|
||||||
|
pending: 'orange',
|
||||||
|
in_progress: 'processing',
|
||||||
|
completed: 'green',
|
||||||
|
closed: 'default'
|
||||||
|
};
|
||||||
|
return colors[status] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
const texts = {
|
||||||
|
pending: '待处理',
|
||||||
|
in_progress: '处理中',
|
||||||
|
completed: '已完成',
|
||||||
|
closed: '已关闭'
|
||||||
|
};
|
||||||
|
return texts[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority) => {
|
||||||
|
const colors = {
|
||||||
|
low: 'green',
|
||||||
|
medium: 'orange',
|
||||||
|
high: 'red',
|
||||||
|
urgent: 'magenta'
|
||||||
|
};
|
||||||
|
return colors[priority] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityText = (priority) => {
|
||||||
|
const texts = {
|
||||||
|
low: '低',
|
||||||
|
medium: '中',
|
||||||
|
high: '高',
|
||||||
|
urgent: '紧急'
|
||||||
|
};
|
||||||
|
return texts[priority] || priority;
|
||||||
|
};
|
||||||
|
|
||||||
function TicketManagement() {
|
function TicketManagement() {
|
||||||
const [tickets, setTickets] = useState([]);
|
const [tickets, setTickets] = useState([]);
|
||||||
const [devices, setDevices] = useState([]);
|
const [devices, setDevices] = useState([]);
|
||||||
@@ -35,7 +75,7 @@ function TicketManagement() {
|
|||||||
|
|
||||||
const [searchFilters, setSearchFilters] = useState({});
|
const [searchFilters, setSearchFilters] = useState({});
|
||||||
|
|
||||||
const fetchTickets = async (page = 1, pageSize = 10, filters = {}) => {
|
const fetchTickets = useCallback(async (page = 1, pageSize = 10, filters = {}) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const params = {
|
const params = {
|
||||||
@@ -56,27 +96,27 @@ function TicketManagement() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [searchFilters]);
|
||||||
|
|
||||||
const fetchDevices = async () => {
|
const fetchDevices = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/devices', { params: { pageSize: 1000 } });
|
const response = await axios.get('/api/devices', { params: { pageSize: 1000 } });
|
||||||
setDevices(response.data.devices || []);
|
setDevices(response.data.devices || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取设备列表失败:', error);
|
console.error('获取设备列表失败:', error);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const fetchCategories = async () => {
|
const fetchCategories = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/ticket-categories');
|
const response = await axios.get('/api/ticket-categories');
|
||||||
setCategories(response.data || []);
|
setCategories(response.data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取分类列表失败:', error);
|
console.error('获取分类列表失败:', error);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const fetchTicketDetail = async (ticketId) => {
|
const fetchTicketDetail = useCallback(async (ticketId) => {
|
||||||
try {
|
try {
|
||||||
const [ticketRes, operationsRes] = await Promise.all([
|
const [ticketRes, operationsRes] = await Promise.all([
|
||||||
axios.get(`/api/tickets/${ticketId}`),
|
axios.get(`/api/tickets/${ticketId}`),
|
||||||
@@ -90,15 +130,15 @@ function TicketManagement() {
|
|||||||
message.error('获取工单详情失败');
|
message.error('获取工单详情失败');
|
||||||
console.error('获取工单详情失败:', error);
|
console.error('获取工单详情失败:', error);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTickets();
|
fetchTickets();
|
||||||
fetchDevices();
|
fetchDevices();
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
}, []);
|
}, [fetchTickets, fetchDevices, fetchCategories]);
|
||||||
|
|
||||||
const showModal = (ticket = null) => {
|
const showModal = useCallback((ticket = null) => {
|
||||||
setEditingTicket(ticket);
|
setEditingTicket(ticket);
|
||||||
if (ticket) {
|
if (ticket) {
|
||||||
const ticketData = { ...ticket };
|
const ticketData = { ...ticket };
|
||||||
@@ -113,14 +153,14 @@ function TicketManagement() {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
}
|
}
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = useCallback(() => {
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
setEditingTicket(null);
|
setEditingTicket(null);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (values) => {
|
const handleSubmit = useCallback(async (values) => {
|
||||||
try {
|
try {
|
||||||
const ticketData = {
|
const ticketData = {
|
||||||
...values,
|
...values,
|
||||||
@@ -146,9 +186,9 @@ function TicketManagement() {
|
|||||||
message.error(editingTicket ? '工单更新失败' : '工单创建失败');
|
message.error(editingTicket ? '工单更新失败' : '工单创建失败');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
}, [editingTicket, fetchTickets]);
|
||||||
|
|
||||||
const handleDelete = async (ticketId) => {
|
const handleDelete = useCallback(async (ticketId) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
content: '确定要删除这个工单吗?',
|
content: '确定要删除这个工单吗?',
|
||||||
@@ -166,15 +206,15 @@ function TicketManagement() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
}, [fetchTickets]);
|
||||||
|
|
||||||
const handleProcess = (ticket) => {
|
const handleProcess = useCallback((ticket) => {
|
||||||
setSelectedTicket(ticket);
|
setSelectedTicket(ticket);
|
||||||
processForm.resetFields();
|
processForm.resetFields();
|
||||||
setProcessingModalVisible(true);
|
setProcessingModalVisible(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleProcessSubmit = async (values) => {
|
const handleProcessSubmit = useCallback(async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.put(`/api/tickets/${selectedTicket.ticketId}/process`, {
|
await axios.put(`/api/tickets/${selectedTicket.ticketId}/process`, {
|
||||||
...values,
|
...values,
|
||||||
@@ -188,9 +228,9 @@ function TicketManagement() {
|
|||||||
message.error('处理失败');
|
message.error('处理失败');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
}, [selectedTicket, fetchTickets]);
|
||||||
|
|
||||||
const handleStatusChange = async (ticketId, newStatus) => {
|
const handleStatusChange = useCallback(async (ticketId, newStatus) => {
|
||||||
try {
|
try {
|
||||||
await axios.put(`/api/tickets/${ticketId}/status`, {
|
await axios.put(`/api/tickets/${ticketId}/status`, {
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
@@ -203,65 +243,25 @@ function TicketManagement() {
|
|||||||
message.error('状态更新失败');
|
message.error('状态更新失败');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
}, [fetchTickets]);
|
||||||
|
|
||||||
const handleSearch = (values) => {
|
const handleSearch = useCallback((values) => {
|
||||||
setSearchFilters(values);
|
setSearchFilters(values);
|
||||||
fetchTickets(1, pagination.pageSize, values);
|
fetchTickets(1, pagination.pageSize, values);
|
||||||
};
|
}, [fetchTickets, pagination.pageSize]);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = useCallback(() => {
|
||||||
searchForm.resetFields();
|
searchForm.resetFields();
|
||||||
setSearchFilters({});
|
setSearchFilters({});
|
||||||
fetchTickets(1, pagination.pageSize, {});
|
fetchTickets(1, pagination.pageSize, {});
|
||||||
};
|
}, [fetchTickets, pagination.pageSize]);
|
||||||
|
|
||||||
const handleTableChange = (paginationInfo) => {
|
const handleTableChange = useCallback((paginationInfo) => {
|
||||||
setPagination(paginationInfo);
|
setPagination(paginationInfo);
|
||||||
fetchTickets(paginationInfo.current, paginationInfo.pageSize, searchFilters);
|
fetchTickets(paginationInfo.current, paginationInfo.pageSize, searchFilters);
|
||||||
};
|
}, [fetchTickets, searchFilters]);
|
||||||
|
|
||||||
const getStatusColor = (status) => {
|
const columns = useMemo(() => [
|
||||||
const colors = {
|
|
||||||
pending: 'orange',
|
|
||||||
in_progress: 'processing',
|
|
||||||
completed: 'green',
|
|
||||||
closed: 'default'
|
|
||||||
};
|
|
||||||
return colors[status] || 'default';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusText = (status) => {
|
|
||||||
const texts = {
|
|
||||||
pending: '待处理',
|
|
||||||
in_progress: '处理中',
|
|
||||||
completed: '已完成',
|
|
||||||
closed: '已关闭'
|
|
||||||
};
|
|
||||||
return texts[status] || status;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityColor = (priority) => {
|
|
||||||
const colors = {
|
|
||||||
low: 'green',
|
|
||||||
medium: 'orange',
|
|
||||||
high: 'red',
|
|
||||||
urgent: 'magenta'
|
|
||||||
};
|
|
||||||
return colors[priority] || 'default';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityText = (priority) => {
|
|
||||||
const texts = {
|
|
||||||
low: '低',
|
|
||||||
medium: '中',
|
|
||||||
high: '高',
|
|
||||||
urgent: '紧急'
|
|
||||||
};
|
|
||||||
return texts[priority] || priority;
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
{
|
||||||
title: '工单编号',
|
title: '工单编号',
|
||||||
dataIndex: 'ticketId',
|
dataIndex: 'ticketId',
|
||||||
@@ -404,7 +404,7 @@ function TicketManagement() {
|
|||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
];
|
], [fetchTicketDetail, handleStatusChange, handleDelete]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24 }}>
|
||||||
@@ -651,4 +651,4 @@ function TicketManagement() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TicketManagement;
|
export default React.memo(TicketManagement);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Card, Row, Col, Statistic, Table, DatePicker, Select, Space, Tag, message } from 'antd';
|
import { Card, Row, Col, Statistic, Table, DatePicker, Select, Space, Tag, message } from 'antd';
|
||||||
import { BarChartOutlined, PieChartOutlined, RiseOutlined, FallOutlined, ClockCircleOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
import { BarChartOutlined, PieChartOutlined, RiseOutlined, FallOutlined, ClockCircleOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -7,6 +7,48 @@ import dayjs from 'dayjs';
|
|||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
const colors = {
|
||||||
|
pending: 'orange',
|
||||||
|
assigned: 'blue',
|
||||||
|
in_progress: 'processing',
|
||||||
|
completed: 'green',
|
||||||
|
closed: 'default'
|
||||||
|
};
|
||||||
|
return colors[status] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
const texts = {
|
||||||
|
pending: '待处理',
|
||||||
|
assigned: '已分配',
|
||||||
|
in_progress: '处理中',
|
||||||
|
completed: '已完成',
|
||||||
|
closed: '已关闭'
|
||||||
|
};
|
||||||
|
return texts[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority) => {
|
||||||
|
const colors = {
|
||||||
|
low: 'green',
|
||||||
|
medium: 'orange',
|
||||||
|
high: 'red',
|
||||||
|
urgent: 'magenta'
|
||||||
|
};
|
||||||
|
return colors[priority] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityText = (priority) => {
|
||||||
|
const texts = {
|
||||||
|
low: '低',
|
||||||
|
medium: '中',
|
||||||
|
high: '高',
|
||||||
|
urgent: '紧急'
|
||||||
|
};
|
||||||
|
return texts[priority] || priority;
|
||||||
|
};
|
||||||
|
|
||||||
function TicketStatistics() {
|
function TicketStatistics() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dateRange, setDateRange] = useState([
|
const [dateRange, setDateRange] = useState([
|
||||||
@@ -27,7 +69,7 @@ function TicketStatistics() {
|
|||||||
trend: []
|
trend: []
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchStatistics = async () => {
|
const fetchStatistics = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const params = {
|
const params = {
|
||||||
@@ -43,17 +85,17 @@ function TicketStatistics() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [dateRange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStatistics();
|
fetchStatistics();
|
||||||
}, [dateRange]);
|
}, [fetchStatistics]);
|
||||||
|
|
||||||
const handleDateChange = (dates) => {
|
const handleDateChange = useCallback((dates) => {
|
||||||
if (dates) {
|
if (dates) {
|
||||||
setDateRange(dates);
|
setDateRange(dates);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const getStatusColor = (status) => {
|
const getStatusColor = (status) => {
|
||||||
const colors = {
|
const colors = {
|
||||||
@@ -97,7 +139,7 @@ function TicketStatistics() {
|
|||||||
return texts[priority] || priority;
|
return texts[priority] || priority;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusColumns = [
|
const statusColumns = useMemo(() => [
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -127,9 +169,9 @@ function TicketStatistics() {
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
];
|
], []);
|
||||||
|
|
||||||
const categoryColumns = [
|
const categoryColumns = useMemo(() => [
|
||||||
{
|
{
|
||||||
title: '故障分类',
|
title: '故障分类',
|
||||||
dataIndex: 'category',
|
dataIndex: 'category',
|
||||||
@@ -164,38 +206,9 @@ function TicketStatistics() {
|
|||||||
width: 150,
|
width: 150,
|
||||||
render: (time) => time !== undefined && time !== null ? time.toFixed(1) : '-'
|
render: (time) => time !== undefined && time !== null ? time.toFixed(1) : '-'
|
||||||
}
|
}
|
||||||
];
|
], []);
|
||||||
|
|
||||||
const deviceColumns = [
|
const priorityColumns = useMemo(() => [
|
||||||
{
|
|
||||||
title: '设备名称',
|
|
||||||
dataIndex: 'deviceName',
|
|
||||||
key: 'deviceName',
|
|
||||||
width: 180
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '故障次数',
|
|
||||||
dataIndex: 'count',
|
|
||||||
key: 'count',
|
|
||||||
width: 100,
|
|
||||||
render: (count) => <Tag color="red">{count}</Tag>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '最后故障时间',
|
|
||||||
dataIndex: 'lastFaultTime',
|
|
||||||
key: 'lastFaultTime',
|
|
||||||
width: 160,
|
|
||||||
render: (text) => text ? dayjs(text).format('YYYY-MM-DD HH:mm') : '-'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '设备类型',
|
|
||||||
dataIndex: 'deviceType',
|
|
||||||
key: 'deviceType',
|
|
||||||
width: 100
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const priorityColumns = [
|
|
||||||
{
|
{
|
||||||
title: '优先级',
|
title: '优先级',
|
||||||
dataIndex: 'priority',
|
dataIndex: 'priority',
|
||||||
@@ -228,7 +241,36 @@ function TicketStatistics() {
|
|||||||
width: 150,
|
width: 150,
|
||||||
render: (time) => time !== undefined && time !== null ? time.toFixed(1) : '-'
|
render: (time) => time !== undefined && time !== null ? time.toFixed(1) : '-'
|
||||||
}
|
}
|
||||||
];
|
], []);
|
||||||
|
|
||||||
|
const deviceColumns = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: '设备名称',
|
||||||
|
dataIndex: 'deviceName',
|
||||||
|
key: 'deviceName',
|
||||||
|
width: 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '故障次数',
|
||||||
|
dataIndex: 'count',
|
||||||
|
key: 'count',
|
||||||
|
width: 100,
|
||||||
|
render: (count) => <Tag color="red">{count}</Tag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后故障时间',
|
||||||
|
dataIndex: 'lastFaultTime',
|
||||||
|
key: 'lastFaultTime',
|
||||||
|
width: 160,
|
||||||
|
render: (text) => text ? dayjs(text).format('YYYY-MM-DD HH:mm') : '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '设备类型',
|
||||||
|
dataIndex: 'deviceType',
|
||||||
|
key: 'deviceType',
|
||||||
|
width: 100
|
||||||
|
}
|
||||||
|
], []);
|
||||||
|
|
||||||
const simpleBarData = [
|
const simpleBarData = [
|
||||||
{ name: '待处理', value: statistics.pending },
|
{ name: '待处理', value: statistics.pending },
|
||||||
@@ -454,4 +496,4 @@ function TicketStatistics() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TicketStatistics;
|
export default React.memo(TicketStatistics);
|
||||||
|
|||||||
+25
-1
@@ -19,6 +19,30 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: true
|
sourcemap: false,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'antd': ['antd', '@ant-design/icons'],
|
||||||
|
'vendor': ['react', 'react-dom', 'react-router-dom'],
|
||||||
|
'charts': ['axios', 'dayjs'],
|
||||||
|
'utils': ['three', 'xlsx']
|
||||||
|
},
|
||||||
|
chunkFileNames: 'js/[name]-[hash].js',
|
||||||
|
entryFileNames: 'js/[name]-[hash].js',
|
||||||
|
assetFileNames: '[ext]/[name]-[hash].[ext]',
|
||||||
|
compact: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
minify: 'terser',
|
||||||
|
terserOptions: {
|
||||||
|
compress: {
|
||||||
|
drop_console: true,
|
||||||
|
drop_debugger: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['antd', '@ant-design/icons', 'axios', 'react-router-dom']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user