From 381ea7085d4ce6ad393569313927eb7870da6d6e Mon Sep 17 00:00:00 2001 From: v6ole Date: Thu, 2 Apr 2026 23:40:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/01-code-standards.md | 73 + .claude/rules/02-architecture.md | 40 + .claude/rules/03-security.md | 44 + .claude/rules/04-performance.md | 41 + .claude/rules/05-development.md | 48 + .claude/rules/06-deployment.md | 55 + .gitignore | 37 + CLAUDE.md | 335 ++++ MAC关联OLT流程.md | 34 + MAC关联OLT流程改进建议.md | 434 ++++ OLT输出.md | 32 + PROJECT_STRUCTURE.md | 580 ++++++ QUICKSTART.md | 37 + README.md | 282 +++ backend/.env.example | 26 + backend/Dockerfile | 14 + backend/alembic.ini | 36 + backend/alembic/env.py | 40 + backend/alembic/script.py.mako | 26 + ...2c84173ef05_add_location_to_olt_devices.py | 47 + ...3e108afa9bba_add_port_id_to_onu_devices.py | 47 + ...07e0f9c_add_place_type_and_notes_fields.py | 66 + .../versions/add_onu_extended_fields.py | 32 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/v1/__init__.py | 0 backend/app/api/v1/auth.py | 88 + backend/app/api/v1/check.py | 86 + backend/app/api/v1/devices.py | 231 +++ backend/app/api/v1/import_data.py | 77 + backend/app/api/v1/olt.py | 402 ++++ backend/app/api/v1/provision.py | 59 + backend/app/api/v1/stats.py | 118 ++ backend/app/core/__init__.py | 0 backend/app/core/casdoor.py | 12 + backend/app/core/celery_app.py | 23 + backend/app/core/config.py | 50 + backend/app/core/database.py | 17 + backend/app/core/security.py | 22 + backend/app/main.py | 28 + .../app/middleware/permission_middleware.py | 27 + backend/app/models/__init__.py | 0 backend/app/models/device.py | 82 + backend/app/models/permission.py | 21 + backend/app/models/user.py | 19 + backend/app/schemas/__init__.py | 0 backend/app/schemas/auth.py | 20 + backend/app/schemas/device.py | 36 + backend/app/services/__init__.py | 0 backend/app/services/check_service.py | 263 +++ backend/app/services/import_service.py | 117 ++ backend/app/services/provision_service.py | 114 ++ backend/app/services/ssh_service.py | 282 +++ backend/app/tasks/__init__.py | 4 + backend/app/tasks/check_tasks.py | 72 + backend/celery_worker.py | 5 + backend/requirements.txt | 20 + backend/scripts/init_db.py | 11 + backend/templates/OLT设备导入模板.xlsx | Bin 0 -> 5158 bytes backend/templates/设备导入模板.xlsx | Bin 0 -> 5248 bytes backend/test_parser.py | 30 + backend/token_jwt_key.pem | 28 + deploy/.env.example | 185 ++ deploy/docker-compose.yml | 104 + deploy/nginx/conf.d/h3c-onu-ms.conf | 165 ++ deploy/nginx/nginx.conf | 56 + deploy/scripts/deploy.sh | 362 ++++ deploy/scripts/init-db.sh | 389 ++++ deploy/scripts/monitor.sh | 368 ++++ docker-compose.yml | 32 + frontend/Dockerfile | 12 + frontend/index.html | 12 + frontend/package-lock.json | 1778 +++++++++++++++++ frontend/package.json | 22 + frontend/src/App.vue | 6 + frontend/src/api/auth.js | 7 + frontend/src/api/check.js | 3 + frontend/src/api/device.js | 17 + frontend/src/api/import.js | 13 + frontend/src/api/stats.js | 7 + frontend/src/components/Layout.vue | 57 + frontend/src/main.js | 12 + frontend/src/router/index.js | 67 + frontend/src/stores/auth.js | 21 + frontend/src/utils/request.js | 16 + frontend/src/views/Callback.vue | 73 + frontend/src/views/Charts.vue | 74 + frontend/src/views/Dashboard.vue | 313 +++ frontend/src/views/DeviceList.vue | 454 +++++ frontend/src/views/ImportData.vue | 120 ++ frontend/src/views/Login.vue | 55 + frontend/src/views/OltManage.vue | 644 ++++++ frontend/vite.config.js | 16 + run_celery.py | 28 + setup.sh | 31 + start-backend.sh | 8 + start-celery.sh | 18 + start-frontend.sh | 7 + test_write.txt | 0 下发业务流程.md | 22 + 启动指南.md | 58 + 开发总结.md | 128 ++ 开发计划.md | 395 ++++ 开发进度.md | 140 ++ 环路检测方法.md | 34 + 系统设计文档.md | 441 ++++ 项目总结.md | 318 +++ 107 files changed, 11858 insertions(+) create mode 100644 .claude/rules/01-code-standards.md create mode 100644 .claude/rules/02-architecture.md create mode 100644 .claude/rules/03-security.md create mode 100644 .claude/rules/04-performance.md create mode 100644 .claude/rules/05-development.md create mode 100644 .claude/rules/06-deployment.md create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 MAC关联OLT流程.md create mode 100644 MAC关联OLT流程改进建议.md create mode 100644 OLT输出.md create mode 100644 PROJECT_STRUCTURE.md create mode 100644 QUICKSTART.md create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/32c84173ef05_add_location_to_olt_devices.py create mode 100644 backend/alembic/versions/3e108afa9bba_add_port_id_to_onu_devices.py create mode 100644 backend/alembic/versions/5c24b07e0f9c_add_place_type_and_notes_fields.py create mode 100644 backend/alembic/versions/add_onu_extended_fields.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/api/v1/check.py create mode 100644 backend/app/api/v1/devices.py create mode 100644 backend/app/api/v1/import_data.py create mode 100644 backend/app/api/v1/olt.py create mode 100644 backend/app/api/v1/provision.py create mode 100644 backend/app/api/v1/stats.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/casdoor.py create mode 100644 backend/app/core/celery_app.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/main.py create mode 100644 backend/app/middleware/permission_middleware.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/device.py create mode 100644 backend/app/models/permission.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/device.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/check_service.py create mode 100644 backend/app/services/import_service.py create mode 100644 backend/app/services/provision_service.py create mode 100644 backend/app/services/ssh_service.py create mode 100644 backend/app/tasks/__init__.py create mode 100644 backend/app/tasks/check_tasks.py create mode 100644 backend/celery_worker.py create mode 100644 backend/requirements.txt create mode 100644 backend/scripts/init_db.py create mode 100644 backend/templates/OLT设备导入模板.xlsx create mode 100644 backend/templates/设备导入模板.xlsx create mode 100644 backend/test_parser.py create mode 100644 backend/token_jwt_key.pem create mode 100644 deploy/.env.example create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/nginx/conf.d/h3c-onu-ms.conf create mode 100644 deploy/nginx/nginx.conf create mode 100755 deploy/scripts/deploy.sh create mode 100755 deploy/scripts/init-db.sh create mode 100755 deploy/scripts/monitor.sh create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/auth.js create mode 100644 frontend/src/api/check.js create mode 100644 frontend/src/api/device.js create mode 100644 frontend/src/api/import.js create mode 100644 frontend/src/api/stats.js create mode 100644 frontend/src/components/Layout.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/stores/auth.js create mode 100644 frontend/src/utils/request.js create mode 100644 frontend/src/views/Callback.vue create mode 100644 frontend/src/views/Charts.vue create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/DeviceList.vue create mode 100644 frontend/src/views/ImportData.vue create mode 100644 frontend/src/views/Login.vue create mode 100644 frontend/src/views/OltManage.vue create mode 100644 frontend/vite.config.js create mode 100755 run_celery.py create mode 100644 setup.sh create mode 100755 start-backend.sh create mode 100755 start-celery.sh create mode 100755 start-frontend.sh create mode 100644 test_write.txt create mode 100644 下发业务流程.md create mode 100644 启动指南.md create mode 100644 开发总结.md create mode 100644 开发计划.md create mode 100644 开发进度.md create mode 100644 环路检测方法.md create mode 100644 系统设计文档.md create mode 100644 项目总结.md diff --git a/.claude/rules/01-code-standards.md b/.claude/rules/01-code-standards.md new file mode 100644 index 0000000..087daac --- /dev/null +++ b/.claude/rules/01-code-standards.md @@ -0,0 +1,73 @@ +# 代码规范 + +## Python 后端规范 + +- 遵循 PEP 8 规范 +- 使用 Black 进行代码格式化 +- 使用 isort 进行导入排序 +- 使用类型注解(Type Hints) +- 异步函数使用 async/await +- 错误处理使用自定义异常类 + +**示例**: +```python +from typing import List, Optional +from fastapi import HTTPException + +async def get_devices( + skip: int = 0, + limit: int = 100 +) -> List[Device]: + """获取设备列表""" + try: + devices = await device_service.get_all(skip, limit) + return devices + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) +``` + +## Vue 前端规范 + +- 使用 Composition API(setup script) +- 组件使用 PascalCase 命名 +- 使用 TypeScript 类型检查 +- 遵循 Vue 官方风格指南 +- 使用 ESLint + Prettier 格式化 + +**示例**: +```vue + +``` + +## Git 提交规范 + +使用 Conventional Commits 格式: +``` +(): + +类型: +- feat: 新功能 +- fix: 修复bug +- docs: 文档更新 +- style: 代码格式 +- refactor: 重构 +- test: 测试 +- chore: 构建/工具 + +示例: +feat(device): 添加设备导入功能 +fix(auth): 修复登录token过期问题 +``` diff --git a/.claude/rules/02-architecture.md b/.claude/rules/02-architecture.md new file mode 100644 index 0000000..c67012a --- /dev/null +++ b/.claude/rules/02-architecture.md @@ -0,0 +1,40 @@ +# 架构规则 + +## 后端架构 + +### 分层架构 +- **API 层**:仅处理请求/响应,调用 Service +- **Service 层**:业务逻辑,不直接操作数据库 +- **Model 层**:SQLAlchemy 模型定义 + +### 目录结构 +``` +app/ +├── api/v1/ # API 路由 +├── services/ # 业务逻辑 +├── models/ # 数据模型 +├── schemas/ # Pydantic 模式 +├── core/ # 核心配置 +├── tasks/ # Celery 任务 +└── utils/ # 工具函数 +``` + +### 异步任务 +- 耗时操作使用 Celery +- SSH 状态检查使用异步任务 +- 批量导入使用后台任务 + +## 前端架构 + +### 组件分类 +- `components/common/`:通用组件(Layout、Table、Form) +- `components/business/`:业务组件(Device、User) +- `views/`:页面组件 + +### 状态管理 +- 使用 Pinia stores +- 按模块划分:auth、device、user、system + +### API 调用 +- 统一在 `api/` 目录封装 +- 使用 Axios 拦截器处理认证和错误 diff --git a/.claude/rules/03-security.md b/.claude/rules/03-security.md new file mode 100644 index 0000000..dcec0d6 --- /dev/null +++ b/.claude/rules/03-security.md @@ -0,0 +1,44 @@ +# 安全规则 + +## 数据安全 + +- SSH 密码必须加密存储(使用 Fernet 加密) +- 敏感信息通过环境变量配置 +- 数据库连接使用 SSL +- 定期备份数据(90天历史记录) + +**密码加密示例**: +```python +from cryptography.fernet import Fernet + +def encrypt_password(password: str, key: bytes) -> str: + f = Fernet(key) + return f.encrypt(password.encode()).decode() + +def decrypt_password(encrypted: str, key: bytes) -> str: + f = Fernet(key) + return f.decrypt(encrypted.encode()).decode() +``` + +## 应用安全 + +- 所有 API 端点需要认证(除登录接口) +- 实现 CSRF 保护 +- 输入验证使用 Pydantic +- SQL 注入防护(使用 ORM) +- XSS 防护(前端转义) +- 实现速率限制 + +## 访问控制 + +- 基于 RBAC 的权限控制 +- 数据级权限过滤(按区域/学校) +- 操作审计日志记录 +- 设备信息变更需要审核 + +**权限级别**: +- 超级管理员:所有权限 +- 管理员:管理所有设备和用户 +- 区域管理员:管理指定区域的设备 +- 学校管理员:管理指定学校的设备 +- 普通用户:只读权限 diff --git a/.claude/rules/04-performance.md b/.claude/rules/04-performance.md new file mode 100644 index 0000000..ae6512c --- /dev/null +++ b/.claude/rules/04-performance.md @@ -0,0 +1,41 @@ +# 性能规则 + +## 数据库优化 + +- 为常用查询字段添加索引(mac_address, region, school_name) +- 使用连接池(pool_size=20, max_overflow=40) +- 避免 N+1 查询问题 +- 定期清理历史数据(保留90天) +- 使用 select_related/joinedload 预加载关联数据 + +**索引示例**: +```python +class ONUDevice(Base): + __tablename__ = "onu_devices" + + mac_address = Column(String(17), index=True) + region = Column(String(100), index=True) + school_name = Column(String(200), index=True) +``` + +## 缓存策略 + +- Redis 缓存热点数据(设备状态) +- 缓存过期时间:30分钟 +- 手动刷新有5分钟冷却限制 +- 使用缓存键命名规范:`device:status:{device_id}` + +## 异步处理 + +- SSH 状态检查使用 Celery 异步任务 +- 批量导入使用后台任务 +- 定时任务使用 Celery Beat(每30分钟) +- 分批处理大量设备(每批100个) + +## 前端优化 + +- 组件懒加载 +- 图片懒加载 +- 虚拟滚动(大列表) +- 防抖和节流 +- 资源压缩和 CDN diff --git a/.claude/rules/05-development.md b/.claude/rules/05-development.md new file mode 100644 index 0000000..b46de5e --- /dev/null +++ b/.claude/rules/05-development.md @@ -0,0 +1,48 @@ +# 开发规则 + +## 环境配置 + +- 开发环境使用 `.env.development` +- 生产环境使用 `.env.production` +- 不提交 `.env` 文件到 Git +- 提供 `.env.example` 模板 + +**必需环境变量**: +```bash +DATABASE_URL=postgresql://user:pass@host:5432/dbname +REDIS_URL=redis://host:6379/0 +CASDOOR_ENDPOINT=https://casdoor.example.com +CASDOOR_CLIENT_ID=your_client_id +CASDOOR_CLIENT_SECRET=your_client_secret +SECRET_KEY=your-secret-key +``` + +## 测试要求 + +- 核心业务逻辑需要单元测试 +- API 端点需要集成测试 +- 测试覆盖率目标:≥80% +- 使用 pytest(后端)和 vitest(前端) + +**测试示例**: +```python +def test_device_status_check(): + device = create_test_device() + result = check_device_status(device) + assert result.status in ["online", "offline"] +``` + +## 文档要求 + +- API 变更及时更新 Swagger 文档 +- 复杂业务逻辑添加注释 +- 重要配置添加说明 +- 保持 README 和设计文档同步 + +## 开发流程 + +1. 创建功能分支:`git checkout -b feature/xxx` +2. 开发并提交代码 +3. 运行测试:`pytest` / `npm run test` +4. 提交 PR 并等待审核 +5. 合并到主分支 diff --git a/.claude/rules/06-deployment.md b/.claude/rules/06-deployment.md new file mode 100644 index 0000000..117202a --- /dev/null +++ b/.claude/rules/06-deployment.md @@ -0,0 +1,55 @@ +# 部署规则 + +## Docker 部署 + +- 使用 Docker Compose 编排 +- 外部 PostgreSQL 和 Redis(不在容器内) +- 日志挂载到宿主机 +- 使用 Nginx 反向代理 + +**服务列表**: +- backend: FastAPI 应用 +- celery-worker: Celery 工作进程 +- celery-beat: Celery 定时任务 +- frontend: Vue 静态文件(Nginx) +- nginx: 反向代理 + +## 环境变量 + +**后端必需配置**: +```bash +DATABASE_URL # PostgreSQL 连接 +REDIS_URL # Redis 连接 +CASDOOR_* # Casdoor 认证配置 +SECRET_KEY # 应用密钥 +SSH_TIMEOUT=30 # SSH 超时时间 +``` + +**前端必需配置**: +```bash +VITE_API_BASE_URL # 后端 API 地址 +VITE_CASDOOR_* # Casdoor 前端配置 +``` + +## 监控告警 + +- 健康检查端点:`GET /health` +- 性能指标端点:`GET /metrics` +- 日志级别:生产环境使用 INFO +- 日志轮转:10MB 每个文件,保留30天 + +## 部署流程 + +1. 配置环境变量(`.env` 文件) +2. 构建镜像:`docker-compose build` +3. 启动服务:`docker-compose up -d` +4. 运行数据库迁移:`docker-compose exec backend alembic upgrade head` +5. 检查服务状态:`docker-compose ps` +6. 查看日志:`docker-compose logs -f` + +## 备份策略 + +- 数据库每日自动备份 +- 备份保留30天 +- 重要操作前手动备份 +- 定期测试恢复流程 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce855da --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +*.egg-info/ +dist/ +build/ + +# Node +node_modules/ +dist/ +.DS_Store + +# Environment +.env +.env.local + +# IDE +.vscode/ +.idea/ +*.swp + +# Logs +logs/ +*.log + +# Database +*.db +*.sqlite + +# Temp +/tmp/ +*.tmp diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ccd96fa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,335 @@ +# H3C ONU设备管理系统 - Claude开发指南 + +## 项目概述 + +这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。 + +**当前版本**: v0.5.0 +**开发状态**: 核心功能已完成 + +**已实现功能**: +- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页) +- ✅ Excel 数据导入和批量管理 +- ✅ 定时自动状态检查(每30分钟)+ 手动触发 +- ✅ Casdoor 统一认证和 JWT 令牌管理 +- ✅ 设备管理(列表、详情、筛选、分页) +- ✅ 统计仪表板(总数、在线、离线) +- ✅ 权限管理基础框架(RBAC) + +**技术栈**: +- 后端:Python FastAPI + PostgreSQL + Celery + Redis + Paramiko +- 前端:Vue 3 + Element Plus + Pinia + Axios +- 部署:Docker Compose + +--- + +## 已实现的 API 端点 + +### 认证相关 +- `GET /api/auth/login` - 获取 Casdoor 登录 URL +- `POST /api/auth/callback` - Casdoor 登录回调 +- `GET /api/auth/profile` - 获取当前用户信息 + +### 设备管理 +- `GET /api/devices` - 获取设备列表(分页、筛选) +- `GET /api/devices/{id}` - 获取设备详情 + +### 状态检查 +- `POST /api/check/status` - 手动触发状态检查 + +### 数据导入 +- `POST /api/import/upload` - 上传并导入 Excel 文件 + +### 统计信息 +- `GET /api/stats/summary` - 获取统计摘要(总数、在线、离线) + +### 系统 +- `GET /health` - 健康检查 +- `GET /docs` - API 文档(Swagger UI) + +--- + +## Rules + +### 代码规范 + +#### Python 后端规范 +- 遵循 PEP 8 规范 +- 使用 Black 进行代码格式化 +- 使用 isort 进行导入排序 +- 使用类型注解(Type Hints) +- 异步函数使用 async/await +- 错误处理使用自定义异常类 + +#### Vue 前端规范 +- 使用 Composition API(setup script) +- 组件使用 PascalCase 命名 +- 使用 TypeScript 类型检查 +- 遵循 Vue 官方风格指南 +- 使用 ESLint + Prettier 格式化 + +#### Git 提交规范 +使用 Conventional Commits 格式: +``` +(): + +类型:feat, fix, docs, style, refactor, test, chore +示例:feat(device): 添加设备导入功能 +``` + +### 架构规则 + +#### 后端架构 +- **分层架构**:API → Service → Model +- **API 层**:仅处理请求/响应,调用 Service +- **Service 层**:业务逻辑,不直接操作数据库 +- **Model 层**:SQLAlchemy 模型定义 +- **异步任务**:耗时操作使用 Celery + +#### 前端架构 +- **组件分类**: + - `components/common/`:通用组件 + - `components/business/`:业务组件 + - `views/`:页面组件 +- **状态管理**:使用 Pinia stores +- **API 调用**:统一在 `api/` 目录封装 + +### 安全规则 + +#### 数据安全 +- SSH 密码必须加密存储(使用 Fernet 加密) +- 敏感信息通过环境变量配置 +- 数据库连接使用 SSL +- 定期备份数据(90天历史记录) + +#### 应用安全 +- 所有 API 端点需要认证(除登录接口) +- 实现 CSRF 保护 +- 输入验证使用 Pydantic +- SQL 注入防护(使用 ORM) +- XSS 防护(前端转义) + +#### 访问控制 +- 基于 RBAC 的权限控制 +- 数据级权限过滤(按区域/学校) +- 操作审计日志记录 +- 设备信息变更需要审核 + +### 性能规则 + +#### 数据库优化 +- 为常用查询字段添加索引 +- 使用连接池(pool_size=20) +- 避免 N+1 查询问题 +- 定期清理历史数据(保留90天) + +#### 缓存策略 +- Redis 缓存热点数据(设备状态) +- 缓存过期时间:30分钟 +- 手动刷新有5分钟冷却限制 + +#### 异步处理 +- SSH 状态检查使用 Celery 异步任务 +- 批量导入使用后台任务 +- 定时任务使用 Celery Beat + +### 开发规则 + +#### 环境配置 +- 开发环境使用 `.env.development` +- 生产环境使用 `.env.production` +- 不提交 `.env` 文件到 Git +- 提供 `.env.example` 模板 + +#### 测试要求 +- 核心业务逻辑需要单元测试 +- API 端点需要集成测试 +- 测试覆盖率目标:≥80% + +#### 文档要求 +- API 变更及时更新 Swagger 文档 +- 复杂业务逻辑添加注释 +- 重要配置添加说明 + +### 部署规则 + +#### Docker 部署 +- 使用 Docker Compose 编排 +- 外部 PostgreSQL 和 Redis(不在容器内) +- 日志挂载到宿主机 +- 使用 Nginx 反向代理 + +#### 环境变量 +必需配置: +- `DATABASE_URL`:PostgreSQL 连接字符串 +- `REDIS_URL`:Redis 连接字符串 +- `CASDOOR_*`:Casdoor 认证配置 +- `SECRET_KEY`:应用密钥 + +#### 监控告警 +- 健康检查端点:`/health` +- 性能指标端点:`/metrics` +- 日志级别:生产环境使用 INFO + +--- + +## 项目结构 + +``` +H3ConuMS2/ +├── backend/ # Python 后端 +│ ├── app/ +│ │ ├── api/ # API 路由 +│ │ ├── core/ # 核心配置 +│ │ ├── models/ # 数据模型 +│ │ ├── schemas/ # Pydantic 模式 +│ │ ├── services/ # 业务逻辑 +│ │ ├── tasks/ # Celery 任务 +│ │ └── utils/ # 工具函数 +│ ├── alembic/ # 数据库迁移 +│ └── tests/ # 测试代码 +├── frontend/ # Vue 前端 +│ └── src/ +│ ├── api/ # API 调用 +│ ├── components/ # 组件 +│ ├── router/ # 路由 +│ ├── stores/ # 状态管理 +│ ├── views/ # 页面 +│ └── utils/ # 工具函数 +├── deploy/ # 部署配置 +│ ├── docker-compose.yml +│ └── nginx.conf +└── docs/ # 文档 +``` + +--- + +## 核心业务逻辑 + +### SSH 状态检查流程 +1. 从数据库获取 OLT 设备配置 +2. 建立 SSH 连接(使用连接池) +3. 执行命令:`display onu slot {slot_number}` +4. 解析返回结果: + - 包含 "Up" → 在线 + - 包含 "Offline" → 离线 +5. 更新设备状态到数据库 +6. 记录历史状态 + +### 权限控制逻辑 +- **超级管理员**:所有权限 +- **管理员**:管理所有设备和用户 +- **区域管理员**:管理指定区域的设备 +- **学校管理员**:管理指定学校的设备 +- **普通用户**:只读权限 + +### 数据导入流程 +1. 上传 Excel 文件 +2. 使用 Pandas 解析数据 +3. 数据验证(MAC 地址格式、必填字段) +4. 批量插入数据库 +5. 返回导入结果(成功/失败记录) + +--- + +## 开发指南 + +### 快速启动 + +**后端**: +```bash +cd backend +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +**前端**: +```bash +cd frontend +npm install +npm run dev +``` + +**Celery Worker**: +```bash +cd backend +celery -A app.core.celery_app worker --loglevel=info +``` + +**Celery Beat**: +```bash +cd backend +celery -A app.core.celery_app beat --loglevel=info +``` + +**Docker 部署**: +```bash +# 配置环境变量 +cp backend/.env.example backend/.env + +# 启动所有服务 +docker-compose up -d + +# 初始化数据库 +docker-compose exec backend python scripts/init_db.py +``` + +### 数据库迁移 + +```bash +# 创建迁移 +alembic revision --autogenerate -m "描述" + +# 执行迁移 +alembic upgrade head + +# 回滚 +alembic downgrade -1 +``` + +### 添加新功能 + +1. **后端 API**: + - 在 `app/api/v1/` 创建路由文件 + - 在 `app/services/` 创建服务文件 + - 在 `app/schemas/` 定义请求/响应模式 + - 在 `app/models/` 定义数据模型(如需要) + +2. **前端页面**: + - 在 `views/` 创建页面组件 + - 在 `api/` 添加 API 调用 + - 在 `router/` 添加路由配置 + - 在 `stores/` 添加状态管理(如需要) + +--- + +## 常见问题 + +### SSH 连接失败 +- 检查网络连通性 +- 验证 SSH 凭证 +- 检查防火墙设置 +- 查看日志:`docker-compose logs backend` + +### 数据库连接失败 +- 检查 `DATABASE_URL` 配置 +- 验证数据库服务状态 +- 检查网络权限 + +### Casdoor 登录失败 +- 检查 Casdoor 服务状态 +- 验证 `CASDOOR_*` 配置 +- 检查回调地址配置 + +--- + +## 参考文档 + +- [FastAPI 文档](https://fastapi.tiangolo.com/) +- [Vue 3 文档](https://vuejs.org/) +- [Element Plus 文档](https://element-plus.org/) +- [Casdoor 文档](https://casdoor.org/) +- [项目详细设计](./系统设计文档.md) +- [开发计划](./开发计划.md) diff --git a/MAC关联OLT流程.md b/MAC关联OLT流程.md new file mode 100644 index 0000000..8689108 --- /dev/null +++ b/MAC关联OLT流程.md @@ -0,0 +1,34 @@ +MAC 地址关联OLT的流程应该是这样: +1. 先远程连接上OLT设备,然后输入 `dis onu slot 1`命令,查看olt设备中已经注册的ONU设备信息。注意有“---- More ----”的时候需要键入空格查看更多信息。 +``` +[16OLT]dis onu slot 1 +---------------------------------- Olt1/0/1 --------------------------------- +MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging +1484-778f-cb20 N/A N/A Onu1/0/1:1 N/A N/A Offline N/A +1484-7790-2d20 N/A N/A Onu1/0/1:2 N/A N/A Offline N/A +1484-778f-d620 N/A N/A Onu1/0/1:3 N/A N/A Offline N/A +1484-7790-6220 N/A N/A Onu1/0/1:4 N/A N/A Offline N/A +1484-778f-8e80 N/A N/A Onu1/0/1:5 N/A N/A Offline N/A +1484-778f-a420 N/A N/A Onu1/0/1:6 N/A N/A Offline N/A +---------------------------------- Olt1/0/2 --------------------------------- +MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging +1484-778e-b7e0 1 12384 Onu1/0/2:1 WA6520H-EGPON/A 106/ Up N/A +1484-778e-b4a0 2 12364 Onu1/0/2:2 WA6520H-EGPON/A 106/ Up N/A +1484-778e-a860 4 12414 Onu1/0/2:4 WA6520H-EGPON/A 106/ Up N/A +1484-778e-bce0 5 12387 Onu1/0/2:5 WA6520H-EGPON/A 106/ Up N/A +1484-778e-b400 6 12388 Onu1/0/2:6 WA6520H-EGPON/A 106/ Up N/A +1484-778f-49c0 7 12364 Onu1/0/2:7 WA6520H-EGPON/A 106/ Up N/A +1484-778f-22a0 8 12384 Onu1/0/2:8 WA6520H-EGPON/A 106/ Up N/A +1484-778e-b9c0 9 12417 Onu1/0/2:9 WA6520H-EGPON/A 106/ Up N/A +1484-778f-1aa0 N/A N/A Onu1/0/2:3 N/A N/A Offline N/A +1484-778f-1dc0 N/A N/A Onu1/0/2:10 N/A N/A Offline N/A +1484-778e-f1e0 N/A N/A Onu1/0/2:11 N/A N/A Offline N/A +---------------------------------- Olt1/0/3 --------------------------------- +MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging +---- More ---- +``` +2. 将OLT返回的信息分析后返回到前端的“设备列表”,并展示给用户。比如“1484-778e-b7e0”这个MAC地址,表示的离OLT的距离是12384米,在Onu1/0/2:1端口中,它的状态是Up(在线)。只需要向前端提供这三个信息就好了。然后再将这个MAC地址关联到这台OLT下。 +3. 在数据库中创建一个表,用于存储MAC地址和olt设备之间的关联关系。这个表应该包含三个字段:MAC地址、olt设备ID和olt设备端口。 + + +1. 将设备列表页面的MAC地址变为可以点击,点击后弹出对话框,可以显示距离(12384米)、端口(Onu1/0/2:1),以及后续可能需要新增的编辑按钮、业务下发按钮等等帮我 \ No newline at end of file diff --git a/MAC关联OLT流程改进建议.md b/MAC关联OLT流程改进建议.md new file mode 100644 index 0000000..596f927 --- /dev/null +++ b/MAC关联OLT流程改进建议.md @@ -0,0 +1,434 @@ +# MAC关联OLT流程改进建议 + +## 文档信息 +- **分析时间**: 2026年4月2日 +- **分析对象**: `MAC关联OLT流程.md` +- **分析重点**: 与系统设计匹配度、40个OLT设备扩展性 +- **目标读者**: 后端开发同事 + +## 一、当前流程分析 + +### 1.1 文档描述流程 +1. 远程连接OLT设备 → 执行`dis onu slot 1`命令 +2. 解析返回的ONU设备信息 +3. 提取MAC地址、距离、端口、状态信息 +4. 将信息展示给前端并关联到OLT + +### 1.2 系统实现现状 +**匹配度评分**: 70% + +| 功能点 | 文档描述 | 系统实现 | 状态 | +|--------|----------|----------|------| +| SSH连接 | 远程连接OLT | `SSHService.connect()` | ✅ 已实现 | +| 命令执行 | `dis onu slot 1` | `execute_command()` | ✅ 已实现 | +| 分页处理 | "---- More ----" | 空格键继续 | ✅ 已实现 | +| MAC地址提取 | 1484-778f-cb20格式 | `parse_onu_status()` | ✅ 已实现 | +| 状态解析 | Up/Offline | 解析状态字段 | ✅ 已实现 | +| 距离提取 | 12384米 | ❌ 未实现 | ❌ 缺失 | +| 端口解析 | Onu1/0/2:1 | ❌ 未完整解析 | ⚠️ 需改进 | +| 数据关联 | 关联到OLT | `olt_id`外键 | ✅ 已实现 | + +### 1.3 主要问题 +1. **数据提取不完整**: 当前只解析状态,未提取距离和详细端口信息 +2. **数据库字段缺失**: 缺少`distance`字段存储距离信息 +3. **端口解析不足**: 需要从"Onu1/0/2:1"解析为`slot_number`和`port_number` + +## 二、40个OLT设备扩展性挑战 + +### 2.1 当前方案问题(串行执行) +| 问题 | 影响程度 | 说明 | +|------|----------|------| +| 执行效率低 | 高 | 40个OLT串行检查,预计20-40分钟 | +| SSH连接开销 | 中 | 每个OLT独立连接,建立/断开开销大 | +| 网络延迟累积 | 中 | 网络不佳的OLT会阻塞整个流程 | +| 资源占用 | 中 | 可能同时维护多个SSH连接 | +| 错误传播 | 高 | 一个OLT失败可能影响后续检查 | + +### 2.2 性能预估 +| 方案 | 40个OLT检查时间 | 资源占用 | 实现复杂度 | 推荐度 | +|------|----------------|----------|------------|--------| +| 当前串行 | ~20-40分钟 | 低 | 低 | ⭐ | +| 并行10并发 | ~2-4分钟 | 中 | 中 | ⭐⭐⭐⭐⭐ | +| 并行20并发 | ~1-2分钟 | 高 | 高 | ⭐⭐⭐ | + +## 三、改进方案建议 + +### 3.1 方案一:并行处理 + 连接池(推荐) + +#### 3.1.1 架构设计 +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Celery主任务 │───▶│ OLT检查任务分发 │───▶│ 并行执行检查 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ SSH连接池管理 │◄───│ 连接复用机制 │◄───│ 并发控制(10) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +#### 3.1.2 核心组件 +1. **任务分发器**: 将40个OLT检查拆分为独立Celery任务 +2. **连接池管理器**: 复用SSH连接,减少建立/断开开销 +3. **并发控制器**: 限制最大并发数(建议10个) +4. **结果聚合器**: 收集各OLT检查结果,统一存储 + +#### 3.1.3 代码实现建议 + +**A. SSH连接池实现** +```python +# app/services/ssh_pool.py +import paramiko +from typing import Dict, Optional +from threading import Lock + +class SSHConnectionPool: + """SSH连接池,支持连接复用""" + + def __init__(self, max_connections: int = 10): + self.pool: Dict[str, paramiko.SSHClient] = {} + self.lock = Lock() + self.max_connections = max_connections + + def get_connection(self, host: str, username: str, password: str) -> paramiko.SSHClient: + """获取或创建SSH连接""" + key = f"{host}:{username}" + + with self.lock: + if key in self.pool: + client = self.pool[key] + # 检查连接是否仍然有效 + try: + transport = client.get_transport() + if transport and transport.is_active(): + return client + except: + pass + + # 创建新连接 + if len(self.pool) >= self.max_connections: + # 清理最久未使用的连接 + self._cleanup_oldest() + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(hostname=host, username=username, password=password, timeout=30) + + self.pool[key] = client + return client + + def _cleanup_oldest(self): + """清理最久未使用的连接""" + # 简化实现:清理第一个连接 + if self.pool: + key = next(iter(self.pool)) + try: + self.pool[key].close() + except: + pass + del self.pool[key] +``` + +**B. 增强的解析器** +```python +# app/services/onu_parser.py +import re +from typing import Dict, List, Optional +from dataclasses import dataclass + +@dataclass +class ONUInfo: + """ONU设备完整信息""" + mac_address: str + status: str # online/offline + distance_m: Optional[int] = None # 距离(米) + slot_number: Optional[int] = None # 插槽号 + port_number: Optional[int] = None # 端口号 + loid: Optional[str] = None # LOID + model: Optional[str] = None # 设备型号 + +class ONUParser: + """增强的ONU信息解析器""" + + @staticmethod + def parse_output(output: str) -> List[ONUInfo]: + """解析OLT命令输出""" + devices = [] + lines = output.split('\n') + + current_slot = None + + for line in lines: + # 检测新的插槽区域 + slot_match = re.search(r'Olt(\d+)/(\d+)/(\d+)', line) + if slot_match: + current_slot = int(slot_match.group(1)) + continue + + # 跳过表头行 + if 'MAC' in line and 'LOID' in line: + continue + + # 解析设备行 + device = ONUParser._parse_device_line(line, current_slot) + if device: + devices.append(device) + + return devices + + @staticmethod + def _parse_device_line(line: str, slot: Optional[int]) -> Optional[ONUInfo]: + """解析单行设备信息""" + # 匹配MAC地址 + mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE) + if not mac_match: + return None + + mac = mac_match.group(1).lower() + + # 提取状态 + status = 'offline' + if 'Up' in line: + status = 'online' + + # 提取距离 + distance = None + dist_match = re.search(r'(\d+)\s*Dist\(M\)', line) + if dist_match: + distance = int(dist_match.group(1)) + + # 提取端口信息 + slot_num, port_num = None, None + port_match = re.search(r'Onu(\d+)/(\d+)/(\d+):(\d+)', line) + if port_match: + slot_num = int(port_match.group(1)) + port_num = int(port_match.group(4)) + + # 提取LOID + loid = None + loid_match = re.search(r'\s+(\d+)\s+', line) + if loid_match: + loid = loid_match.group(1) + + return ONUInfo( + mac_address=mac, + status=status, + distance_m=distance, + slot_number=slot_num or slot, + port_number=port_num, + loid=loid + ) +``` + +**C. 并行检查任务** +```python +# app/tasks/parallel_check_tasks.py +from celery import group +from app.tasks.check_tasks import check_single_olt +from app.models.device import OLTDevice +from sqlalchemy.orm import Session + +def check_all_olts_parallel(db: Session, max_concurrent: int = 10): + """并行检查所有OLT设备""" + # 获取所有OLT设备 + olts = db.query(OLTDevice).all() + + if not olts: + return {"message": "没有可检查的OLT设备"} + + # 创建任务组 + tasks = [] + for olt in olts: + tasks.append(check_single_olt.s(olt.id)) + + # 分批执行,控制并发数 + results = [] + for i in range(0, len(tasks), max_concurrent): + batch = tasks[i:i + max_concurrent] + job = group(batch) + result = job.apply_async() + results.extend(result.get()) # 等待批次完成 + + return { + "total_olts": len(olts), + "completed": len([r for r in results if r.get("success")]), + "failed": len([r for r in results if not r.get("success")]), + "results": results + } +``` + +### 3.2 方案二:数据库优化 + +#### 3.2.1 新增字段建议 +```sql +-- 在onu_devices表中新增字段 +ALTER TABLE onu_devices ADD COLUMN IF NOT EXISTS distance_m INTEGER; +ALTER TABLE onu_devices ADD COLUMN IF NOT EXISTS loid VARCHAR(50); +ALTER TABLE onu_devices ADD COLUMN IF NOT EXISTS model VARCHAR(100); +ALTER TABLE onu_devices ADD COLUMN IF NOT EXISTS last_seen TIMESTAMP; + +-- 在olt_devices表中新增字段 +ALTER TABLE olt_devices ADD COLUMN IF NOT EXISTS max_concurrent INTEGER DEFAULT 5; +ALTER TABLE olt_devices ADD COLUMN IF NOT EXISTS check_timeout INTEGER DEFAULT 60; +``` + +#### 3.2.2 数据模型更新 +```python +# app/models/device.py - 更新ONUDevice模型 +class ONUDevice(Base): + __tablename__ = "onu_devices" + + # 现有字段... + distance_m = Column(Integer) # 新增:距离(米) + loid = Column(String(50)) # 新增:LOID + model = Column(String(100)) # 新增:设备型号 + last_seen = Column(TIMESTAMP) # 新增:最后在线时间 + + # 索引优化 + __table_args__ = ( + Index('idx_mac_olt', 'mac_address', 'olt_id'), + Index('idx_status_checked', 'status', 'last_seen'), + ) +``` + +### 3.3 方案三:监控与告警增强 + +#### 3.3.1 检查任务监控 +```python +# app/services/monitor_service.py +class CheckMonitor: + """检查任务监控服务""" + + @staticmethod + def get_check_stats(db: Session): + """获取检查统计信息""" + stats = { + "total_olts": db.query(OLTDevice).count(), + "total_onus": db.query(ONUDevice).count(), + "online_onus": db.query(ONUDevice) + .join(DeviceStatusHistory) + .filter(DeviceStatusHistory.status == 'online') + .distinct().count(), + "recent_checks": db.query(DeviceStatusHistory) + .order_by(DeviceStatusHistory.checked_at.desc()) + .limit(10).all() + } + return stats + + @staticmethod + def check_olt_health(olt: OLTDevice) -> Dict: + """检查OLT健康状态""" + # 测试连接 + ssh = SSHService(olt.ip_address, olt.username, olt.password) + try: + start_time = time.time() + ssh.connect() + connect_time = time.time() - start_time + + # 执行简单命令测试 + output = ssh.execute_command("display version") + + return { + "olt_id": olt.id, + "status": "healthy", + "connect_time": connect_time, + "response_time": len(output) / 1024, # KB/s + "timestamp": datetime.utcnow() + } + except Exception as e: + return { + "olt_id": olt.id, + "status": "unhealthy", + "error": str(e), + "timestamp": datetime.utcnow() + } + finally: + ssh.close() +``` + +## 四、实施计划建议 + +### 4.1 第一阶段:基础改进(1-2天) +1. 增强ONU信息解析器,提取完整字段 +2. 更新数据库模型,新增必要字段 +3. 修改现有检查逻辑,存储完整信息 + +### 4.2 第二阶段:并行化改造(3-5天) +1. 实现SSH连接池 +2. 改造Celery任务为并行执行 +3. 添加并发控制和错误处理 +4. 测试10个OLT并发场景 + +### 4.3 第三阶段:优化与监控(2-3天) +1. 添加检查任务监控 +2. 实现健康检查机制 +3. 优化数据库查询性能 +4. 添加告警通知功能 + +### 4.4 第四阶段:压力测试(1-2天) +1. 模拟40个OLT并发检查 +2. 测试网络异常情况 +3. 验证系统稳定性 +4. 性能调优 + +## 五、风险与应对 + +### 5.1 技术风险 +| 风险 | 可能性 | 影响 | 应对措施 | +|------|--------|------|----------| +| OLT连接超时 | 中 | 高 | 实现超时重试机制 | +| 并发连接过多 | 低 | 中 | 限制最大并发数 | +| 内存泄漏 | 低 | 高 | 添加连接池清理机制 | +| 数据库锁竞争 | 中 | 中 | 优化事务处理,使用批量操作 | + +### 5.2 业务风险 +| 风险 | 可能性 | 影响 | 应对措施 | +|------|--------|------|----------| +| 检查结果不一致 | 低 | 中 | 添加数据校验机制 | +| 检查时间过长 | 中 | 高 | 实现增量检查,优化查询 | +| 用户等待时间 | 中 | 中 | 提供进度查询接口 | + +## 六、预期收益 + +### 6.1 性能提升 +- **检查时间**: 从20-40分钟缩短到2-4分钟(10倍提升) +- **资源利用率**: SSH连接复用减少50%连接开销 +- **系统稳定性**: 错误隔离避免单点故障影响全局 + +### 6.2 功能增强 +- **数据完整性**: 完整记录距离、端口、LOID等信息 +- **监控能力**: 实时监控检查任务状态和OLT健康度 +- **扩展性**: 支持未来扩展到更多OLT设备 + +### 6.3 运维改善 +- **故障诊断**: 详细的检查日志和错误信息 +- **性能分析**: 检查任务性能统计和趋势分析 +- **容量规划**: 基于实际负载的资源规划依据 + +## 七、后续建议 + +### 7.1 短期建议(1个月内) +1. 实现基础并行检查功能 +2. 完成数据库字段扩展 +3. 添加基础监控告警 + +### 7.2 中期建议(1-3个月) +1. 实现智能调度(基于OLT负载和网络状况) +2. 添加检查结果分析和报表 +3. 集成自动化运维工具 + +### 7.3 长期建议(3-6个月) +1. 机器学习预测设备故障 +2. 自动化故障恢复机制 +3. 多数据中心部署支持 + +--- + +**文档维护**: +- 本文档应随系统改进同步更新 +- 重大架构变更需更新本文档 +- 实际实施中的调整应记录在案 + +**联系方式**: +- 如有疑问或建议,请通过项目沟通渠道反馈 +- 实施过程中遇到问题及时记录并分享解决方案 \ No newline at end of file diff --git a/OLT输出.md b/OLT输出.md new file mode 100644 index 0000000..d3d60f6 --- /dev/null +++ b/OLT输出.md @@ -0,0 +1,32 @@ +<16OLT>dis onu slot 1 +---------------------------------- Olt1/0/1 --------------------------------- +MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging +1484-7790-5200 12 <1000 Onu1/0/1:1 WA6520H-EGPON/A 106/ Up N/A +1484-7790-4e20 13 <1000 Onu1/0/1:2 WA6520H-EGPON/A 106/ Up N/A +1484-7790-3900 N/A N/A Onu1/0/1:3 N/A N/A Offline N/A +---------------------------------- Olt1/0/2 --------------------------------- +MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging +34dc-99c8-8320 5 <1000 Onu1/0/2:1 WA6520H-EGPON/A 106/ Up N/A +1484-778f-7b80 N/A N/A Onu1/0/2:2 N/A N/A Offline N/A +---------------------------------- Olt1/0/3 --------------------------------- +MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging +1484-778f-ba60 3 <1000 Onu1/0/3:1 WA6520H-EGPON/A 106/ Up N/A +1484-778f-5480 2 <1000 Onu1/0/3:2 WA6520H-EGPON/A 106/ Up N/A +1484-778f-eb80 N/A N/A Onu1/0/3:3 N/A N/A Offline N/A +1484-778f-b280 N/A N/A Onu1/0/3:4 N/A N/A Offline N/A +1484-778f-00c0 N/A N/A Onu1/0/3:5 N/A N/A Offline N/A +---------------------------------- Olt1/0/4 --------------------------------- +MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging +1484-778f-8960 13 <1000 Onu1/0/4:1 WA6520H-EGPON/A 106/ Up N/A +1484-778f-e9e0 8 <1000 Onu1/0/4:2 WA6520H-EGPON/A 106/ Up N/A +---- More ---- + + +OLT可能会遇到多个MAC地址在不同端口的情况,这个情况是因为之前这个MAC已经在某个端口上,但是后来因为其他原因更换了端口,但是旧的端口上还有它的记录。如果是这种情况,需要把这个MAC记录下来,在OLT管理页面中新增一个“重复MAC地址”的按钮,后续可以通过命令删除这些旧的MAC地址(后期再实现)。 + +再帮我优化一下设备列表页面: +1. “状态”新增“未知”选项 +2. 点击“全部更新”按钮后,执行所有OLT扫描功能,并更新设备状态(不需要入库,只需要更新MAC的在线情况和距离)。 + +再帮我优化一下OLT管理功能: +1. \ No newline at end of file diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..cf04888 --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -0,0 +1,580 @@ +# 项目结构详细说明 + +## 后端目录结构 (backend/) + +``` +backend/ +├── app/ # 主应用目录 +│ ├── __init__.py +│ ├── main.py # FastAPI应用入口 +│ ├── api/ # API路由 +│ │ ├── __init__.py +│ │ ├── v1/ # API版本1 +│ │ │ ├── __init__.py +│ │ │ ├── auth.py # 认证相关API +│ │ │ ├── devices.py # 设备管理API +│ │ │ ├── check.py # 状态检查API +│ │ │ ├── stats.py # 统计API +│ │ │ ├── users.py # 用户管理API +│ │ │ ├── import.py # 数据导入API +│ │ │ └── system.py # 系统设置API +│ │ └── dependencies.py # 依赖注入 +│ ├── core/ # 核心配置 +│ │ ├── __init__.py +│ │ ├── config.py # 应用配置 +│ │ ├── security.py # 安全相关 +│ │ ├── database.py # 数据库配置 +│ │ ├── casdoor.py # Casdoor配置 +│ │ └── celery_app.py # Celery配置 +│ ├── models/ # SQLAlchemy模型 +│ │ ├── __init__.py +│ │ ├── base.py # 基础模型 +│ │ ├── device.py # 设备相关模型 +│ │ ├── user.py # 用户相关模型 +│ │ ├── permission.py # 权限相关模型 +│ │ └── history.py # 历史记录模型 +│ ├── schemas/ # Pydantic模式 +│ │ ├── __init__.py +│ │ ├── device.py # 设备模式 +│ │ ├── user.py # 用户模式 +│ │ ├── auth.py # 认证模式 +│ │ └── common.py # 通用模式 +│ ├── services/ # 业务逻辑服务 +│ │ ├── __init__.py +│ │ ├── device_service.py # 设备服务 +│ │ ├── ssh_service.py # SSH连接服务 +│ │ ├── check_service.py # 状态检查服务 +│ │ ├── import_service.py # 数据导入服务 +│ │ ├── user_service.py # 用户服务 +│ │ ├── permission_service.py # 权限服务 +│ │ └── casdoor_service.py # Casdoor服务 +│ ├── tasks/ # Celery任务 +│ │ ├── __init__.py +│ │ ├── check_tasks.py # 状态检查任务 +│ │ ├── import_tasks.py # 数据导入任务 +│ │ └── notification_tasks.py # 通知任务 +│ ├── utils/ # 工具函数 +│ │ ├── __init__.py +│ │ ├── excel_parser.py # Excel解析工具 +│ │ ├── ssh_utils.py # SSH工具函数 +│ │ ├── validators.py # 数据验证 +│ │ ├── encryption.py # 加密工具 +│ │ └── logger.py # 日志配置 +│ └── middleware/ # 中间件 +│ ├── __init__.py +│ ├── auth_middleware.py # 认证中间件 +│ ├── permission_middleware.py # 权限中间件 +│ └── logging_middleware.py # 日志中间件 +├── alembic/ # 数据库迁移 +│ ├── versions/ # 迁移版本 +│ ├── env.py +│ └── alembic.ini +├── tests/ # 测试代码 +│ ├── __init__.py +│ ├── conftest.py # 测试配置 +│ ├── test_api/ # API测试 +│ ├── test_services/ # 服务测试 +│ └── test_utils/ # 工具测试 +├── scripts/ # 脚本文件 +│ ├── init_db.py # 数据库初始化 +│ ├── create_admin.py # 创建管理员 +│ └── backup_data.py # 数据备份 +├── requirements/ # 依赖管理 +│ ├── base.txt # 基础依赖 +│ ├── dev.txt # 开发依赖 +│ └── prod.txt # 生产依赖 +├── logs/ # 日志目录 +├── static/ # 静态文件 +├── Dockerfile # Docker构建文件 +├── docker-entrypoint.sh # Docker入口脚本 +├── requirements.txt # 依赖文件 +├── .env.example # 环境变量示例 +└── pyproject.toml # Python项目配置 +``` + +## 前端目录结构 (frontend/) + +``` +frontend/ +├── public/ # 静态资源 +│ ├── index.html # 主HTML文件 +│ ├── favicon.ico # 网站图标 +│ └── robots.txt # 搜索引擎配置 +├── src/ # 源代码 +│ ├── main.js # 应用入口 +│ ├── App.vue # 根组件 +│ ├── api/ # API调用 +│ │ ├── index.js # API配置 +│ │ ├── auth.js # 认证API +│ │ ├── device.js # 设备API +│ │ ├── check.js # 状态检查API +│ │ ├── user.js # 用户API +│ │ └── import.js # 数据导入API +│ ├── assets/ # 资源文件 +│ │ ├── css/ # 样式文件 +│ │ │ ├── main.css # 主样式 +│ │ │ ├── variables.css # CSS变量 +│ │ │ └── components.css # 组件样式 +│ │ └── images/ # 图片资源 +│ ├── components/ # 公共组件 +│ │ ├── common/ # 通用组件 +│ │ │ ├── Layout/ # 布局组件 +│ │ │ │ ├── AppLayout.vue +│ │ │ │ ├── Header.vue +│ │ │ │ ├── Sidebar.vue +│ │ │ │ └── Footer.vue +│ │ │ ├── Table/ # 表格组件 +│ │ │ │ ├── DataTable.vue +│ │ │ │ └── Pagination.vue +│ │ │ ├── Form/ # 表单组件 +│ │ │ │ ├── SearchForm.vue +│ │ │ │ └── FilterForm.vue +│ │ │ ├── Chart/ # 图表组件 +│ │ │ │ ├── StatusChart.vue +│ │ │ │ └── TrendChart.vue +│ │ │ └── Dialog/ # 对话框组件 +│ │ │ ├── ConfirmDialog.vue +│ │ │ └── ImportDialog.vue +│ │ └── business/ # 业务组件 +│ │ ├── Device/ # 设备相关组件 +│ │ ├── User/ # 用户相关组件 +│ │ └── System/ # 系统相关组件 +│ ├── router/ # 路由配置 +│ │ ├── index.js # 路由主文件 +│ │ ├── routes.js # 路由定义 +│ │ └── guards.js # 路由守卫 +│ ├── stores/ # 状态管理 (Pinia) +│ │ ├── index.js # Store配置 +│ │ ├── auth.js # 认证状态 +│ │ ├── device.js # 设备状态 +│ │ ├── user.js # 用户状态 +│ │ └── system.js # 系统状态 +│ ├── views/ # 页面组件 +│ │ ├── Auth/ # 认证页面 +│ │ │ ├── Login.vue # 登录页面 +│ │ │ └── Callback.vue # 回调页面 +│ │ ├── Dashboard/ # 仪表板 +│ │ │ └── index.vue # 仪表板主页 +│ │ ├── Device/ # 设备管理 +│ │ │ ├── List.vue # 设备列表 +│ │ │ ├── Detail.vue # 设备详情 +│ │ │ └── Import.vue # 数据导入 +│ │ ├── Check/ # 状态检查 +│ │ │ ├── Status.vue # 状态页面 +│ │ │ └── History.vue # 检查历史 +│ │ ├── User/ # 用户管理 +│ │ │ ├── List.vue # 用户列表 +│ │ │ ├── Profile.vue # 用户资料 +│ │ │ └── Permission.vue # 权限管理 +│ │ ├── System/ # 系统设置 +│ │ │ ├── OltConfig.vue # OLT配置 +│ │ │ ├── TaskConfig.vue # 任务配置 +│ │ │ └── Audit.vue # 审核中心 +│ │ └── Error/ # 错误页面 +│ │ ├── 404.vue # 404页面 +│ │ └── 500.vue # 500页面 +│ ├── utils/ # 工具函数 +│ │ ├── auth.js # 认证工具 +│ │ ├── request.js # 请求工具 +│ │ ├── permission.js # 权限工具 +│ │ ├── format.js # 格式化工具 +│ │ ├── validate.js # 验证工具 +│ │ └── storage.js # 存储工具 +│ ├── directives/ # 自定义指令 +│ │ ├── permission.js # 权限指令 +│ │ └── loading.js # 加载指令 +│ └── plugins/ # 插件 +│ ├── element-plus.js # Element Plus插件 +│ └── echarts.js # ECharts插件 +├── tests/ # 测试文件 +│ ├── unit/ # 单元测试 +│ └── e2e/ # 端到端测试 +├── .env.development # 开发环境变量 +├── .env.production # 生产环境变量 +├── .env.example # 环境变量示例 +├── package.json # 项目配置 +├── package-lock.json # 依赖锁文件 +├── vite.config.js # Vite配置 +├── index.html # HTML入口 +└── Dockerfile # Docker构建文件 +``` + +## 部署目录结构 (deploy/) + +``` +deploy/ +├── docker-compose.yml # Docker Compose配置 +├── docker-compose.dev.yml # 开发环境配置 +├── docker-compose.prod.yml # 生产环境配置 +├── nginx/ # Nginx配置 +│ ├── nginx.conf # 主配置文件 +│ ├── conf.d/ # 站点配置 +│ │ └── h3c-onu-ms.conf # 应用配置 +│ └── ssl/ # SSL证书 +├── scripts/ # 部署脚本 +│ ├── deploy.sh # 部署脚本 +│ ├── backup.sh # 备份脚本 +│ ├── restore.sh # 恢复脚本 +│ └── monitor.sh # 监控脚本 +├── config/ # 配置文件 +│ ├── backend.env # 后端环境变量 +│ ├── frontend.env # 前端环境变量 +│ └── celery.env # Celery环境变量 +└── .env.example # 环境变量示例 +``` + +## 文档目录结构 (docs/) + +``` +docs/ +├── api/ # API文档 +│ ├── overview.md # API概述 +│ ├── auth.md # 认证API文档 +│ ├── device.md # 设备API文档 +│ ├── check.md # 状态检查API文档 +│ └── user.md # 用户API文档 +├── deployment/ # 部署文档 +│ ├── requirements.md # 环境要求 +│ ├── installation.md # 安装指南 +│ ├── configuration.md # 配置说明 +│ ├── docker.md # Docker部署 +│ └── troubleshooting.md # 故障排除 +├── user-guide/ # 用户指南 +│ ├── getting-started.md # 快速开始 +│ ├── device-management.md # 设备管理指南 +│ ├── data-import.md # 数据导入指南 +│ ├── permission-guide.md # 权限管理指南 +│ └── faq.md # 常见问题 +├── development/ # 开发文档 +│ ├── setup.md # 开发环境搭建 +│ ├── architecture.md # 架构说明 +│ ├── coding-standards.md # 编码规范 +│ ├── testing.md # 测试指南 +│ └── contributing.md # 贡献指南 +└── images/ # 文档图片 +``` + +## 脚本目录结构 (scripts/) + +``` +scripts/ +├── database/ # 数据库脚本 +│ ├── init.sql # 初始化SQL +│ ├── backup.sh # 数据库备份 +│ ├── restore.sh # 数据库恢复 +│ └── migrate.sh # 迁移脚本 +├── monitoring/ # 监控脚本 +│ ├── health-check.sh # 健康检查 +│ ├── log-rotate.sh # 日志轮转 +│ └── alert.sh # 告警脚本 +├── deployment/ # 部署脚本 +│ ├── build.sh # 构建脚本 +│ ├── deploy.sh # 部署脚本 +│ └── rollback.sh # 回滚脚本 +└── utils/ # 工具脚本 + ├── cleanup.sh # 清理脚本 + ├── generate-cert.sh # 证书生成 + └── update-config.sh # 配置更新 +``` + +## 环境变量说明 + +### 后端环境变量 (.env) +```bash +# 应用配置 +APP_NAME=H3C-ONU-MS +APP_ENV=production +DEBUG=false +SECRET_KEY=your-secret-key-here + +# 数据库配置 +DATABASE_URL=postgresql://user:password@host:5432/dbname +DATABASE_POOL_SIZE=20 +DATABASE_MAX_OVERFLOW=40 + +# Redis配置 +REDIS_URL=redis://host:6379/0 +REDIS_POOL_SIZE=10 + +# Casdoor配置 +CASDOOR_ENDPOINT=https://casdoor.example.com +CASDOOR_CLIENT_ID=your_client_id +CASDOOR_CLIENT_SECRET=your_client_secret +CASDOOR_CERTIFICATE=your_certificate +CASDOOR_ORG_NAME=your_org +CASDOOR_APP_NAME=h3c-onu-ms + +# SSH配置 +SSH_TIMEOUT=30 +SSH_MAX_CONNECTIONS=10 +SSH_RETRY_COUNT=3 + +# 任务配置 +CHECK_INTERVAL=1800 # 30分钟(秒) +MANUAL_COOLDOWN=300 # 5分钟(秒) +HISTORY_RETENTION=90 # 90天 + +# 日志配置 +LOG_LEVEL=INFO +LOG_FILE=/app/logs/app.log +LOG_ROTATION=10MB +LOG_RETENTION=30 +``` + +### 前端环境变量 (.env) +```bash +# 应用配置 +VITE_APP_TITLE=H3C ONU设备管理系统 +VITE_APP_VERSION=1.0.0 + +# API配置 +VITE_API_BASE_URL=http://localhost:8000 +VITE_API_TIMEOUT=30000 + +# Casdoor配置 +VITE_CASDOOR_ENDPOINT=https://casdoor.example.com +VITE_CASDOOR_CLIENT_ID=your_client_id +VITE_CASDOOR_ORG_NAME=your_org +VITE_CASDOOR_APP_NAME=h3c-onu-ms + +# 功能开关 +VITE_ENABLE_SSO=true +VITE_ENABLE_WEBSOCKET=true +VITE_ENABLE_ANALYTICS=false +``` + +## 开发工作流 + +### 1. 环境准备 +```bash +# 克隆项目 +git clone +cd H3ConuMS2 + +# 后端环境 +cd backend +python -m venv venv +source venv/bin/activate +pip install -r requirements/dev.txt + +# 前端环境 +cd ../frontend +npm install +``` + +### 2. 数据库初始化 +```bash +# 创建数据库 +createdb h3c_onu_ms + +# 运行迁移 +cd backend +alembic upgrade head + +# 初始化数据 +python scripts/init_data.py +``` + +### 3. 启动开发服务 +```bash +# 启动后端 +cd backend +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# 启动前端(新终端) +cd frontend +npm run dev + +# 启动Celery Worker(新终端) +cd backend +celery -A app.core.celery_app worker --loglevel=info + +# 启动Celery Beat(新终端) +cd backend +celery -A app.core.celery_app beat --loglevel=info +``` + +### 4. 访问应用 +- 前端开发服务器: http://localhost:5173 +- 后端API服务器: http://localhost:8000 +- API文档: http://localhost:8000/docs +- 管理界面: http://localhost:8000/admin + +## 代码规范 + +### Python代码规范 +- 遵循PEP 8规范 +- 使用Black进行代码格式化 +- 使用isort进行导入排序 +- 使用Flake8进行代码检查 +- 使用mypy进行类型检查 + +### Vue代码规范 +- 使用ESLint + Prettier +- 遵循Vue官方风格指南 +- 使用TypeScript进行类型检查 +- 组件使用PascalCase命名 +- 单文件组件结构规范 + +### Git提交规范 +- 使用Conventional Commits规范 +- 提交信息格式: `(): ` +- 类型: feat, fix, docs, style, refactor, test, chore +- 示例: `feat(device): 添加设备导入功能` + +## 测试策略 + +### 单元测试 +```bash +# 后端单元测试 +cd backend +pytest tests/unit/ + +# 前端单元测试 +cd frontend +npm run test:unit +``` + +### 集成测试 +```bash +# 后端集成测试 +cd backend +pytest tests/integration/ + +# API测试 +pytest tests/api/ +``` + +### 端到端测试 +```bash +# 前端E2E测试 +cd frontend +npm run test:e2e +``` + +## 部署流程 + +### 开发环境部署 +```bash +cd deploy +docker-compose -f docker-compose.dev.yml up -d +``` + +### 生产环境部署 +```bash +# 构建镜像 +docker-compose -f docker-compose.prod.yml build + +# 启动服务 +docker-compose -f docker-compose.prod.yml up -d + +# 查看日志 +docker-compose -f docker-compose.prod.yml logs -f +``` + +### 持续集成/持续部署 +1. 代码推送到Git仓库 +2. 自动运行测试 +3. 构建Docker镜像 +4. 推送到镜像仓库 +5. 部署到服务器 +6. 运行健康检查 + +## 监控和告警 + +### 应用监控 +- 健康检查端点: `/health` +- 性能指标: `/metrics` (Prometheus格式) +- 请求统计: 中间件记录 +- 错误跟踪: Sentry集成 + +### 系统监控 +- 服务器资源使用率 +- 数据库连接池状态 +- Redis内存使用情况 +- 网络连接状态 + +### 告警规则 +- 设备离线率超过阈值 +- 系统资源使用率过高 +- 服务不可用 +- 安全相关事件 + +## 安全考虑 + +### 数据安全 +- SSH密码加密存储 +- 数据库连接加密 +- 敏感信息环境变量管理 +- 定期数据备份 + +### 应用安全 +- CSRF保护 +- XSS防护 +- SQL注入防护 +- 速率限制 +- 输入验证 + +### 访问安全 +- 基于角色的访问控制 +- 会话管理 +- 双因素认证支持 +- 登录尝试限制 + +## 性能优化 + +### 数据库优化 +- 合理使用索引 +- 查询优化 +- 连接池配置 +- 定期清理历史数据 + +### 缓存策略 +- Redis缓存热点数据 +- 浏览器缓存静态资源 +- CDN加速前端资源 + +### 异步处理 +- Celery处理耗时任务 +- WebSocket实时更新 +- 批量操作优化 + +## 扩展性设计 + +### 水平扩展 +- 无状态API服务 +- 数据库读写分离 +- Redis集群支持 +- 负载均衡配置 + +### 功能扩展 +- 插件化架构设计 +- 模块化代码组织 +- 配置驱动功能开关 +- API版本管理 + +## 维护计划 + +### 日常维护 +- 日志监控和分析 +- 数据库备份验证 +- 系统更新和补丁 +- 性能监控和优化 + +### 定期维护 +- 每月安全审计 +- 每季度性能评估 +- 每年架构评审 +- 数据归档和清理 + +## 文档更新 +- API变更及时更新文档 +- 部署流程变更记录 +- 故障处理经验总结 +- 用户反馈整理 + +--- + +**文档版本**: v1.0 +**最后更新**: 2026年4月1日 +**维护人员**: 系统开发团队 \ No newline at end of file diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..474e829 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,37 @@ +# H3C ONU设备管理系统 - 快速开始 + +## 项目状态 +✅ 第一阶段:基础框架已完成 + +## 已完成功能 +- ✅ 后端 FastAPI 框架 +- ✅ 前端 Vue 3 框架 +- ✅ SSH 连接和 ONU 状态解析 +- ✅ 数据库模型设计 +- ✅ Docker 配置 + +## 快速启动 + +### 1. 配置环境变量 +```bash +cp backend/.env.example backend/.env +# 编辑 backend/.env 配置数据库等信息 +``` + +### 2. 启动后端 +```bash +./start-backend.sh +``` + +### 3. 启动前端 +```bash +./start-frontend.sh +``` + +### 4. 访问系统 +- 前端:http://localhost:5173 +- 后端 API:http://localhost:8000 +- API 文档:http://localhost:8000/docs + +## 下一步开发 +参考 `开发计划.md` 继续第一阶段第2周任务 diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf0622f --- /dev/null +++ b/README.md @@ -0,0 +1,282 @@ +# H3C ONU设备管理系统 + +基于Python FastAPI + Vue 3的H3C OLT设备监控管理系统,用于监控和管理4000+ ONU设备的在线状态。 + +## 功能特性 + +### 核心功能 +- ✅ H3C OLT设备SSH连接和状态查询 +- ✅ ONU设备在线状态监控(4000+设备) +- ✅ Excel数据导入和批量管理 +- ✅ 定时自动状态检查(每30分钟) +- ✅ 手动刷新功能(5分钟冷却限制) +- ✅ 90天状态历史记录 + +### 权限管理 +- ✅ Casdoor统一认证集成 +- ✅ 基于RBAC的多级权限控制 +- ✅ 区域/学校级别的数据隔离 +- ✅ 设备信息变更审核流程 +- ✅ 用户角色和权限管理 + +### 界面功能 +- ✅ 响应式Web界面 +- ✅ 设备列表表格展示 +- ✅ 高级筛选和搜索 +- ✅ 实时统计图表 +- ✅ 设备详情和历史趋势 +- ✅ 数据导入和管理界面 + +## 技术栈 + +### 后端 +- **Python FastAPI** - 高性能异步Web框架 +- **PostgreSQL** - 关系型数据库 +- **Paramiko** - SSH连接库 +- **Celery + Redis** - 异步任务队列 +- **Pandas** - Excel数据处理 +- **SQLAlchemy** - ORM框架 +- **Casdoor SDK** - 统一认证 + +### 前端 +- **Vue 3 + Composition API** - 前端框架 +- **Element Plus** - UI组件库 +- **Axios** - HTTP客户端 +- **Vue Router** - 路由管理 +- **Pinia** - 状态管理 +- **ECharts** - 图表库 + +### 部署 +- **Docker + Docker Compose** - 容器化部署 +- **Nginx** - 反向代理 +- **外部PostgreSQL/Redis** - 数据库服务 + +## 快速开始 + +### 环境要求 +- Docker 20.10+ +- Docker Compose 2.0+ +- 外部PostgreSQL数据库 +- 外部Redis服务 +- Casdoor认证服务器 + +### 配置步骤 + +1. **克隆项目** + ```bash + git clone + cd H3ConuMS2 + ``` + +2. **配置环境变量** + ```bash + cp deploy/.env.example deploy/.env + # 编辑deploy/.env文件,配置数据库和Casdoor信息 + ``` + +3. **启动服务** + ```bash + cd deploy + docker-compose up -d + ``` + +4. **访问系统** + - 前端界面: http://localhost:8080 + - API文档: http://localhost:8000/docs + - 健康检查: http://localhost:8000/health + +## 项目结构 + +``` +H3ConuMS2/ +├── backend/ # Python后端代码 +│ ├── app/ +│ │ ├── api/ # API路由 +│ │ ├── core/ # 核心配置 +│ │ ├── models/ # 数据模型 +│ │ ├── schemas/ # Pydantic模式 +│ │ ├── services/ # 业务逻辑 +│ │ ├── tasks/ # Celery任务 +│ │ └── utils/ # 工具函数 +│ ├── alembic/ # 数据库迁移 +│ ├── tests/ # 测试代码 +│ ├── requirements.txt # Python依赖 +│ └── Dockerfile # 后端Dockerfile +├── frontend/ # Vue前端代码 +│ ├── public/ # 静态资源 +│ ├── src/ +│ │ ├── api/ # API调用 +│ │ ├── assets/ # 资源文件 +│ │ ├── components/ # 组件 +│ │ ├── router/ # 路由配置 +│ │ ├── stores/ # 状态管理 +│ │ ├── views/ # 页面组件 +│ │ └── utils/ # 工具函数 +│ ├── package.json # 前端依赖 +│ └── Dockerfile # 前端Dockerfile +├── deploy/ # 部署配置 +│ ├── docker-compose.yml # Docker编排 +│ ├── nginx.conf # Nginx配置 +│ └── .env.example # 环境变量示例 +├── docs/ # 文档 +│ ├── api/ # API文档 +│ ├── deployment/ # 部署文档 +│ └── user-guide/ # 用户指南 +├── scripts/ # 脚本文件 +│ ├── init-db.sh # 数据库初始化 +│ ├── backup.sh # 数据备份 +│ └── monitor.sh # 系统监控 +└── 系统设计文档.md # 详细设计文档 +``` + +## 数据库初始化 + +1. **创建数据库** + ```sql + CREATE DATABASE h3c_onu_ms; + CREATE USER h3c_user WITH PASSWORD 'your_password'; + GRANT ALL PRIVILEGES ON DATABASE h3c_onu_ms TO h3c_user; + ``` + +2. **运行迁移** + ```bash + cd backend + alembic upgrade head + ``` + +3. **初始化数据** + ```bash + python scripts/init_data.py + ``` + +## 配置说明 + +### Casdoor配置 +1. 在Casdoor中创建应用 +2. 配置回调地址: `http://your-domain/api/auth/callback` +3. 获取Client ID、Client Secret和Certificate +4. 在.env文件中配置Casdoor参数 + +### OLT设备配置 +1. 通过系统界面添加OLT设备 +2. 配置SSH连接信息(IP、用户名、密码) +3. 设置插槽命令(如`display onu slot 1`) + +### 权限配置 +1. 在Casdoor中配置用户角色 +2. 在系统中配置角色权限映射 +3. 分配用户区域和学校权限 + +## 开发指南 + +### 后端开发 +```bash +cd backend +python -m venv venv +source venv/bin/activate # Linux/Mac +# venv\Scripts\activate # Windows +pip install -r requirements.txt +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### 前端开发 +```bash +cd frontend +npm install +npm run dev +``` + +### 测试 +```bash +# 后端测试 +cd backend +pytest + +# 前端测试 +cd frontend +npm run test +``` + +## API文档 + +启动后端服务后,访问以下地址查看API文档: +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## 监控和日志 + +### 健康检查 +``` +GET /health +``` + +### 指标监控 +``` +GET /metrics +``` + +### 日志查看 +```bash +# 查看后端日志 +docker-compose logs backend + +# 查看Celery日志 +docker-compose logs celery-worker + +# 查看Nginx日志 +docker-compose logs nginx +``` + +## 故障排除 + +### 常见问题 + +1. **SSH连接失败** + - 检查网络连通性 + - 验证SSH凭证 + - 检查防火墙设置 + +2. **数据库连接失败** + - 检查数据库服务状态 + - 验证连接字符串 + - 检查网络权限 + +3. **Casdoor登录失败** + - 检查Casdoor服务状态 + - 验证应用配置 + - 检查回调地址配置 + +### 日志级别 +- DEBUG: 开发环境详细日志 +- INFO: 生产环境常规日志 +- ERROR: 错误信息记录 + +## 贡献指南 + +1. Fork项目 +2. 创建功能分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 创建Pull Request + +## 许可证 + +本项目采用MIT许可证 - 查看LICENSE文件了解详情。 + +## 联系方式 + +- 项目维护者: [Your Name] +- 问题反馈: [GitHub Issues] +- 文档更新: [GitHub Wiki] + +## 更新日志 + +### v1.0.0 (计划中) +- 基础设备监控功能 +- Casdoor认证集成 +- 权限管理系统 +- Docker化部署 + +--- + +**重要提示**: 生产环境部署前,请务必修改默认密码和密钥,并配置适当的防火墙规则。 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d351d66 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,26 @@ +# 应用配置 +APP_NAME=H3C-ONU-MS +DEBUG=false +SECRET_KEY=your-secret-key-change-this + +# 数据库配置 +DATABASE_URL=postgresql://user:password@localhost:5432/h3c_onu_ms + +# Redis配置 +REDIS_URL=redis://localhost:6379/0 + +# Casdoor配置 +CASDOOR_ENDPOINT=https://casdoor.example.com +CASDOOR_CLIENT_ID=your_client_id +CASDOOR_CLIENT_SECRET=your_client_secret +CASDOOR_ORG_NAME=your_org +CASDOOR_APP_NAME=h3c-onu-ms +CASDOOR_CERTIFICATE=backend/token_jwt_key.pem +CASDOOR_REDIRECT_URL=http://localhost:5173/callback + +# SSH配置 +SSH_TIMEOUT=30 + +# 任务配置 +CHECK_INTERVAL=1800 +MANUAL_COOLDOWN=300 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..25bb4c7 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 使用清华镜像源 +RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \ + pip config set global.trusted-host https://pypi.tuna.tsinghua.edu.cn + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..fc8ab4b --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..e17530e --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,40 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config, pool +from alembic import context +from app.core.config import settings +from app.core.database import Base +from app.models import device + +config = context.config +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline(): + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() + diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/32c84173ef05_add_location_to_olt_devices.py b/backend/alembic/versions/32c84173ef05_add_location_to_olt_devices.py new file mode 100644 index 0000000..8b36234 --- /dev/null +++ b/backend/alembic/versions/32c84173ef05_add_location_to_olt_devices.py @@ -0,0 +1,47 @@ +"""add location to olt_devices + +Revision ID: 32c84173ef05 +Revises: 5c24b07e0f9c +Create Date: 2026-04-02 10:52:19.883293 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '32c84173ef05' +down_revision: Union[str, None] = '5c24b07e0f9c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_table('users') + op.add_column('olt_devices', sa.Column('location', sa.String(length=200), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('olt_devices', 'location') + op.create_table('users', + sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False), + sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True), + sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True), + sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')), + sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/3e108afa9bba_add_port_id_to_onu_devices.py b/backend/alembic/versions/3e108afa9bba_add_port_id_to_onu_devices.py new file mode 100644 index 0000000..6f1ceec --- /dev/null +++ b/backend/alembic/versions/3e108afa9bba_add_port_id_to_onu_devices.py @@ -0,0 +1,47 @@ +"""add port_id to onu_devices + +Revision ID: 3e108afa9bba +Revises: add_onu_extended_fields +Create Date: 2026-04-02 20:30:23.475918 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '3e108afa9bba' +down_revision: Union[str, None] = 'add_onu_extended_fields' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_table('users') + op.add_column('onu_devices', sa.Column('port_id', sa.String(length=20), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('onu_devices', 'port_id') + op.create_table('users', + sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False), + sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True), + sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True), + sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')), + sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/5c24b07e0f9c_add_place_type_and_notes_fields.py b/backend/alembic/versions/5c24b07e0f9c_add_place_type_and_notes_fields.py new file mode 100644 index 0000000..6c33a90 --- /dev/null +++ b/backend/alembic/versions/5c24b07e0f9c_add_place_type_and_notes_fields.py @@ -0,0 +1,66 @@ +"""add place_type and notes fields + +Revision ID: 5c24b07e0f9c +Revises: +Create Date: 2026-04-01 17:51:21.451465 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '5c24b07e0f9c' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('role_permissions') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_table('users') + op.drop_table('permissions') + op.add_column('onu_devices', sa.Column('place_type', sa.String(length=50), nullable=True)) + op.drop_column('onu_devices', 'location_type') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('onu_devices', sa.Column('location_type', sa.VARCHAR(length=100), autoincrement=False, nullable=True)) + op.drop_column('onu_devices', 'place_type') + op.create_table('role_permissions', + sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=False), + sa.Column('permission_id', sa.BIGINT(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], name=op.f('role_permissions_permission_id_fkey')), + sa.PrimaryKeyConstraint('role', 'permission_id', name=op.f('role_permissions_pkey')) + ) + op.create_table('users', + sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False), + sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True), + sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True), + sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')), + sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_table('permissions', + sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False), + sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('code', sa.VARCHAR(length=50), autoincrement=False, nullable=False), + sa.Column('module', sa.VARCHAR(length=50), autoincrement=False, nullable=True), + sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('permissions_pkey')), + sa.UniqueConstraint('code', name=op.f('permissions_code_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/add_onu_extended_fields.py b/backend/alembic/versions/add_onu_extended_fields.py new file mode 100644 index 0000000..52695b8 --- /dev/null +++ b/backend/alembic/versions/add_onu_extended_fields.py @@ -0,0 +1,32 @@ +"""添加距离、LOID、型号等扩展字段 + +Revision ID: add_onu_extended_fields +Revises: 32c84173ef05 +Create Date: 2026-04-02 +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers +revision = 'add_onu_extended_fields' +down_revision = '32c84173ef05' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ONUDevice 表新增字段 + op.add_column('onu_devices', sa.Column('loid', sa.String(50), nullable=True)) + op.add_column('onu_devices', sa.Column('model', sa.String(100), nullable=True)) + op.add_column('onu_devices', sa.Column('distance_m', sa.Integer, nullable=True)) + + # DeviceStatusHistory 表新增字段 + op.add_column('device_status_history', sa.Column('distance_m', sa.Integer, nullable=True)) + + +def downgrade() -> None: + op.drop_column('device_status_history', 'distance_m') + op.drop_column('onu_devices', 'distance_m') + op.drop_column('onu_devices', 'model') + op.drop_column('onu_devices', 'loid') diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..1f9f519 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,88 @@ +"""认证 API""" +import base64 +import json +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.casdoor import casdoor_sdk +from app.core.security import create_access_token +from app.core.config import settings +from app.models.user import User +from app.schemas.auth import Token, UserInfo +from datetime import datetime + +router = APIRouter(prefix="/api/auth", tags=["认证"]) + + +def decode_jwt_payload(token: str) -> dict: + """直接解码 JWT payload,不验签(Casdoor 已完成认证)""" + payload_b64 = token.split(".")[1] + rem = len(payload_b64) % 4 + if rem: + payload_b64 += "=" * (4 - rem) + return json.loads(base64.urlsafe_b64decode(payload_b64)) + + +@router.get("/login") +def login(): + """获取 Casdoor 登录 URL""" + return {"url": casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)} + + +class CallbackRequest(BaseModel): + code: str + state: str + + +@router.post("/callback", response_model=Token) +def callback(body: CallbackRequest, db: Session = Depends(get_db)): + """Casdoor 登录回调""" + try: + token_response = casdoor_sdk.get_oauth_token(code=body.code) + if isinstance(token_response, dict) and "error" in token_response: + raise HTTPException(status_code=400, detail=token_response.get("error_description", token_response["error"])) + + access_token = token_response.get("access_token") if isinstance(token_response, dict) else token_response + if not access_token: + raise HTTPException(status_code=400, detail="Casdoor 未返回 access_token") + + casdoor_user = decode_jwt_payload(access_token) + + user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first() + if not user: + user = User( + casdoor_id=casdoor_user["sub"], + username=casdoor_user.get("name") or casdoor_user.get("preferred_username", ""), + email=casdoor_user.get("email"), + role="user" + ) + db.add(user) + + user.last_login = datetime.utcnow() + db.commit() + + jwt_token = create_access_token({"sub": str(user.id), "username": user.username}) + return {"access_token": jwt_token} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/profile", response_model=UserInfo) +def get_profile(token: str, db: Session = Depends(get_db)): + """获取当前用户信息""" + from app.core.security import verify_token + + payload = verify_token(token) + if not payload: + raise HTTPException(status_code=401, detail="无效的令牌") + + user = db.query(User).filter(User.id == int(payload["sub"])).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + return user + + diff --git a/backend/app/api/v1/check.py b/backend/app/api/v1/check.py new file mode 100644 index 0000000..4a0adc6 --- /dev/null +++ b/backend/app/api/v1/check.py @@ -0,0 +1,86 @@ +"""状态检查 API""" +import asyncio +import logging +from fastapi import APIRouter, HTTPException, Depends +from celery.result import AsyncResult +from pydantic import BaseModel +from typing import Optional, List +from sqlalchemy.orm import Session +from app.tasks.check_tasks import check_all_devices +from app.core.celery_app import celery_app +from app.core.database import get_db +from app.services.check_service import CheckService + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/check", tags=["状态检查"]) + + +class CheckResult(BaseModel): + olt_id: int + olt_name: str + online: Optional[int] = 0 + offline: Optional[int] = 0 + success: bool + + +class CheckError(BaseModel): + olt_id: int + olt_name: str + error: str + + +@router.post("/status") +def trigger_check(): + """手动触发状态检查""" + try: + task = check_all_devices.delay() + return {"task_id": task.id, "status": "started"} + except Exception as e: + logger.error(f"触发状态检查失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"触发状态检查失败: {str(e)}") + + +@router.get("/status/{task_id}") +def get_check_status(task_id: str): + """查询状态检查任务进度和结果""" + task_result = AsyncResult(task_id, app=celery_app) + state = task_result.state + + result = { + "task_id": task_id, + "status": state, + "progress": None, + "result": None + } + + if state == 'PROGRESS': + result["progress"] = task_result.info + + if state == 'SUCCESS': + result["result"] = task_result.result + elif state == 'FAILURE': + result["error"] = str(task_result.info) + + return result + + +@router.post("/scan/{olt_id}") +def scan_olt(olt_id: int, db: Session = Depends(get_db)): + """扫描单台 OLT,预览发现的设备(不写入数据库)""" + try: + service = CheckService(db) + result = asyncio.run(service.scan_olt(olt_id)) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/discover/{olt_id}") +def discover_olt(olt_id: int, db: Session = Depends(get_db)): + """扫描单台 OLT 并将新发现的 MAC 自动入库关联""" + try: + service = CheckService(db) + result = asyncio.run(service.check_olt_devices(olt_id)) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/api/v1/devices.py b/backend/app/api/v1/devices.py new file mode 100644 index 0000000..976f092 --- /dev/null +++ b/backend/app/api/v1/devices.py @@ -0,0 +1,231 @@ +"""设备管理 API""" +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import asc, desc, distinct, or_ +from pydantic import BaseModel +from typing import Optional +from app.core.database import get_db +from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice +from app.schemas.device import DeviceListResponse, ONUDeviceResponse + +router = APIRouter(prefix="/api/devices", tags=["设备管理"]) + + +@router.get("", response_model=DeviceListResponse) +def get_devices( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + region: str = None, + school_name: str = None, + keyword: str = None, + status: str = None, # online / offline + db: Session = Depends(get_db) +): + """获取设备列表""" + # 子查询:每台设备最新一条状态记录 + from sqlalchemy import func + latest_subq = ( + db.query( + DeviceStatusHistory.onu_device_id, + func.max(DeviceStatusHistory.checked_at).label("max_checked_at") + ) + .group_by(DeviceStatusHistory.onu_device_id) + .subquery() + ) + latest_history = ( + db.query(DeviceStatusHistory) + .join( + latest_subq, + (DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) & + (DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at) + ) + .subquery() + ) + + query = db.query(ONUDevice) + + if region: + query = query.filter(ONUDevice.region == region) + if school_name: + query = query.filter(ONUDevice.school_name.contains(school_name)) + if keyword: + query = query.filter( + or_( + ONUDevice.mac_address.contains(keyword.lower()), + ONUDevice.building.contains(keyword), + ONUDevice.place_type.contains(keyword), + ONUDevice.school_name.contains(keyword), + ONUDevice.region.contains(keyword), + ONUDevice.room_number.contains(keyword), + ) + ) + if status in ("online", "offline", "unknown"): + if status in ("online", "offline"): + query = query.join( + latest_history, + ONUDevice.id == latest_history.c.onu_device_id + ).filter(latest_history.c.status == status) + else: + # unknown:最新状态为 unknown,或没有任何状态记录 + query = query.outerjoin( + latest_history, + ONUDevice.id == latest_history.c.onu_device_id + ).filter( + or_( + latest_history.c.onu_device_id == None, + latest_history.c.status == "unknown" + ) + ) + + # 多级排序:区域 > 学校名称 > 楼宇 > 房间号(均为升序) + query = query.order_by( + asc(ONUDevice.region), + asc(ONUDevice.school_name), + asc(ONUDevice.building), + asc(ONUDevice.room_number) + ) + + total = query.count() + items = query.offset(skip).limit(limit).all() + + # 获取每个设备最新的状态(批量,避免 N+1) + device_ids = [item.id for item in items] + history_map = {} + if device_ids: + histories = ( + db.query(DeviceStatusHistory) + .join( + latest_subq, + (DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) & + (DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at) + ) + .filter(DeviceStatusHistory.onu_device_id.in_(device_ids)) + .all() + ) + history_map = {h.onu_device_id: h for h in histories} + + # 批量加载 OLT 信息 + olt_ids = {item.olt_id for item in items if item.olt_id} + olt_map = {} + if olt_ids: + olts = db.query(OLTDevice).filter(OLTDevice.id.in_(olt_ids)).all() + olt_map = {o.id: o for o in olts} + + result_items = [] + for item in items: + latest_status = history_map.get(item.id) + olt = olt_map.get(item.olt_id) + + item_dict = { + "id": item.id, + "mac_address": item.mac_address, + "olt_id": item.olt_id, + "region": item.region, + "school_name": item.school_name, + "building": item.building, + "place_type": item.place_type, + "room_number": item.room_number, + "notes": item.notes, + "status": latest_status.status if latest_status else None, + "distance_m": latest_status.distance_m if latest_status else None, + "slot_number": item.slot_number, + "port_number": item.port_number, + "port_id": item.port_id, + "model": item.model, + "olt_location": olt.location if olt else None, + "created_at": item.created_at + } + result_items.append(ONUDeviceResponse(**item_dict)) + + return {"total": total, "items": result_items} + + +@router.get("/regions") +def get_regions(db: Session = Depends(get_db)): + """获取所有区域列表""" + regions = db.query(distinct(ONUDevice.region)).filter( + ONUDevice.region.isnot(None), + ONUDevice.region != '' + ).order_by(ONUDevice.region).all() + return [r[0] for r in regions] + + +@router.get("/{device_id}", response_model=ONUDeviceResponse) +def get_device(device_id: int, db: Session = Depends(get_db)): + """获取设备详情""" + device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first() + if not device: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="设备不存在") + + latest_status = db.query(DeviceStatusHistory).filter( + DeviceStatusHistory.onu_device_id == device.id + ).order_by(desc(DeviceStatusHistory.checked_at)).first() + + olt = db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first() if device.olt_id else None + + return ONUDeviceResponse( + id=device.id, + mac_address=device.mac_address, + olt_id=device.olt_id, + region=device.region, + school_name=device.school_name, + building=device.building, + place_type=device.place_type, + room_number=device.room_number, + notes=device.notes, + status=latest_status.status if latest_status else None, + distance_m=latest_status.distance_m if latest_status else None, + slot_number=device.slot_number, + port_number=device.port_number, + port_id=device.port_id, + model=device.model, + olt_location=olt.location if olt else None, + created_at=device.created_at + ) + + +@router.post("/{device_id}/refresh") +def refresh_device_status(device_id: int, db: Session = Depends(get_db)): + """通过 SSH 单独更新一台设备的状态和距离""" + from app.services.check_service import CheckService + try: + service = CheckService(db) + result = service.check_single_device(device_id) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + + +class DeviceUpdate(BaseModel): + region: Optional[str] = None + school_name: Optional[str] = None + building: Optional[str] = None + room_number: Optional[str] = None + notes: Optional[str] = None + + +@router.delete("/status/all") +def clear_all_status(db: Session = Depends(get_db)): + """清空所有设备状态历史记录""" + db.query(DeviceStatusHistory).delete() + db.commit() + return {"message": "已清空所有设备状态"} + + +@router.put("/{device_id}") +def update_device(device_id: int, body: DeviceUpdate, db: Session = Depends(get_db)): + """更新设备信息(区域、学校、楼宇、房间号、备注)""" + device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first() + if not device: + raise HTTPException(status_code=404, detail="设备不存在") + device.region = body.region + device.school_name = body.school_name + device.building = body.building or None + device.room_number = body.room_number or None + device.notes = body.notes or None + db.commit() + return {"message": "更新成功"} + + diff --git a/backend/app/api/v1/import_data.py b/backend/app/api/v1/import_data.py new file mode 100644 index 0000000..2a852dd --- /dev/null +++ b/backend/app/api/v1/import_data.py @@ -0,0 +1,77 @@ +"""数据导入 API""" +from fastapi import APIRouter, UploadFile, File, Depends, Response +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.services.import_service import ImportService +import shutil +import io +from openpyxl import Workbook + +router = APIRouter(prefix="/api/import", tags=["数据导入"]) + + +@router.get("/template") +def download_template(): + """下载导入数据模板""" + wb = Workbook() + ws = wb.active + ws.title = "ONU设备导入模板" + + # 表头:序号|区域|学校名称|楼宇|场所类型|房间号|MAC地址|备注 + headers = ["mac_address", "region", "school_name", "building", "place_type", "room_number", "notes"] + ws.append(headers) + + # 示例数据 + example_data = [ + ["AA:BB:CC:DD:EE:01", "区域1", "学校1", "1号楼", "教室", "101", ""], + ["AA:BB:CC:DD:EE:02", "区域1", "学校1", "1号楼", "办公室", "102", ""], + ] + for row in example_data: + ws.append(row) + + # 设置列宽 + ws.column_dimensions['A'].width = 20 # mac_address + ws.column_dimensions['B'].width = 12 # region + ws.column_dimensions['C'].width = 18 # school_name + ws.column_dimensions['D'].width = 12 # building + ws.column_dimensions['E'].width = 12 # place_type + ws.column_dimensions['F'].width = 12 # room_number + ws.column_dimensions['G'].width = 20 # notes + + # 保存到内存 + output = io.BytesIO() + wb.save(output) + output.seek(0) + + return Response( + content=output.getvalue(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=onu_import_template.xlsx"} + ) + + +@router.post("/upload") +async def upload_excel( + file: UploadFile = File(...), + olt_id: int = None, + db: Session = Depends(get_db) +): + """上传并导入 Excel 文件(仅导入 MAC 信息,不关联 OLT)""" + file_path = f"/tmp/{file.filename}" + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + service = ImportService(db) + records = service.parse_excel(file_path) + validation = service.validate_data(records) + + success_count = 0 + if validation['valid']: + result = service.import_devices(validation['valid'], olt_id) + success_count = result['success'] + + return { + "success": success_count, + "failed": validation['invalid'] + } + diff --git a/backend/app/api/v1/olt.py b/backend/app/api/v1/olt.py new file mode 100644 index 0000000..f3af8f4 --- /dev/null +++ b/backend/app/api/v1/olt.py @@ -0,0 +1,402 @@ +"""OLT 设备管理 API""" +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.orm import Session +from pydantic import BaseModel +from app.core.database import get_db +from app.models.device import OLTDevice +import pandas as pd +import io + +router = APIRouter(prefix="/api/olt", tags=["OLT设备"]) + + +class OLTCreate(BaseModel): + ip_address: str + username: str + password: str + slot_command: str = "display onu slot" + location: str = "" + description: str = "" + + +class OLTEdit(BaseModel): + username: str + password: str = None + slot_command: str = "display onu slot" + location: str = "" + description: str = "" + + +@router.get("/devices") +def get_devices(db: Session = Depends(get_db)): + return db.query(OLTDevice).all() + + +@router.post("/devices") +def create_device(device: OLTCreate, db: Session = Depends(get_db)): + db_device = OLTDevice(**device.dict()) + db.add(db_device) + db.commit() + return {"message": "创建成功"} + + +@router.put("/devices/{ip_address}") +def update_device(ip_address: str, device: OLTEdit, db: Session = Depends(get_db)): + db_device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first() + if not db_device: + raise HTTPException(status_code=404, detail="设备不存在") + + db_device.username = device.username + if device.password: + db_device.password = device.password + db_device.slot_command = device.slot_command + db_device.location = device.location + db_device.description = device.description + + db.commit() + return {"message": "更新成功"} + + +@router.delete("/devices/{ip_address}") +def delete_device(ip_address: str, db: Session = Depends(get_db)): + from app.models.device import ONUDevice + + device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first() + if not device: + raise HTTPException(status_code=404, detail="设备不存在") + + # 检查是否有关联的 ONU 设备 + onu_count = db.query(ONUDevice).filter(ONUDevice.olt_id == device.id).count() + if onu_count > 0: + raise HTTPException(status_code=400, detail=f"该 OLT 设备下还有 {onu_count} 个 ONU 设备,无法删除") + + db.delete(device) + db.commit() + return {"message": "删除成功"} + + +@router.post("/import") +async def import_devices(file: UploadFile = File(...), db: Session = Depends(get_db)): + try: + content = await file.read() + df = pd.read_excel(io.BytesIO(content)) + # 标准化列名 + df.columns = [str(c).strip() for c in df.columns] + except Exception as e: + raise HTTPException(status_code=400, detail=f"文件解析失败: {str(e)}") + + required_cols = ['IP地址', '用户名', '密码'] + missing = [c for c in required_cols if c not in df.columns] + if missing: + raise HTTPException(status_code=400, detail=f"缺少必填列: {', '.join(missing)},当前列: {', '.join(df.columns.tolist())}") + + success, failed = 0, [] + for idx, row in df.iterrows(): + try: + ip = str(row['IP地址']).strip() + if not ip or ip == 'nan': + continue + existing = db.query(OLTDevice).filter(OLTDevice.ip_address == ip).first() + if existing: + failed.append({"row": idx + 2, "ip": ip, "reason": "IP 已存在"}) + continue + device = OLTDevice( + ip_address=ip, + username=str(row['用户名']).strip(), + password=str(row['密码']).strip(), + slot_command=str(row['槽位命令']).strip() if '槽位命令' in df.columns and str(row['槽位命令']) != 'nan' else 'display onu slot', + location=str(row['安装位置']).strip() if '安装位置' in df.columns and str(row['安装位置']) != 'nan' else '', + description=str(row['描述']).strip() if '描述' in df.columns and str(row['描述']) != 'nan' else '', + ) + db.add(device) + success += 1 + except Exception as e: + failed.append({"row": idx + 2, "ip": str(row.get('IP地址', '')), "reason": str(e)}) + + db.commit() + return {"message": f"成功导入 {success} 条记录", "success": success, "failed": failed} + + +@router.get("/template") +def download_template(): + from fastapi.responses import FileResponse + return FileResponse( + path="/home/v6ole/pyproject/H3ConuMS2/backend/templates/OLT设备导入模板.xlsx", + filename="OLT设备导入模板.xlsx" + ) + + +@router.get("/duplicate-macs") +def get_duplicate_macs(olt_id: int = None, db: Session = Depends(get_db)): + """查询重复 MAC 地址记录""" + from app.models.device import DuplicateMac + query = db.query(DuplicateMac) + if olt_id: + query = query.filter(DuplicateMac.olt_id == olt_id) + records = query.order_by(DuplicateMac.last_seen_at.desc()).all() + return [ + { + "id": r.id, + "olt_id": r.olt_id, + "mac_address": r.mac_address, + "ports": r.ports, + "first_seen_at": r.first_seen_at, + "last_seen_at": r.last_seen_at, + } + for r in records + ] + + +@router.delete("/duplicate-macs/{record_id}") +def delete_duplicate_mac(record_id: int, db: Session = Depends(get_db)): + """删除重复 MAC 记录(已处理后清除)""" + from app.models.device import DuplicateMac + record = db.query(DuplicateMac).filter(DuplicateMac.id == record_id).first() + if not record: + raise HTTPException(status_code=404, detail="记录不存在") + db.delete(record) + db.commit() + return {"message": "已删除"} + + +class ClearPortRequest(BaseModel): + port_id: str # 如 "1/0/1:3" + + +@router.post("/duplicate-macs/{record_id}/clear-port") +def clear_onu_port(record_id: int, body: ClearPortRequest, db: Session = Depends(get_db)): + """通过 SSH 清除指定端口的 ONU 配置,并从 ports 列表中移除该端口""" + from app.models.device import DuplicateMac + from app.services.ssh_service import SSHService + + record = db.query(DuplicateMac).filter(DuplicateMac.id == record_id).first() + if not record: + raise HTTPException(status_code=404, detail="记录不存在") + + olt = db.query(OLTDevice).filter(OLTDevice.id == record.olt_id).first() + if not olt: + raise HTTPException(status_code=404, detail="OLT 设备不存在") + + # 验证 port_id 在记录中 + port_ids = [p["port_id"] for p in (record.ports or [])] + if body.port_id not in port_ids: + raise HTTPException(status_code=400, detail="端口不在重复记录中") + + ssh = SSHService(olt.ip_address, olt.username, olt.password) + try: + ssh.connect() + ssh.clear_onu_port(body.port_id) + except Exception as e: + raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}") + finally: + ssh.close() + + # 从 ports 列表移除已清除的端口 + remaining = [p for p in record.ports if p["port_id"] != body.port_id] + if remaining: + record.ports = remaining + else: + # 所有端口都清除了,删除整条记录 + db.delete(record) + db.commit() + + return {"message": f"端口 Onu{body.port_id} 已清除", "remaining_ports": remaining} + + +@router.get("/new-devices") +def get_new_devices(db: Session = Depends(get_db)): + """查询新发现的设备列表(待补全信息)""" + from app.models.device import NewDevice, ONUDevice + rows = ( + db.query(NewDevice, ONUDevice) + .join(ONUDevice, NewDevice.onu_device_id == ONUDevice.id) + .all() + ) + return [ + { + "id": nd.id, + "onu_device_id": nd.onu_device_id, + "olt_id": nd.olt_id, + "discovered_at": nd.discovered_at, + "mac_address": onu.mac_address, + "port_id": f"{onu.slot_number}/{onu.port_number}" if onu.slot_number else None, + "loid": onu.loid, + "model": onu.model, + "region": onu.region, + "school_name": onu.school_name, + "building": onu.building, + "room_number": onu.room_number, + } + for nd, onu in rows + ] + + +class NewDeviceUpdate(BaseModel): + region: str + school_name: str + building: str = "" + place_type: str = "" + room_number: str = "" + notes: str = "" + + +@router.put("/new-devices/{record_id}") +def update_new_device(record_id: int, body: NewDeviceUpdate, db: Session = Depends(get_db)): + """补全新设备信息,完成后从 new_devices 移除""" + from app.models.device import NewDevice, ONUDevice + record = db.query(NewDevice).filter(NewDevice.id == record_id).first() + if not record: + raise HTTPException(status_code=404, detail="记录不存在") + + onu = db.query(ONUDevice).filter(ONUDevice.id == record.onu_device_id).first() + if not onu: + raise HTTPException(status_code=404, detail="ONU 设备不存在") + + onu.region = body.region + onu.school_name = body.school_name + onu.building = body.building or None + onu.place_type = body.place_type or None + onu.room_number = body.room_number or None + onu.notes = body.notes or None + + db.delete(record) + db.commit() + return {"message": "信息已补全"} + + +@router.delete("/new-devices/{record_id}") +def dismiss_new_device(record_id: int, db: Session = Depends(get_db)): + """忽略新设备(不补全信息,仅从待处理列表移除)""" + from app.models.device import NewDevice + record = db.query(NewDevice).filter(NewDevice.id == record_id).first() + if not record: + raise HTTPException(status_code=404, detail="记录不存在") + db.delete(record) + db.commit() + return {"message": "已忽略"} + + +@router.post("/quick-scan") +def quick_scan(db: Session = Depends(get_db)): + """多线程对所有 OLT 同时执行扫描,更新已有设备状态""" + from app.services.check_service import CheckService + from concurrent.futures import ThreadPoolExecutor, as_completed + + olts = db.query(OLTDevice).all() + + def scan_one(olt): + from app.core.database import SessionLocal + thread_db = SessionLocal() + try: + service = CheckService(thread_db) + result = service.scan_and_discover(olt.id) + return {"olt_id": olt.id, "olt_location": olt.location or olt.ip_address, + "online": result.get("online", 0), "offline": result.get("offline", 0), + "new_discovered": result.get("new_discovered", 0), + "error": None} + except Exception as e: + return {"olt_id": olt.id, "olt_location": olt.location or olt.ip_address, + "online": 0, "offline": 0, "new_discovered": 0, "error": str(e)} + finally: + thread_db.close() + + olt_results = {} + with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor: + futures = {executor.submit(scan_one, olt): olt.id for olt in olts} + for future in as_completed(futures): + r = future.result() + olt_results[r["olt_id"]] = r + + results = [] + errors = [] + total_online = 0 + total_offline = 0 + total_new = 0 + for olt in olts: + r = olt_results.get(olt.id, {}) + if r.get("error"): + errors.append({"olt_location": r["olt_location"], "error": r["error"]}) + else: + total_online += r.get("online", 0) + total_offline += r.get("offline", 0) + total_new += r.get("new_discovered", 0) + results.append({ + "olt_location": r.get("olt_location", olt.location or olt.ip_address), + "online": r.get("online", 0), + "offline": r.get("offline", 0), + "new_discovered": r.get("new_discovered", 0), + "success": not r.get("error"), + "error": r.get("error"), + }) + + return { + "total_online": total_online, + "total_offline": total_offline, + "total_new": total_new, + "results": results, + "errors": errors, + } + + +@router.post("/loopback-detection") +def loopback_detection(db: Session = Depends(get_db)): + """对所有 OLT 并发执行环路检测,返回有环路的端口及对应设备信息""" + from app.models.device import ONUDevice + from app.services.ssh_service import SSHService + from concurrent.futures import ThreadPoolExecutor, as_completed + + olts = db.query(OLTDevice).all() + + # 预加载所有 ONU 设备,按 (olt_id, port_id) 索引,避免多线程操作 Session + all_onus = db.query(ONUDevice).all() + onu_map = {(o.olt_id, o.port_id): o for o in all_onus if o.port_id} + + def check_one(olt): + ssh = SSHService(olt.ip_address, olt.username, olt.password) + try: + ssh.connect() + detection = ssh.detect_loopback() + except Exception as e: + return { + "olt_id": olt.id, + "olt_ip": olt.ip_address, + "olt_location": olt.location or olt.ip_address, + "error": str(e), + "has_loop": False, + "loop_interfaces": [], + } + finally: + ssh.close() + + loop_interfaces = [] + for iface in detection.get("interfaces", []): + port_id = iface.removeprefix("Onu") + onu = onu_map.get((olt.id, port_id)) + loop_interfaces.append({ + "interface": iface, + "port_id": port_id, + "mac_address": onu.mac_address if onu else None, + "region": onu.region if onu else None, + "school_name": onu.school_name if onu else None, + "building": onu.building if onu else None, + "room_number": onu.room_number if onu else None, + }) + + return { + "olt_id": olt.id, + "olt_ip": olt.ip_address, + "olt_location": olt.location or olt.ip_address, + "has_loop": detection["has_loop"], + "loop_interfaces": loop_interfaces, + "error": None, + } + + results_map = {} + with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor: + futures = {executor.submit(check_one, olt): olt.id for olt in olts} + for future in as_completed(futures): + olt_id = futures[future] + results_map[olt_id] = future.result() + + # 按原始顺序返回 + return [results_map[olt.id] for olt in olts] diff --git a/backend/app/api/v1/provision.py b/backend/app/api/v1/provision.py new file mode 100644 index 0000000..a9c4459 --- /dev/null +++ b/backend/app/api/v1/provision.py @@ -0,0 +1,59 @@ +"""业务下发 API""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import List, Optional +from app.core.database import get_db +from app.services.provision_service import ProvisionService + +router = APIRouter(prefix="/api/provision", tags=["业务下发"]) + + +class ProvisionRequest(BaseModel): + device_id: int + + +class BatchProvisionRequest(BaseModel): + device_ids: List[int] + + +class ProvisionResponse(BaseModel): + success: bool + message: str + device_id: Optional[int] = None + mac_address: Optional[str] = None + olt_ip: Optional[str] = None + port: Optional[str] = None + + +@router.post("/service", response_model=ProvisionResponse) +def provision_single_device( + request: ProvisionRequest, + db: Session = Depends(get_db) +): + """下发业务到单个设备""" + service = ProvisionService(db) + result = service.provision_device(request.device_id) + + if result.get("success"): + return ProvisionResponse( + success=True, + message=f"业务下发成功,MAC: {result['mac_address']}, 端口: {result['port']}", + device_id=result["device_id"], + mac_address=result["mac_address"], + olt_ip=result.get("olt_ip"), + port=result.get("port") + ) + else: + raise HTTPException(status_code=400, detail=result.get("error", "业务下发失败")) + + +@router.post("/batch", response_model=dict) +def provision_batch_devices( + request: BatchProvisionRequest, + db: Session = Depends(get_db) +): + """批量下发业务""" + service = ProvisionService(db) + result = service.batch_provision(request.device_ids) + return result diff --git a/backend/app/api/v1/stats.py b/backend/app/api/v1/stats.py new file mode 100644 index 0000000..9ebd374 --- /dev/null +++ b/backend/app/api/v1/stats.py @@ -0,0 +1,118 @@ +"""统计 API""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from sqlalchemy import func, case +from app.core.database import get_db +from app.models.device import ONUDevice, DeviceStatusHistory +from datetime import datetime, timedelta + +router = APIRouter(prefix="/api/stats", tags=["统计"]) + + +@router.get("/dashboard") +def get_dashboard(db: Session = Depends(get_db)): + """仪表板统计:总体、城区、城郊、乡镇在线率""" + # 每台设备最新状态子查询 + latest_subq = ( + db.query( + DeviceStatusHistory.onu_device_id, + func.max(DeviceStatusHistory.checked_at).label("max_checked_at") + ) + .group_by(DeviceStatusHistory.onu_device_id) + .subquery() + ) + latest_status_subq = ( + db.query( + DeviceStatusHistory.onu_device_id, + DeviceStatusHistory.status + ) + .join( + latest_subq, + (DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) & + (DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at) + ) + .subquery() + ) + + rows = ( + db.query( + ONUDevice.region, + ONUDevice.school_name, + func.count().label("total"), + func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"), + func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"), + ) + .outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id) + .group_by(ONUDevice.region, ONUDevice.school_name) + .order_by(ONUDevice.region, ONUDevice.school_name) + .all() + ) + + overall = {"total": 0, "online": 0, "offline": 0} + urban_schools, suburban_schools = [], [] + rural_towns = {} + + for row in rows: + region = row.region or "" + total = int(row.total or 0) + online = int(row.online or 0) + offline = int(row.offline or 0) + overall["total"] += total + overall["online"] += online + overall["offline"] += offline + + school_stat = {"name": row.school_name or "未知", "total": total, "online": online, "offline": offline} + + if region == "城区": + urban_schools.append(school_stat) + elif region == "城郊": + suburban_schools.append(school_stat) + else: + if region not in rural_towns: + rural_towns[region] = {"region": region, "total": 0, "online": 0, "offline": 0, "schools": []} + rural_towns[region]["total"] += total + rural_towns[region]["online"] += online + rural_towns[region]["offline"] += offline + rural_towns[region]["schools"].append(school_stat) + + def sort_by_rate(items): + return sorted(items, key=lambda x: x["online"] / x["total"] if x["total"] > 0 else 0) + + def agg(items): + return {"total": sum(s["total"] for s in items), "online": sum(s["online"] for s in items), "offline": sum(s["offline"] for s in items)} + + rural_list = sort_by_rate(list(rural_towns.values())) + + return { + "overall": overall, + "urban": {**agg(urban_schools), "schools": sort_by_rate(urban_schools)}, + "suburban": {**agg(suburban_schools), "schools": sort_by_rate(suburban_schools)}, + "rural": {**agg(list(rural_towns.values())), "towns": rural_list}, + } + + +@router.get("/summary") +def get_summary(db: Session = Depends(get_db)): + """获取统计摘要""" + total = db.query(ONUDevice).count() + latest_status = db.query( + DeviceStatusHistory.status, + func.count(DeviceStatusHistory.id) + ).group_by(DeviceStatusHistory.status).all() + status_dict = dict(latest_status) + return {"total": total, "online": status_dict.get('online', 0), "offline": status_dict.get('offline', 0)} + + +@router.get("/trend") +def get_trend(days: int = 7, db: Session = Depends(get_db)): + """获取状态趋势数据""" + start_date = datetime.utcnow() - timedelta(days=days) + history = db.query( + func.date(DeviceStatusHistory.checked_at).label('date'), + func.sum(func.case((DeviceStatusHistory.status == 'online', 1), else_=0)).label('online'), + func.sum(func.case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label('offline') + ).filter(DeviceStatusHistory.checked_at >= start_date).group_by( + func.date(DeviceStatusHistory.checked_at) + ).all() + return [{"date": str(h.date), "online": h.online, "offline": h.offline} for h in history] + diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/casdoor.py b/backend/app/core/casdoor.py new file mode 100644 index 0000000..db07961 --- /dev/null +++ b/backend/app/core/casdoor.py @@ -0,0 +1,12 @@ +"""Casdoor 认证配置""" +from casdoor import CasdoorSDK +from app.core.config import settings + +casdoor_sdk = CasdoorSDK( + endpoint=settings.CASDOOR_ENDPOINT, + client_id=settings.CASDOOR_CLIENT_ID, + client_secret=settings.CASDOOR_CLIENT_SECRET, + certificate=settings.casdoor_cert_content, + org_name=settings.CASDOOR_ORG_NAME, + application_name=settings.CASDOOR_APP_NAME, +) diff --git a/backend/app/core/celery_app.py b/backend/app/core/celery_app.py new file mode 100644 index 0000000..e728dab --- /dev/null +++ b/backend/app/core/celery_app.py @@ -0,0 +1,23 @@ +"""Celery 配置""" +from celery import Celery +from app.core.config import settings + +celery_app = Celery( + "h3c_onu_ms", + broker=settings.REDIS_URL, + backend=settings.REDIS_URL +) + +celery_app.conf.update( + task_serializer='json', + result_serializer='json', + accept_content=['json'], + timezone='UTC', + enable_utc=True, + beat_schedule={ + 'check-devices-every-30-minutes': { + 'task': 'app.tasks.check_tasks.check_all_devices', + 'schedule': settings.CHECK_INTERVAL, + }, + }, +) diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..10abf6a --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,50 @@ +"""应用配置""" +import os +from pathlib import Path +from pydantic_settings import BaseSettings + +# 项目根目录(config.py 位于 backend/app/core/,parent.parent.parent 即 backend/) +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent + + +class Settings(BaseSettings): + APP_NAME: str = "H3C-ONU-MS" + DEBUG: bool = False + SECRET_KEY: str + + DATABASE_URL: str + REDIS_URL: str + + CASDOOR_ENDPOINT: str + CASDOOR_CLIENT_ID: str + CASDOOR_CLIENT_SECRET: str + CASDOOR_ORG_NAME: str + CASDOOR_APP_NAME: str + CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容 + CASDOOR_REDIRECT_URL: str = "http://localhost:5173/callback" + + SSH_TIMEOUT: int = 30 + CHECK_INTERVAL: int = 1800 + MANUAL_COOLDOWN: int = 300 + + class Config: + env_file = str(PROJECT_ROOT / ".env") + + @property + def casdoor_cert_content(self) -> str: + """读取证书文件内容或直接返回证书字符串""" + cert = self.CASDOOR_CERTIFICATE + if not cert: + return "" + cert_path = Path(cert) + if cert_path.is_absolute(): + path = cert_path + else: + # 相对路径基于项目根目录解析 + path = PROJECT_ROOT / cert + if path.is_file(): + return path.read_text() + return cert # 直接是 PEM 内容 + + +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..0dc12e1 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,17 @@ +"""数据库配置""" +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from app.core.config import settings + +engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..3f1e58d --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,22 @@ +"""JWT 安全配置""" +from datetime import datetime, timedelta +from jose import JWTError, jwt +from app.core.config import settings + +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24小时 + + +def create_access_token(data: dict): + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + + +def verify_token(token: str): + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + return None diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..c35e658 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,28 @@ +"""FastAPI 主应用""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.core.config import settings +from app.api.v1 import auth, devices, check, import_data, stats, olt, provision + +app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(devices.router) +app.include_router(check.router) +app.include_router(import_data.router) +app.include_router(stats.router) +app.include_router(olt.router) +app.include_router(provision.router) + + +@app.get("/health") +def health_check(): + return {"status": "ok"} diff --git a/backend/app/middleware/permission_middleware.py b/backend/app/middleware/permission_middleware.py new file mode 100644 index 0000000..78e38d4 --- /dev/null +++ b/backend/app/middleware/permission_middleware.py @@ -0,0 +1,27 @@ +"""权限检查中间件""" +from fastapi import HTTPException, Depends +from app.core.security import verify_token + +ROLE_PERMISSIONS = { + 'admin': ['*'], + 'area_admin': ['device.view', 'device.check'], + 'school_admin': ['device.view'], + 'user': ['device.view'] +} + + +def check_permission(required_permission: str): + def permission_checker(token: str): + payload = verify_token(token) + if not payload: + raise HTTPException(status_code=401, detail="未授权") + + role = payload.get('role', 'user') + permissions = ROLE_PERMISSIONS.get(role, []) + + if '*' in permissions or required_permission in permissions: + return payload + + raise HTTPException(status_code=403, detail="权限不足") + + return permission_checker diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/device.py b/backend/app/models/device.py new file mode 100644 index 0000000..a8ec214 --- /dev/null +++ b/backend/app/models/device.py @@ -0,0 +1,82 @@ +"""设备数据模型""" +from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, JSON +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base + + +class OLTDevice(Base): + __tablename__ = "olt_devices" + + id = Column(BigInteger, primary_key=True, index=True) + ip_address = Column(String(45), nullable=False) + username = Column(String(100), nullable=False) + password = Column(Text, nullable=False) + slot_command = Column(String(50), nullable=False) + location = Column(String(200)) + description = Column(Text) + created_at = Column(TIMESTAMP, server_default=func.now()) + updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) + + +class ONUDevice(Base): + __tablename__ = "onu_devices" + + id = Column(BigInteger, primary_key=True, index=True) + mac_address = Column(String(17), nullable=False, index=True) + olt_id = Column(BigInteger, ForeignKey("olt_devices.id")) + slot_number = Column(Integer) + port_number = Column(Integer) + port_id = Column(String(20)) # 完整端口标识,如 "1/0/2:4" + # OLT 返回的额外信息 + loid = Column(String(50)) # LOID + model = Column(String(100)) # 设备型号 + distance_m = Column(Integer) # 距离(米) + region = Column(String(100), index=True) + school_name = Column(String(200), index=True) + building = Column(String(100)) + place_type = Column(String(50)) # 场所类型 + room_number = Column(String(50)) + notes = Column(Text) # 备注 + created_at = Column(TIMESTAMP, server_default=func.now()) + updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) + + # 关联状态历史 + status_history = relationship("DeviceStatusHistory", back_populates="device", lazy="selectin") + + +class DeviceStatusHistory(Base): + __tablename__ = "device_status_history" + + id = Column(BigInteger, primary_key=True, index=True) + onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id")) + status = Column(String(20), nullable=False) # online, offline + distance_m = Column(Integer) # 距离(米) + checked_at = Column(TIMESTAMP, nullable=False) + response_data = Column(Text) + created_at = Column(TIMESTAMP, server_default=func.now()) + + device = relationship("ONUDevice", back_populates="status_history") + + +class DuplicateMac(Base): + """重复 MAC 地址记录(同一 MAC 出现在多个端口)""" + __tablename__ = "duplicate_macs" + + id = Column(BigInteger, primary_key=True, index=True) + olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False) + mac_address = Column(String(17), nullable=False, index=True) + # 所有出现的端口列表,JSON 格式: [{"port_id": "1/0/1:1", "status": "online"}, ...] + ports = Column(JSON, nullable=False) + first_seen_at = Column(TIMESTAMP, server_default=func.now()) + last_seen_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) + + +class NewDevice(Base): + """新发现设备(OLT 扫描到但尚未补全信息的设备)""" + __tablename__ = "new_devices" + + id = Column(BigInteger, primary_key=True, index=True) + onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, unique=True) + olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False) + discovered_at = Column(TIMESTAMP, server_default=func.now()) diff --git a/backend/app/models/permission.py b/backend/app/models/permission.py new file mode 100644 index 0000000..6b7637e --- /dev/null +++ b/backend/app/models/permission.py @@ -0,0 +1,21 @@ +"""权限数据模型""" +from sqlalchemy import Column, BigInteger, String, Text, ForeignKey, Table +from app.core.database import Base + +role_permissions = Table( + 'role_permissions', + Base.metadata, + Column('role', String(50), primary_key=True), + Column('permission_id', BigInteger, ForeignKey('permissions.id'), primary_key=True) +) + + +class Permission(Base): + __tablename__ = "permissions" + + id = Column(BigInteger, primary_key=True) + name = Column(String(100), nullable=False) + code = Column(String(50), unique=True, nullable=False) + module = Column(String(50)) + description = Column(Text) + diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..824b734 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,19 @@ +"""用户数据模型""" +from sqlalchemy import Column, BigInteger, String, Boolean, TIMESTAMP +from sqlalchemy.sql import func +from app.core.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(BigInteger, primary_key=True, index=True) + casdoor_id = Column(String(100), unique=True, nullable=False) + username = Column(String(100), nullable=False) + email = Column(String(255)) + role = Column(String(50), default="user") + assigned_area = Column(String(100)) + assigned_school = Column(String(200)) + is_active = Column(Boolean, default=True) + last_login = Column(TIMESTAMP) + created_at = Column(TIMESTAMP, server_default=func.now()) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..af4bf9b --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,20 @@ +"""认证相关 Schema""" +from pydantic import BaseModel +from typing import Optional + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +class UserInfo(BaseModel): + id: int + username: str + email: Optional[str] + role: str + assigned_area: Optional[str] + assigned_school: Optional[str] + + class Config: + from_attributes = True diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py new file mode 100644 index 0000000..fd4aa91 --- /dev/null +++ b/backend/app/schemas/device.py @@ -0,0 +1,36 @@ +"""设备相关 Schema""" +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class ONUDeviceBase(BaseModel): + mac_address: str + olt_id: Optional[int] = None + region: Optional[str] = None + school_name: Optional[str] = None + building: Optional[str] = None + place_type: Optional[str] = None # 场所类型 + room_number: Optional[str] = None + notes: Optional[str] = None # 备注 + + +class ONUDeviceResponse(ONUDeviceBase): + id: int + status: Optional[str] = None + distance_m: Optional[int] = None + slot_number: Optional[int] = None + port_number: Optional[int] = None + port_id: Optional[str] = None # 完整端口标识,如 "1/0/2:4" + model: Optional[str] = None + olt_location: Optional[str] = None # OLT 安装位置 + created_at: datetime + + class Config: + from_attributes = True + + +class DeviceListResponse(BaseModel): + total: int + items: list[ONUDeviceResponse] + diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/check_service.py b/backend/app/services/check_service.py new file mode 100644 index 0000000..dac6e17 --- /dev/null +++ b/backend/app/services/check_service.py @@ -0,0 +1,263 @@ +"""设备状态检查服务""" +from typing import List, Dict, Optional +from sqlalchemy.orm import Session +from app.services.ssh_service import SSHService +from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, DuplicateMac, NewDevice +from datetime import datetime +import re + + +def parse_distance(distance_str: Optional[str]) -> Optional[int]: + """将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234""" + if not distance_str: + return None + m = re.search(r'\d+', distance_str) + return int(m.group()) if m else None + + +class CheckService: + """设备状态检查服务""" + + def __init__(self, db: Session): + self.db = db + + def update_status_only(self, olt_id: int) -> Dict: + """扫描 OLT,仅更新已有设备的在线状态和距离(按全局 MAC 匹配)。 + 不修改 olt_id/端口等字段,不标记 unknown,不入库新设备。 + """ + olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first() + if not olt: + raise Exception(f"OLT 设备不存在: {olt_id}") + + ssh = SSHService(olt.ip_address, olt.username, olt.password) + try: + ssh.connect() + output = ssh.execute_command(olt.slot_command) + onu_info_dict, _ = ssh.parse_onu_info(output) + + checked_at = datetime.utcnow() + online_count = 0 + offline_count = 0 + + # 全局 MAC → id 索引,只取需要的字段 + existing = { + row.mac_address.lower(): row.id + for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all() + } + + for mac, onu_info in onu_info_dict.items(): + onu_id = existing.get(mac) + if onu_id is None: + continue # 不在库中,跳过 + + status = onu_info.status + distance_m = parse_distance(onu_info.distance_str) + if status == 'online': + online_count += 1 + else: + offline_count += 1 + + self.db.add(DeviceStatusHistory( + onu_device_id=onu_id, + status=status, + distance_m=distance_m, + checked_at=checked_at, + response_data=None, + )) + + self.db.commit() + return { + "online": online_count, + "offline": offline_count, + } + finally: + ssh.close() + + def check_single_device(self, device_id: int) -> Dict: + """通过 SSH 单独查询一台 ONU 设备的当前状态和距离。 + 命令格式: display onu slot {slot} | include {mac} + """ + device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first() + if not device: + raise Exception("设备不存在") + if not device.olt_id: + raise Exception("该设备未关联 OLT,无法查询") + + olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first() + if not olt: + raise Exception("关联的 OLT 不存在") + + mac = device.mac_address.lower() + base_cmd = olt.slot_command # e.g. "display onu slot" + if device.slot_number is not None: + cmd = f"{base_cmd} {device.slot_number} | include {mac}" + else: + cmd = f"{base_cmd} | include {mac}" + + ssh = SSHService(olt.ip_address, olt.username, olt.password) + try: + ssh.connect() + output = ssh.execute_command(cmd) + + # 从输出中找到包含该 MAC 的行并解析 + info = None + for line in output.splitlines(): + if mac in line.lower(): + info = ssh._parse_device_line(line, device.slot_number) + if info: + break + + if info is None: + # 未找到该 MAC,视为离线 + status = 'offline' + distance_m = None + else: + status = info.status + distance_m = parse_distance(info.distance_str) + + self.db.add(DeviceStatusHistory( + onu_device_id=device.id, + status=status, + distance_m=distance_m, + checked_at=datetime.utcnow(), + response_data=None, + )) + self.db.commit() + return {"status": status, "distance_m": distance_m} + finally: + ssh.close() + + def scan_and_discover(self, olt_id: int) -> Dict: + """扫描 OLT,更新已有设备状态,并将新发现的在线设备入库。 + 不标记 unknown,不修改已有设备的 olt_id/端口以外的字段。 + """ + olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first() + if not olt: + raise Exception(f"OLT 设备不存在: {olt_id}") + + ssh = SSHService(olt.ip_address, olt.username, olt.password) + try: + ssh.connect() + output = ssh.execute_command(olt.slot_command) + onu_info_dict, duplicate_dict = ssh.parse_onu_info(output) + + checked_at = datetime.utcnow() + online_count = 0 + offline_count = 0 + new_count = 0 + + existing = { + row.mac_address.lower(): row.id + for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all() + } + + for mac, onu_info in onu_info_dict.items(): + onu_id = existing.get(mac) + + if onu_id is None: + # 新设备:只入库在线的 + if onu_info.status != 'online': + continue + onu = ONUDevice( + mac_address=mac, + olt_id=olt_id, + slot_number=onu_info.slot_number, + port_number=onu_info.port_number, + port_id=onu_info.port_id, + distance_m=parse_distance(onu_info.distance_str), + loid=onu_info.loid, + model=onu_info.model, + ) + self.db.add(onu) + self.db.flush() + self.db.add(NewDevice(onu_device_id=onu.id, olt_id=olt_id)) + onu_id = onu.id + new_count += 1 + + status = onu_info.status + distance_m = parse_distance(onu_info.distance_str) + if status == 'online': + online_count += 1 + else: + offline_count += 1 + + self.db.add(DeviceStatusHistory( + onu_device_id=onu_id, + status=status, + distance_m=distance_m, + checked_at=checked_at, + response_data=None, + )) + + self._save_duplicate_macs(olt_id, duplicate_dict) + self.db.commit() + return { + "online": online_count, + "offline": offline_count, + "new_discovered": new_count, + } + finally: + ssh.close() + + async def scan_olt(self, olt_id: int) -> Dict: + """仅扫描 OLT,返回发现的设备列表(不写入数据库)""" + olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first() + if not olt: + raise Exception(f"OLT 设备不存在: {olt_id}") + + ssh = SSHService(olt.ip_address, olt.username, olt.password) + try: + ssh.connect() + output = ssh.execute_command(olt.slot_command) + onu_info_dict, duplicate_dict = ssh.parse_onu_info(output) + + # 对比全局 MAC + existing_macs = { + onu.mac_address.lower() + for onu in self.db.query(ONUDevice.mac_address).all() + } + + devices = [] + for mac, info in onu_info_dict.items(): + devices.append({ + "mac_address": mac, + "status": info.status, + "distance_m": info.distance_str, + "slot_number": info.slot_number, + "port_number": info.port_number, + "port_id": info.port_id, + "loid": info.loid, + "model": info.model, + "is_new": mac not in existing_macs, + }) + + duplicates = [] + for mac, records in duplicate_dict.items(): + duplicates.append({ + "mac_address": mac, + "ports": [{"port_id": r.port_id, "status": r.status} for r in records], + }) + + return { + "olt_id": olt_id, + "olt_ip": olt.ip_address, + "total": len(devices), + "new": sum(1 for d in devices if d["is_new"]), + "devices": devices, + "duplicates": duplicates, + } + finally: + ssh.close() + + def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict): + for mac, records in duplicate_dict.items(): + ports = [{"port_id": r.port_id, "status": r.status} for r in records] + existing = self.db.query(DuplicateMac).filter( + DuplicateMac.olt_id == olt_id, + DuplicateMac.mac_address == mac + ).first() + if existing: + existing.ports = ports + existing.last_seen_at = datetime.utcnow() + else: + self.db.add(DuplicateMac(olt_id=olt_id, mac_address=mac, ports=ports)) diff --git a/backend/app/services/import_service.py b/backend/app/services/import_service.py new file mode 100644 index 0000000..d2849f0 --- /dev/null +++ b/backend/app/services/import_service.py @@ -0,0 +1,117 @@ +"""Excel 导入服务""" +import pandas as pd +from typing import List, Dict +from sqlalchemy.orm import Session +from app.models.device import ONUDevice +import math + +# 定义字段映射:Excel列名 -> 数据库字段名 +FIELD_MAPPING = { + 'mac_address': 'mac_address', + 'region': 'region', + 'school_name': 'school_name', + 'building': 'building', + 'place_type': 'place_type', + 'room_number': 'room_number', + 'notes': 'notes', +} + + +def clean_value(value) -> str: + """清理单元格值,处理NaN和None""" + if value is None: + return '' + if isinstance(value, float) and math.isnan(value): + return '' + return str(value).strip() + + +class ImportService: + def __init__(self, db: Session): + self.db = db + + def parse_excel(self, file_path: str) -> List[Dict]: + """解析 Excel 文件""" + df = pd.read_excel(file_path) + # 标准化列名(去除空格,转小写) + df.columns = [col.strip().lower() for col in df.columns] + # 转换为记录列表 + records = [] + for _, row in df.iterrows(): + record = {} + for col_name, db_field in FIELD_MAPPING.items(): + if col_name in row: + record[db_field] = clean_value(row[col_name]) + records.append(record) + return records + + def validate_data(self, records: List[Dict]) -> Dict: + """验证数据""" + valid = [] + invalid = [] + + for idx, record in enumerate(records): + mac = record.get('mac_address', '').strip() + if not mac: + invalid.append({'record': record, 'error': f'第{idx+2}行: MAC地址缺失'}) + continue + + # 标准化MAC地址:统一使用横杠分隔小写格式 + # 支持格式:AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF, AABBCCDDEEFF, aa:bb:cc:dd:ee:ff + mac_clean = mac.upper().replace(':', '-') + + # 验证基本格式:12个十六进制字符(可能有分隔符) + hex_chars = mac_clean.replace('-', '') + if len(hex_chars) != 12 or not all(c in '0123456789ABCDEF' for c in hex_chars): + invalid.append({'record': record, 'error': f'第{idx+2}行: MAC地址格式错误 "{mac}"'}) + continue + + # 转换为标准格式 34dc-99c8-56e0(小写4位分组) + mac_formatted = '-'.join([hex_chars[i:i+4].lower() for i in range(0, 12, 4)]) + record['mac_address'] = mac_formatted + valid.append(record) + + return {'valid': valid, 'invalid': invalid} + + def import_devices(self, records: List[Dict], olt_id: int = None) -> Dict: + """批量导入设备,存在则更新,不存在则新增""" + success_count = 0 + skip_count = 0 + invalid_count = 0 + + for record in records: + mac = record.get('mac_address', '') + if not mac: + skip_count += 1 + continue + + # 查询是否已存在该 MAC 地址 + existing = self.db.query(ONUDevice).filter(ONUDevice.mac_address == mac).first() + + if existing: + # 更新现有记录 + existing.region = record.get('region', '') + existing.school_name = record.get('school_name', '') + existing.building = record.get('building') or None + existing.place_type = record.get('place_type') or None + existing.room_number = record.get('room_number') or None + existing.notes = record.get('notes') or None + success_count += 1 + else: + # 新增记录 + device = ONUDevice( + mac_address=mac, + olt_id=olt_id, + region=record.get('region', ''), + school_name=record.get('school_name', ''), + building=record.get('building') or None, + place_type=record.get('place_type') or None, + room_number=record.get('room_number') or None, + notes=record.get('notes') or None + ) + self.db.add(device) + success_count += 1 + + self.db.commit() + return {'success': success_count} + diff --git a/backend/app/services/provision_service.py b/backend/app/services/provision_service.py new file mode 100644 index 0000000..4f64722 --- /dev/null +++ b/backend/app/services/provision_service.py @@ -0,0 +1,114 @@ +"""业务下发服务""" +from typing import Dict +from sqlalchemy.orm import Session +from app.services.ssh_service import SSHService +from app.models.device import ONUDevice, OLTDevice +import time + + +class ProvisionService: + """业务下发服务""" + + def __init__(self, db: Session): + self.db = db + + def provision_device(self, device_id: int) -> Dict[str, any]: + """ + 业务下发流程(参照下发业务流程.md): + 1. system-view + 2. interface Onu{port_id} + 3. uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010 + 4. port link-type trunk + 5. undo port trunk permit vlan 1 + 6. port trunk permit vlan 2000 3000 to 3010 4094 + 7. save force + """ + device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first() + if not device: + return {"success": False, "error": f"设备不存在: {device_id}"} + + if not device.olt_id: + return {"success": False, "error": "设备未关联 OLT,请先进行扫描"} + + olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first() + if not olt: + return {"success": False, "error": "关联的 OLT 设备不存在"} + + if not device.port_id: + return {"success": False, "error": "设备端口信息不完整,请先对 OLT 执行扫描"} + + port_name = f"Onu{device.port_id}" + ssh = SSHService(olt.ip_address, olt.username, olt.password) + + try: + ssh.connect() + + def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str: + ssh.shell.send(cmd + "\n") + buf = "" + deadline = time.time() + timeout + while time.time() < deadline: + if ssh.shell.recv_ready(): + buf += ssh.shell.recv(4096).decode("utf-8", errors="ignore") + if expect in buf: + return buf + else: + time.sleep(0.2) + return buf + + # 进入系统视图 + out = send_and_wait("system-view", "]") + if "]" not in out: + raise Exception("进入 system-view 失败") + + # 进入端口 + out = send_and_wait(f"interface {port_name}", "]") + if "]" not in out: + raise Exception(f"进入端口 {port_name} 失败") + + # 配置 VLAN + send_and_wait("uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010", "]") + send_and_wait("port link-type trunk", "]") + send_and_wait("undo port trunk permit vlan 1", "]") + send_and_wait("port trunk permit vlan 2000 3000 to 3010 4094", "]") + + # 保存配置(等待 "successfully" 出现) + save_out = send_and_wait("save force", "successfully", timeout=30) + if "successfully" not in save_out: + raise Exception("save force 未确认成功,请检查 OLT 日志") + + return { + "success": True, + "device_id": device_id, + "mac_address": device.mac_address, + "olt_ip": olt.ip_address, + "port": port_name, + } + + except Exception as e: + return { + "success": False, + "device_id": device_id, + "mac_address": device.mac_address, + "error": str(e), + } + finally: + ssh.close() + + def batch_provision(self, device_ids: list) -> Dict[str, any]: + results = [] + success_count = 0 + fail_count = 0 + for device_id in device_ids: + result = self.provision_device(device_id) + results.append(result) + if result.get("success"): + success_count += 1 + else: + fail_count += 1 + return { + "total": len(device_ids), + "success": success_count, + "failed": fail_count, + "results": results, + } diff --git a/backend/app/services/ssh_service.py b/backend/app/services/ssh_service.py new file mode 100644 index 0000000..b9a59d3 --- /dev/null +++ b/backend/app/services/ssh_service.py @@ -0,0 +1,282 @@ +"""SSH 连接服务""" +import paramiko +import re +import time +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass +from app.core.config import settings + + +@dataclass +class ONUInfo: + """ONU 设备完整信息""" + mac_address: str + status: str # online/offline + distance_m: Optional[int] = None # 距离(米) + distance_str: Optional[str] = None # 距离原始字符串,如 "<1000" + slot_number: Optional[int] = None # 插槽号 + port_number: Optional[int] = None # 端口号 + port_id: Optional[str] = None # 完整端口标识,如 "1/0/1:1" + loid: Optional[str] = None # LOID + model: Optional[str] = None # 设备型号 + + +class SSHService: + """SSH 连接和命令执行服务""" + + def __init__(self, host: str, username: str, password: str, port: int = 22): + self.host = host + self.username = username + self.password = password + self.port = port + self.client: Optional[paramiko.SSHClient] = None + self.shell = None + + def connect(self) -> bool: + """建立 SSH 连接,等待初始 banner 输出完毕""" + try: + self.client = paramiko.SSHClient() + self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.client.connect( + hostname=self.host, + port=self.port, + username=self.username, + password=self.password, + timeout=30 + ) + self.shell = self.client.invoke_shell(width=200, height=50) + # 等待登录 banner 输出完毕,直到出现命令提示符 ">" + deadline = time.time() + 10 + buf = "" + while time.time() < deadline: + if self.shell.recv_ready(): + buf += self.shell.recv(4096).decode('utf-8', errors='ignore') + if ">" in buf: + break + else: + time.sleep(0.2) + return True + except Exception as e: + raise Exception(f"SSH 连接失败: {str(e)}") + + def execute_command(self, command: str) -> str: + """执行命令并处理 More 分页,等待命令提示符出现后返回""" + if not self.shell: + raise Exception("SSH 未连接") + + self.shell.send(command + "\n") + output = "" + # 等待命令回显出现,再开始收集输出 + time.sleep(0.5) + + deadline = time.time() + 60 + while time.time() < deadline: + if self.shell.recv_ready(): + chunk = self.shell.recv(4096).decode('utf-8', errors='ignore') + output += chunk + if "---- More ----" in chunk: + self.shell.send(" ") + time.sleep(0.3) + elif ">" in chunk: + # 命令提示符出现,说明输出完毕 + break + else: + time.sleep(0.2) + + return output + + def clear_onu_port(self, port_id: str) -> bool: + """清除指定端口的 ONU 配置(恢复默认) + 流程: system-view -> interface Onu{port_id} -> default -> Y + """ + if not self.shell: + raise Exception("SSH 未连接") + + def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str: + self.shell.send(cmd + "\n") + buf = "" + deadline = time.time() + timeout + while time.time() < deadline: + if self.shell.recv_ready(): + buf += self.shell.recv(4096).decode('utf-8', errors='ignore') + if expect in buf: + return buf + else: + time.sleep(0.2) + return buf + + # 进入系统视图 + out = send_and_wait("system-view", "]") + if "]" not in out: + raise Exception("进入 system-view 失败") + + # 进入端口 + out = send_and_wait(f"interface Onu{port_id}", "]") + if "]" not in out: + raise Exception(f"进入端口 Onu{port_id} 失败") + + # 执行 default,等待确认提示 + self.shell.send("default\n") + buf = "" + deadline = time.time() + 10 + while time.time() < deadline: + if self.shell.recv_ready(): + buf += self.shell.recv(4096).decode('utf-8', errors='ignore') + if "[Y/N]" in buf or "[y/n]" in buf: + break + else: + time.sleep(0.2) + + if "[Y/N]" not in buf and "[y/n]" not in buf: + raise Exception("未收到确认提示") + + # 确认 + self.shell.send("Y\n") + time.sleep(1) + # 排空缓冲区 + if self.shell.recv_ready(): + self.shell.recv(4096) + + # 退出到用户视图 + send_and_wait("quit", "]", timeout=5) + send_and_wait("quit", ">", timeout=5) + + return True + + def detect_loopback(self) -> dict: + """执行环路检测,返回 {has_loop: bool, interfaces: [str]}""" + output = self.execute_command("display loopback-detection") + has_loop = "Loop is detected on following interfaces" in output + interfaces = [] + if has_loop: + for line in output.splitlines(): + m = re.match(r'\s+(Onu\S+)\s+', line) + if m: + interfaces.append(m.group(1)) + return {"has_loop": has_loop, "interfaces": interfaces, "raw": output} + + def parse_onu_status(self, output: str) -> Dict[str, str]: + """解析 ONU 状态输出""" + devices = {} + lines = output.split('\n') + + for line in lines: + # 匹配包含 MAC 地址的行 + mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE) + if mac_match: + mac = mac_match.group(1).lower() + # 提取状态字段 + if 'Up' in line: + devices[mac] = 'online' + elif 'Offline' in line: + devices[mac] = 'offline' + + return devices + + def parse_onu_info(self, output: str) -> Tuple[Dict[str, ONUInfo], Dict[str, List[ONUInfo]]]: + """增强解析:提取完整 ONU 信息 + 返回: (unique_devices, duplicate_devices) + - unique_devices: MAC -> ONUInfo(每个 MAC 只保留最新端口) + - duplicate_devices: MAC -> [ONUInfo, ...] (出现在多个端口的 MAC) + """ + all_records: Dict[str, List[ONUInfo]] = {} + lines = output.split('\n') + + current_slot = None + + for line in lines: + # 检测新的插槽区域: Olt1/0/1 + slot_match = re.search(r'Olt(\d+)/(\d+)/(\d+)', line) + if slot_match: + current_slot = int(slot_match.group(3)) + continue + + # 跳过表头行和空行 + if 'MAC' in line and 'LOID' in line: + continue + if not line.strip(): + continue + + # 解析设备行 + device = self._parse_device_line(line, current_slot) + if device: + if device.mac_address not in all_records: + all_records[device.mac_address] = [] + all_records[device.mac_address].append(device) + + unique_devices: Dict[str, ONUInfo] = {} + duplicate_devices: Dict[str, List[ONUInfo]] = {} + + for mac, records in all_records.items(): + if len(records) == 1: + unique_devices[mac] = records[0] + else: + duplicate_devices[mac] = records + # unique 中保留在线的,若都离线则保留最后一条 + online = [r for r in records if r.status == 'online'] + unique_devices[mac] = online[0] if online else records[-1] + + return unique_devices, duplicate_devices + + def _parse_device_line(self, line: str, slot: Optional[int]) -> Optional[ONUInfo]: + """解析单行设备信息""" + # 匹配 MAC 地址 + mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE) + if not mac_match: + return None + + mac = mac_match.group(1).lower() + + # 提取状态 + status = 'offline' + if 'Up' in line: + status = 'online' + + # 提取端口信息: Onu1/0/2:1 -> slot=2, port=1, port_id="1/0/2:1" + slot_num, port_num, port_id = None, None, None + port_match = re.search(r'Onu(\d+)/(\d+)/(\d+):(\d+)', line) + if port_match: + slot_num = int(port_match.group(3)) # 第三段数字为槽位 + port_num = int(port_match.group(4)) # 冒号后为端口号 + port_id = f"{port_match.group(1)}/{port_match.group(2)}/{port_match.group(3)}:{port_match.group(4)}" + + # 提取距离 - Port 列前的字段,如 "<1000" 或 "N/A" + distance_str = None + dist_match = re.search(r'(\S+)\s+Onu\d+/\d+/\d+:\d+', line) + if dist_match: + val = dist_match.group(1) + if val != 'N/A': + distance_str = val + + # 提取 LOID - MAC 后第一个非空字段 + loid = None + loid_match = re.search(r'[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}\s+(\S+)', line, re.IGNORECASE) + if loid_match: + loid_val = loid_match.group(1) + if loid_val != 'N/A' and loid_val.isdigit(): + loid = loid_val + + # 提取设备型号 - Port 列之后的第一个字段,如 "WA6520H-EGPON/A" + model = None + model_match = re.search(r'Onu\d+/\d+/\d+:\d+\s+(\S+)', line) + if model_match: + potential_model = model_match.group(1) + if potential_model != 'N/A': + model = potential_model + + return ONUInfo( + mac_address=mac, + status=status, + distance_m=None, + distance_str=distance_str, + slot_number=slot_num or slot, + port_number=port_num, + port_id=port_id, + loid=loid, + model=model + ) + + def close(self): + """关闭 SSH 连接""" + if self.client: + self.client.close() diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py new file mode 100644 index 0000000..25b0aab --- /dev/null +++ b/backend/app/tasks/__init__.py @@ -0,0 +1,4 @@ +"""Celery 任务模块""" +from app.tasks.check_tasks import check_all_devices # noqa: F401 + +__all__ = ['check_all_devices'] diff --git a/backend/app/tasks/check_tasks.py b/backend/app/tasks/check_tasks.py new file mode 100644 index 0000000..4ddde08 --- /dev/null +++ b/backend/app/tasks/check_tasks.py @@ -0,0 +1,72 @@ +"""状态检查任务""" +import traceback +from app.core.celery_app import celery_app +from app.core.database import SessionLocal +from app.services.check_service import CheckService + + +@celery_app.task(bind=True) +def check_all_devices(self): + """检查所有设备状态""" + db = SessionLocal() + self.update_state(state='PROGRESS', meta={'current': 0, 'total': 0, 'status': '获取OLT列表...'}) + try: + service = CheckService(db) + from app.models.device import OLTDevice + olts = db.query(OLTDevice).all() + + total = len(olts) + self.update_state(state='PROGRESS', meta={'current': 0, 'total': total, 'status': f'准备检查 {total} 个OLT...'}) + + results = [] + errors = [] + total_online = 0 + total_offline = 0 + + for idx, olt in enumerate(olts): + self.update_state(state='PROGRESS', meta={ + 'current': idx, 'total': total, + 'status': f'检查 OLT: {olt.location or olt.ip_address}...' + }) + try: + result = service.update_status_only(olt.id) + total_online += result.get('online', 0) + total_offline += result.get('offline', 0) + results.append({ + 'olt_id': olt.id, + 'olt_name': olt.location or olt.ip_address, + 'online': result.get('online', 0), + 'offline': result.get('offline', 0), + 'success': True + }) + except Exception as e: + errors.append({ + 'olt_id': olt.id, + 'olt_name': olt.location or olt.ip_address, + 'error': str(e) + }) + results.append({ + 'olt_id': olt.id, + 'olt_name': olt.location or olt.ip_address, + 'success': False, + 'error': str(e) + }) + + self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'}) + + return { + 'success': True, + 'total_olts': total, + 'total_online': total_online, + 'total_offline': total_offline, + 'results': results, + 'errors': errors + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'traceback': traceback.format_exc() + } + finally: + db.close() diff --git a/backend/celery_worker.py b/backend/celery_worker.py new file mode 100644 index 0000000..7036d9c --- /dev/null +++ b/backend/celery_worker.py @@ -0,0 +1,5 @@ +"""Celery Worker 入口模块""" +from app.core.celery_app import celery_app +from app.tasks import check_tasks # noqa: F401 - 导入以注册任务 + +__all__ = ['celery_app'] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0aeb435 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,20 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy==2.0.25 +alembic==1.13.1 +psycopg2-binary==2.9.9 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +celery==5.3.6 +redis==5.0.1 +paramiko==3.4.0 +pandas==2.1.4 +openpyxl==3.1.2 +cryptography==42.0.0 +casdoor==1.18.0 +aiohttp>=3.9.0 +PyJWT>=2.8.0 +requests>=2.31.0 diff --git a/backend/scripts/init_db.py b/backend/scripts/init_db.py new file mode 100644 index 0000000..ff23ed4 --- /dev/null +++ b/backend/scripts/init_db.py @@ -0,0 +1,11 @@ +"""数据库初始化脚本""" +from app.core.database import engine, Base +from app.models import device, user, permission + +def init_db(): + """创建所有表""" + Base.metadata.create_all(bind=engine) + print("数据库表创建完成") + +if __name__ == "__main__": + init_db() diff --git a/backend/templates/OLT设备导入模板.xlsx b/backend/templates/OLT设备导入模板.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..740b2f62abb20cd3f9bc17d04cd7c6bf5dc8d0ab GIT binary patch literal 5158 zcmZ`-1ymGo_g-2;X@Lb6X_Y0Gl1^zU1zcD_Y6TRQ?nW9>N6yX3jfv=A3!%y!ScJjk#KCIJi^*0N@(HNiWb)u~S|74d$sIv&b;Z!p>aF z(azq9*VNvg$KBRO9i>j(!bgI?(D=}`@@)ht=f-`>i1a!Xk4tdH9owMOoh@8xgoj5L zeHKqR3r9tg!XD3xtpI=Lb(wM2Q>osb%!tetuM)^?z{_$VX$}(zr!~uV!ao>7BGR{1 zS%x0vQM7bHCCIsb6w~HtU4jaVm2)x`&M**>t$VUdMN+DXCUW8ZjC|Bf{V08{$L`z$ zuaTgJM(bZL=uT#k>|nOeg$)3Z|I-C?J4g60ANpgp)mr(;IvXQa>gxr^m@|7L%{7Sv zaoQ`RXDUqMLRNBSD=Uj(p&JCd5VxP_+ivzz;ls{^8INcnF|FPL$HErYOUaDkS-4$m zVaDr@4}!|8cVZ_5rADx_gqcqLZ;3n!P#V^Y3n7HI1$XI~M+p)Nxu9QGK5gzqjSlcV z2f)75-j>(ZYZ?`Vq=!@laG3;>b7V9UThS~TUQmj=`KQInyQ&rJoP8`^pQ)0^v(tRd zdr%Wh{MMOITeH%_ z4c7|reP@GDvH#7^TfnotCzyXO_}>&pU^PmHh*|ed6(I zRF1MosIT!~7Z*!|4!C+BwQXg*?3r>Fv;{6K@41Uq^bSYbGvF?0*QU3meYa`MktIZC zTMt@H9!-_RtVVCK}E;3XuKPMdu>Axw7g4YD!c7%b(yQAbBH zx$OL?ofD!>4GBvoy@WC&6lx{?r6X(Y>=D%GNK*;HI`N_D6U#)rBAY@+7kAAHxyWag z>-?0`*V00go@p^)=MX{?GDD7$9Yy2|ls=UW*ShAT!hRImB}+cegsMyh9g`^nmr|Y9 zoQ7=92yzvNj&$p0vJ;`ySc$arf(1h0_Hp+&O?$lNjcH?>3PE})GTYqAG@66G^ApmSvETwk|wHlW2yu>?66(VAYp=ZRlr2nMI z9AsU)3o2!Mm0a9H≪hMkfq({CqQ@!r?=Zi2+5&kh{h(lKfo1NGMLqr0ZLMjLX1y z%nkBb<9nEQ2O^`%8$lr=s>xef4p7{SbmLZ)=8eGKrx#VeA!7nI;8#DCMrl-6NIBk1 zqxl61vXk`*>x1;3WfX)8O67z2iSYKSK~k%p?nL(zI_k7a0==+)4o%%_DLf8r67}Qp zLFMjLf0xW0uDr+UW7#|s37bV>XgRanH0@v`gUO@mAf2Qk zuoZM^rn3|nbt3U(9obJTAYbXI_O&9u;WLMb>i66EyB zpQQQ@^+2(dsGkD(MTmy+FgpcRL>UPlGmYc&&Q9Djx0e&A-~iho9e#^3njZP^W=R1@ z`AHr2gOVu@;^65eo&H*21BZ!Pt0y9joAc+i@u(z{MpE%0`|SegQ>aslXH|)P7oQ4##+n5-gwN+w2n&;ZI&@)=*QbdBUD5j`Z3KxRr7~KAm$kyFlVST4<1K z!9^~uM&Qj9i*NoC_FeGFYbvwiyz=6XDF}&&KlFek^Qp*us6sMOU@WpoG>XVhc1&hf zdV2J_)zNi9h8R3!$lD6x>=g|niJ8lt>t!u%uNJo=8?pXfK$McbQX!bq8jL9-w0{bS zo1NoxCo4GI*@^e}?ROQ4(zUglzD0J)mB6`W&bN?QRn9Gh&eEMi=Fj$8QpC|x)01|6 zygb8JDavnAk#-=EK+Q^qqS|G4y-@C(kR_v0x6IabLf3@o>XKFnpM;%6xl#5Oettyy zw+(@@#?v=~AX@0N@nH?f5MC^zcNa9rU0%P#bN=-EJDzV%dLM5twTr!Wp;HuQ?m;WU z@XZqZoMlBT?{h>oat_#Evkpi+dh3IJcJFghcouQ<&oYC1aFOdTk`dkMo16~Es+xyv z@`0rH;|ije$dqB0x;90NX5!3!w&VJ5(mxBn?+Y3kTmE{$8N?Uy6w!_@0!*>NHJjJk zAdLg2(H`lnz0pmy)6^V%2M}%q|Dwq2>o~9YaeYWgnM|652~zJ5u2L*sGhoWSc0$`Y zW6^i3!9tEtq58mdd+fPK97Kg8@sU{6`yE<;^unN`Ad);Px|u=UL@cd|@Vg6wMDc5a zO9OrsmSuw}GYbnzU?gy~#kO+{Pz! z%kACu4|9Nj%WPp=OX&kfW;clefa`x`=H%>Q19$q>q0H({M@;jPT~IHQ=Eo%^SG5HQ zlifzbO@CyTs)9SQ^&R1&Y-fjQF%0V;lXJJsDTP7(LvIC==@yL+w~N}GScA;p>!BMS z0M~0Yt*3VAhxRXDpo8#pgez8WBpgK_rBT;?jTtR)hNP@o_vUJhb4enn#}94n@V9y_ z5^^?mZNHCwQVzta*w-s#eGLD&sJdPB-OGb(Otgw-W0D>Yc8ZR;9VhB}Isrhn5`AJ= zMn0sE91y%6G(TH)fxr72B&?jfEr8SMIp(k)GzNpZzjA8`n6zq46<6LeIq_&XPBJ9C z#9|RQ=Q7=~2z*}uFw0e@Y=3ZJY3FCO*FWi;wdnuUzjCE2eHk(jn%FV8Cym)o`QT;2h1 zt_)T2;-o5_9hNqW>{%#qYx?>WaQfUmO%1!@JLmHCT)+DTLP6w=iMZtPd5d{e`9Afi zJ<*Q^GGS9C)3ljv0^mxoQ$Z(CeRZ$BhU+#;fnZL~i=PRvL|90}W+l+T{A}FEp13#V9**((zwX3^G4g<3{cAf}(n^)5YJodtp?JMvPGIUovP(`q;_k#|_6R zr^S~DvFz0_XWw3$&2x^HbqPpj9DTjH@St5(Kg1~4F&n`bMTHl<0Pe69M1_A{Iga`f zTEnidN9BN9?vAgo$m~vU9n=GGdKV2o*l7;^q3!9RnLF__XyR(>OpK^yXu@cj5~FL3 ztN~9P;WkdZJio4KvDJuHJ`x$f_Q7;Mf0pMu6bkGn{pNcmc!4$a zg?v~504v5((m%obt45gF***UizERpks;x3)=WpzpOdbve=At7NxWjI8`82YnJVnpi zPEI4IO5Nhfz{g7?a|7VkzM1_8-r%rT-HC3@YkB0>G}gdg{JN-nqjFk#97lT2tMWh? zKCB&IrcTZ7lTEs3WOGqM+<3ysW-zX*ED_OB6KIja@5EPKgSK2IbU!U+ajS_9gyUH! z^CqQ_tvvI97-D=o%%wKAyLzTi(To%fy$o=x@79F1VUfeUp+d1K9(jR6j`53TgOy+R za6KguX@itvSjMmb%e<0y`wh6Df+w~4hH0{SIPs-sl+FaSCAnunKDt~9e}bN*yjeJz z4N6)~2fh*Dj3|Jl<`)(gxCf~2<^eC%^X}$DWDfJhv>mwfXB>xL%dQ`7`yRbha2}An zdjvA>)_c@}_S2L8cV=V*rDPRjNIk|74E#gxA0hpd;@_hB93y$-1s_RpDa9&4Nb7?< z-UA~j7Y`ZU0V@SXdkIk`Z!lK0n6v4}bZ)<$ZjB5pDmTQtiUT&q=lDGqz29~KIWS}d z&%iRD<};e2CH)Zmy5ag~WU;qvWG4kI(zoAcrAl1NJG;zUdy8Cr6xk)^t25g8pkQ%F zl^VG6pv9^}xcT8y?}LP_e15u!y9xFNx0bqZ*;?!Qlxb=p*F9+N{ZJ_BUnVS>C$V>T zzP!?9z@t54=`aqWaQ^(J!sP7`&TxeD6CDqGxRcQ@6&1&+YhZrkG2X#p>^xxTyF7Xm zVk3Jjb4_63Wt^qK@U(!&lR|OE_qjS$dScPsYx1&#XgRK5XBunwtgZ#C zvPE90JM+iGZ)dU^uon$6{mU05*7sHd%%E$iW3LXHpY#KJLcTlu@WxC*_Rwf}}1VH%TvU2r7-z> J_}2^#@PCyi^%?*G literal 0 HcmV?d00001 diff --git a/backend/templates/设备导入模板.xlsx b/backend/templates/设备导入模板.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..58b652b3a6c805344d6d380913ba7b987c4293f2 GIT binary patch literal 5248 zcmZ`-1yoZH`yQ!;z!)JTloVxj2na|B(xHHZNi$(ccSt$`L6njlASp`gXrvoqG)N1h zL|R7Uf9m;u!T9T8A3Z_`dX_kGtUKJ@CTX}(!>IeO6igP{*lr;(5`Q6E;?Q>$DEMCgpUTrj?~~aXa7+voGY*DpRGq*s-OJfa6B%-ZhV=5(-m?!zf$FHoHB!>a(rdi?!z8 z`_8NuKEAR4I%d)-_-}TSAEG#ENC5!UH2?sFXUE%K(A^g90RM9q{>9I_G0bg3k}hDg zq~F^H=Rnae5nO==xgk6!(aFel?JLiK&?tK!>lkEU!(9oQo`g&5v062$GQ^P8<38oP z%PW#TyBh+QE1}`>JKpAB1SdypD+Oof9*A%O6@-}HmTWbW=W3dk8zTTMlKx#F+Lo!1 z$cq}@bUmXF=U`w#&e3n^$!S9*!anozU2AiHQ;4m6zrLQali5}D;uX;Y`X2o#cs2p( zfdWr;IC|&MzU^dUMh=cym1Y$Qb`)ZUUJdMXdW+RI4*#m(+}dbobKCH7$H1U!P)L`5 zJV}ijE_;anqR6FN(rM5>&nLLn^^6zY6YgRT;JJluZ^??D!ANHo;yJzA^yai5_6<1- zWaw-}zx6n7qBth+ebjxOp?&|^wNzUac@#`%=&{%I(97rTMg88F>bG^mT8f{y7xcTY zA$YEx7C+8eWw2FLSy=O>r7vHUAG!*FI(v?O9wbUIy?*2o5B|bhJFW0=3%Vio9C+5= zMm&lbWJ(ZIaZQURp#t1&9njQ$Qld~1G1nYB_%M1DbG=*_d@-dnc<*iqd)rjW>0V-O zSB|J}n!j}4C0m0k#+Eh&3iQ5V`<89oSmSUfC9fWNAm=k6ZcFpk?$W@xfM)3&- zhl|PO7Qt*DlCNpY*jzD8C^f-gu<0*cIcukJP=B1xT}RHbca0x8#^P1DlruW{Yp_7c za=yCl#Qj z3OeJ~Zu z7%E__=}r89`y!)(4Q!#3>M844E>Pmrbki2Krj?MMN2gT*p(CR9keB;+hZ)te)I4u* zuZoCW$WAdL`-C*K%Xl9qCYR49LQb+>%_g_#>rH+$p}kJGIK+?O=fK3x=7NKeMyWsn ze@yOX^$*$1!OEMQ{x(fRk+2yRM_B7|vGBy0m;kTU1J~Zp4*in2>*G=xjo(jg<k3Z{27mTd{_5llABi!EZ*;rYkZ#uHN5Me?7KXQq5Z> z1H-PCWV*{iFo!Z`%jjMTQKd>(jc*n4_2oR0>OaapJ8QG`P~NhUiDj+udc!Jyj5G1X za^?!%PgJS`E7lV^)TB*GOQt8qRFext$IBAcoEa5$W>5mP87OE~t>PK;)U$S(?$sdifC{kT3_0AVAY?HS$%TGxLfrT5<&TE;hk1VI6ihVgej}pC_%y&+myY5mi-dZ;M zVWLzVfSX=8a*VsBs)_Px0}{9^6LP+0A!T3`!Rl_rMw5aFdgHt9Y!QZ7=dlDO9Z`JR zp{%}6-&Z6j6{rk(8metN$W2QZQA$a|&gi{fc-BrEEw zG_KFRTRg!-@oaKlzqb}#&ttC9;_H~kf9>a_>98!CQC4L)J9&=nW0+g2Z&kB$;ZNu& z+o8y;l=GzY43X&)2%?k|(BvNx5deqk+}%y^_s{PsQe7tlbpDXCD$ zp8*w!kL0vo)P6f1<*zoS_B%am;=a9`zL@#c%wq&SxON{T1vreVpeP|;$llntAF*3 z?JXdDHKC;KY5%LqTW}Sl! zu1~CeHolu_67t~TF2ul_z(VU$4x?%zq>Q!*5rqg(xuNk`F++PcR(mw&h#p|JDrrKonR zttD)yfvV*D`dkCcmtX=dXJG-f}#Wyvqx@V z?kdtLXPB9KO*~%Uev0_vYEP(Rzwvz9+cMH(G>_ax#0950-vvwc?GCB^F!xiJfx5jO za#4P+JLG!gCpc4c2u&ui9r=P6URVMdzt8X+%hJ)G`k@ZUUlV0Yz)pZtL9afikI(z(1;SsxoZ&lWm~|}|NR`OH zV}T3$m=qf9>pyFH!Ps%SydvJXXTsy=lE&3|6Z83J@kc_Xn8*8Foo9f53wUlr_wGBq zfEg$NfJ=V_?B?!c4|n^u`%D{7MobFPoYH@#&W}qKlbW{CxOEq^cNK#+l&|x9AAoI4R$wF#x88{3FW(`W|y+pBOK4cDm}6bn6wH zglg`FC{c&+h|4l^1P1kf=~W**ZrgymrMhl@=u>}?^pNa~fa8`GpT(wi$m37@@F;W@el9;Zf~DX#=hJm3Y78 zMv>}?S!M39iX=~23+{H42gl5Pb21r@0=8-<&*SnX76#coXROVGz8!4u9tYWHP(`=J63N^`f&2|)uq9b`W2Whtv!#w|nJ&4*5tKl{>q;nxI^CmS?VfSW6 zAiDu>Z=xZ)n@wT+dcHn7xnn<(W9OT4Vni)VBVNlOysq)G2AH|R?cD?gex1`|s~z!g zJMw{T{pms%`q{%p;oA(vhXjUM)Z?@y5z1)|B@?+W(G*NRvNE(&YzX;v$()MFcb89T z?jejvst8moxL+aqVib#qA#CDdgYtJKt&~cQA#(BpOphu*Su?zK%Xtv~a4aoXc2ZDB z!6~RjTGK@lp{gNDwbeTqD6+pxDXzSz&iNV!_8wY1^PE!~$mD=Bq$qa{WwGW4vUcF2 zhgM6hIMBYZl|vU>ee#!6R@^UKEYWA;Azjqp1I`sMxQ4kvhyVcK#3xGqr+9x&A4?~v z$G^%qN^d~DMV{v5weuD8djlc4tC7n5;S7BK4P2>@R;L}tC(#onUU4*#gZZJEK1j>w zsU6KHknopXiC*kWc|Zgs0^CDd7j<)3Q8$kVXXw7D1cs5q+DPT=G@L%#r~5`W6(+=u zCJe1TBUV=+C&x9iePsze4A9cPTqYm3la~5*y^)KJ$hS`Zb?RrXvdmoyVN>34kJ{L- z>Z#8vmedgFS+LiqE*)4a0TA{CDju8alNTcH8voU@zw+BQv9F9{T0cmdz!Vm2lULm4 zyaE?f_NBL4u}HCcPI0Car9TF3PU-GbiY~iLI>t;{)+CX_1*NWLf=~s!JH8i2h)^p*{pK=|1rLc_K2*ABjcJGtDfnzi6GQ8iu8fbX?-<^?* zEj6nMPwD}lVDKMu{|M=y6#o`gd5kR8Qz6P{CA5nGaou-HB$_5rJ^>n%T~1oswqo*1 z!Dj@~((V=?()j~7yRAW1@RP1u8JSOe7?)kn6$bq3F1o{?)bnF-l=Z!*1>jppB z(ZurBD2$6*r*9-@p=8dK+&!idPb5!2NN!OJ)tRikQ?|aYP7lUvHrrN6G~Ju;(M-t7 z7h#IHk>G56b-wGW1H#b1R7V@V?8A6-U%9wH9dt-AsO|s3=YgZxGPnvxCF91t8EjdCcY%Ce8#_I^cq{I2+@^Nm1wH2Xb^ zPj#C>-tT$+;@3t*(XLMKyjOjgwYId98!e4wqMAS3sWWAZtJ99PHLYEV|8^(8+O6N* zhC=|V8&QCKc*yGf&F_}Hx{6X+%yZLRtJf<<^3ukQz7zA^OiGhr66yR;3khH=!gpmv zS8dh?+=aDx(uH1NYQLN`JtFVy)*=T=l@SMaq;Yml8(4FyTIZE`vwzt8eypGkds-jU zyKqX0xQPw6gf3wY{Ms3RGWTr*n>QM6m(`7Gg9Dc$zuqYoAy^>)9p&%k;ym /dev/null 2>&1; then + echo -e "${RED}错误: Docker未运行或当前用户无权限${NC}" + exit 1 + fi +} + +# 函数:检查Docker Compose是否安装 +check_docker_compose() { + if ! command -v docker-compose &> /dev/null; then + echo -e "${RED}错误: Docker Compose未安装${NC}" + exit 1 + fi +} + +# 函数:启动服务 +start_services() { + echo -e "${GREEN}启动服务...${NC}" + docker-compose -f "$COMPOSE_FILE" up -d + + # 等待服务启动 + echo -e "${BLUE}等待服务启动...${NC}" + sleep 10 + + # 检查服务状态 + check_services_status +} + +# 函数:停止服务 +stop_services() { + echo -e "${YELLOW}停止服务...${NC}" + docker-compose -f "$COMPOSE_FILE" down +} + +# 函数:重启服务 +restart_services() { + echo -e "${YELLOW}重启服务...${NC}" + docker-compose -f "$COMPOSE_FILE" restart +} + +# 函数:查看日志 +show_logs() { + echo -e "${BLUE}查看日志 (Ctrl+C退出)...${NC}" + docker-compose -f "$COMPOSE_FILE" logs -f +} + +# 函数:构建镜像 +build_images() { + echo -e "${GREEN}构建Docker镜像...${NC}" + docker-compose -f "$COMPOSE_FILE" build --no-cache + + # 清理未使用的镜像 + echo -e "${BLUE}清理未使用的镜像...${NC}" + docker image prune -f +} + +# 函数:查看服务状态 +show_status() { + echo -e "${BLUE}服务状态:${NC}" + docker-compose -f "$COMPOSE_FILE" ps + + echo -e "\n${BLUE}容器资源使用:${NC}" + docker stats --no-stream $(docker-compose -f "$COMPOSE_FILE" ps -q) +} + +# 函数:进入容器 +exec_container() { + echo -e "${BLUE}选择要进入的容器:${NC}" + echo "1) backend (后端API)" + echo "2) frontend (前端)" + echo "3) celery-worker (Celery Worker)" + echo "4) celery-beat (Celery Beat)" + echo "5) nginx (Nginx)" + echo "6) redis (Redis)" + + read -p "请输入数字 (1-6): " choice + + case $choice in + 1) CONTAINER="backend" ;; + 2) CONTAINER="frontend" ;; + 3) CONTAINER="celery-worker" ;; + 4) CONTAINER="celery-beat" ;; + 5) CONTAINER="nginx" ;; + 6) CONTAINER="redis" ;; + *) + echo -e "${RED}无效的选择${NC}" + exit 1 + ;; + esac + + echo -e "${GREEN}进入 $CONTAINER 容器...${NC}" + docker-compose -f "$COMPOSE_FILE" exec "$CONTAINER" sh +} + +# 函数:备份数据 +backup_data() { + BACKUP_DIR="backups/$(date +%Y%m%d_%H%M%S)" + mkdir -p "$BACKUP_DIR" + + echo -e "${GREEN}开始备份数据...${NC}" + + # 备份数据库 + echo -e "${BLUE}备份数据库...${NC}" + DB_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q backend) + if [ -n "$DB_CONTAINER" ]; then + docker exec "$DB_CONTAINER" pg_dump "$DATABASE_URL" > "$BACKUP_DIR/database.sql" + echo "数据库备份完成: $BACKUP_DIR/database.sql" + else + echo -e "${YELLOW}警告: 数据库容器未运行${NC}" + fi + + # 备份Redis数据 + echo -e "${BLUE}备份Redis数据...${NC}" + REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis) + if [ -n "$REDIS_CONTAINER" ]; then + docker exec "$REDIS_CONTAINER" redis-cli --rdb /data/dump.rdb + docker cp "$REDIS_CONTAINER:/data/dump.rdb" "$BACKUP_DIR/redis.rdb" + echo "Redis备份完成: $BACKUP_DIR/redis.rdb" + else + echo -e "${YELLOW}警告: Redis容器未运行${NC}" + fi + + # 备份配置文件 + echo -e "${BLUE}备份配置文件...${NC}" + cp .env "$BACKUP_DIR/" + cp "$COMPOSE_FILE" "$BACKUP_DIR/" + + # 创建备份信息文件 + echo "备份时间: $(date)" > "$BACKUP_DIR/backup.info" + echo "环境: $ENV" >> "$BACKUP_DIR/backup.info" + echo "版本: $(git describe --tags 2>/dev/null || echo '未知')" >> "$BACKUP_DIR/backup.info" + + # 压缩备份文件 + echo -e "${BLUE}压缩备份文件...${NC}" + tar -czf "$BACKUP_DIR.tar.gz" "$BACKUP_DIR" + rm -rf "$BACKUP_DIR" + + echo -e "${GREEN}备份完成: $BACKUP_DIR.tar.gz${NC}" +} + +# 函数:恢复数据 +restore_data() { + echo -e "${YELLOW}警告: 恢复数据将覆盖现有数据${NC}" + read -p "请输入要恢复的备份文件路径: " BACKUP_FILE + + if [ ! -f "$BACKUP_FILE" ]; then + echo -e "${RED}错误: 备份文件不存在${NC}" + exit 1 + fi + + # 解压备份文件 + BACKUP_DIR="${BACKUP_FILE%.tar.gz}" + echo -e "${BLUE}解压备份文件...${NC}" + tar -xzf "$BACKUP_FILE" + + # 停止服务 + echo -e "${BLUE}停止服务...${NC}" + docker-compose -f "$COMPOSE_FILE" down + + # 恢复数据库 + if [ -f "$BACKUP_DIR/database.sql" ]; then + echo -e "${BLUE}恢复数据库...${NC}" + # 这里需要根据实际情况调整数据库恢复命令 + # 例如: psql -d database -f backup.sql + echo "数据库恢复命令需要根据实际情况配置" + fi + + # 恢复Redis + if [ -f "$BACKUP_DIR/redis.rdb" ]; then + echo -e "${BLUE}恢复Redis数据...${NC}" + # 这里需要根据实际情况调整Redis恢复命令 + echo "Redis恢复命令需要根据实际情况配置" + fi + + # 恢复配置文件 + if [ -f "$BACKUP_DIR/.env" ]; then + echo -e "${BLUE}恢复配置文件...${NC}" + cp "$BACKUP_DIR/.env" . + fi + + # 清理临时文件 + rm -rf "$BACKUP_DIR" + + # 启动服务 + echo -e "${BLUE}启动服务...${NC}" + docker-compose -f "$COMPOSE_FILE" up -d + + echo -e "${GREEN}数据恢复完成${NC}" +} + +# 函数:检查服务状态 +check_services_status() { + echo -e "${BLUE}检查服务健康状态...${NC}" + + # 检查后端服务 + if curl -f http://localhost:8000/health > /dev/null 2>&1; then + echo -e "${GREEN}✓ 后端服务运行正常${NC}" + else + echo -e "${RED}✗ 后端服务异常${NC}" + fi + + # 检查前端服务 + if curl -f http://localhost:8080 > /dev/null 2>&1; then + echo -e "${GREEN}✓ 前端服务运行正常${NC}" + else + echo -e "${RED}✗ 前端服务异常${NC}" + fi + + # 检查Redis + REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis) + if [ -n "$REDIS_CONTAINER" ] && docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; then + echo -e "${GREEN}✓ Redis服务运行正常${NC}" + else + echo -e "${RED}✗ Redis服务异常${NC}" + fi + + echo -e "\n${GREEN}部署完成!${NC}" + echo -e "前端访问: ${BLUE}http://localhost:8080${NC}" + echo -e "API文档: ${BLUE}http://localhost:8000/docs${NC}" + echo -e "健康检查: ${BLUE}http://localhost:8000/health${NC}" +} + +# 主程序 +main() { + # 显示标题 + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN} H3C ONU设备管理系统 - 部署工具${NC}" + echo -e "${GREEN}========================================${NC}" + echo "" + + # 检查依赖 + check_docker + check_docker_compose + + # 根据操作执行相应命令 + case "$ACTION" in + up) + start_services + ;; + down) + stop_services + ;; + restart) + restart_services + ;; + logs) + show_logs + ;; + build) + build_images + ;; + ps) + show_status + ;; + exec) + exec_container + ;; + backup) + backup_data + ;; + restore) + restore_data + ;; + help|--help|-h) + show_help + ;; + *) + echo -e "${RED}错误: 未知操作 '$ACTION'${NC}" + show_help + exit 1 + ;; + esac +} + +# 执行主程序 +main "$@" \ No newline at end of file diff --git a/deploy/scripts/init-db.sh b/deploy/scripts/init-db.sh new file mode 100755 index 0000000..a73e8bc --- /dev/null +++ b/deploy/scripts/init-db.sh @@ -0,0 +1,389 @@ +#!/bin/bash + +# H3C ONU设备管理系统 - 数据库初始化脚本 +# 用于初始化数据库和创建基础数据 + +set -e + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 加载环境变量 +if [ -f "../.env" ]; then + set -a + source ../.env + set +a +else + echo -e "${RED}错误: .env 文件不存在${NC}" + echo "请先复制 .env.example 为 .env 并配置数据库连接" + exit 1 +fi + +# 检查数据库连接字符串 +if [ -z "$DATABASE_URL" ]; then + echo -e "${RED}错误: DATABASE_URL 未设置${NC}" + exit 1 +fi + +# 从DATABASE_URL解析连接信息 +parse_db_url() { + local url="$1" + # 格式: postgresql://username:password@host:port/database + local pattern="postgresql://([^:]+):([^@]+)@([^:]+):([^/]+)/(.+)" + + if [[ $url =~ $pattern ]]; then + DB_USER="${BASH_REMATCH[1]}" + DB_PASSWORD="${BASH_REMATCH[2]}" + DB_HOST="${BASH_REMATCH[3]}" + DB_PORT="${BASH_REMATCH[4]}" + DB_NAME="${BASH_REMATCH[5]}" + else + echo -e "${RED}错误: 无法解析DATABASE_URL${NC}" + exit 1 + fi +} + +parse_db_url "$DATABASE_URL" + +# 函数:检查PostgreSQL连接 +check_postgres_connection() { + echo -e "${BLUE}检查PostgreSQL连接...${NC}" + + if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "SELECT 1;" > /dev/null 2>&1; then + echo -e "${GREEN}✓ PostgreSQL连接成功${NC}" + return 0 + else + echo -e "${RED}✗ PostgreSQL连接失败${NC}" + return 1 + fi +} + +# 函数:检查数据库是否存在 +check_database_exists() { + echo -e "${BLUE}检查数据库 '$DB_NAME' 是否存在...${NC}" + + if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" > /dev/null 2>&1; then + echo -e "${GREEN}✓ 数据库已存在${NC}" + return 0 + else + echo -e "${YELLOW}数据库不存在${NC}" + return 1 + fi +} + +# 函数:创建数据库 +create_database() { + echo -e "${BLUE}创建数据库 '$DB_NAME'...${NC}" + + # 创建数据库 + PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres < /dev/null + return + fi + + # 运行迁移 + if command -v alembic &> /dev/null; then + # 设置数据库URL环境变量 + export DATABASE_URL + + # 初始化迁移(如果第一次运行) + if [ ! -d "alembic/versions" ]; then + echo -e "${BLUE}初始化Alembic迁移...${NC}" + alembic init alembic + # 需要手动配置alembic.ini中的sqlalchemy.url + fi + + # 创建迁移版本 + echo -e "${BLUE}创建迁移版本...${NC}" + alembic revision --autogenerate -m "初始迁移" + + # 应用迁移 + echo -e "${BLUE}应用迁移...${NC}" + alembic upgrade head + + echo -e "${GREEN}✓ 数据库迁移完成${NC}" + else + echo -e "${YELLOW}警告: alembic 命令未找到,跳过迁移${NC}" + echo "请安装: pip install alembic" + fi + + # 返回原目录 + cd - > /dev/null +} + +# 函数:创建基础数据 +create_base_data() { + echo -e "${BLUE}创建基础数据...${NC}" + + # 检查数据库连接 + if ! check_database_exists; then + echo -e "${RED}错误: 数据库不存在,无法创建基础数据${NC}" + return 1 + fi + + # 创建基础数据SQL + BASE_DATA_SQL=$(cat < /dev/null; then + echo -e "${RED}错误: docker-compose 未安装${NC}" + return + fi + + # 获取服务状态 + SERVICES=$(docker-compose -f "$COMPOSE_FILE" ps --services) + + for service in $SERVICES; do + STATUS=$(docker-compose -f "$COMPOSE_FILE" ps --filter "name=$service" --format "table {{.Status}}" | tail -1) + CONTAINER_ID=$(docker-compose -f "$COMPOSE_FILE" ps -q "$service") + + if [ -n "$CONTAINER_ID" ]; then + # 检查容器是否运行 + if docker inspect --format='{{.State.Status}}' "$CONTAINER_ID" | grep -q "running"; then + # 检查健康状态 + HEALTH=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}no healthcheck{{end}}' "$CONTAINER_ID") + + if [ "$HEALTH" = "healthy" ]; then + echo -e "${GREEN}✓ $service: 运行中 (健康)${NC}" + elif [ "$HEALTH" = "no healthcheck" ]; then + echo -e "${GREEN}✓ $service: 运行中${NC}" + else + echo -e "${YELLOW}⚠ $service: 运行中 ($HEALTH)${NC}" + fi + else + echo -e "${RED}✗ $service: 未运行${NC}" + fi + else + echo -e "${RED}✗ $service: 容器不存在${NC}" + fi + done +} + +# 函数:检查系统资源使用 +check_system_resources() { + echo -e "\n${BLUE}=== 系统资源 ===${NC}" + + # CPU使用率 + CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1) + echo -e "CPU使用率: ${CYAN}${CPU_USAGE}%${NC}" + + # 内存使用 + MEM_TOTAL=$(free -m | awk '/Mem:/ {print $2}') + MEM_USED=$(free -m | awk '/Mem:/ {print $3}') + MEM_PERCENT=$((MEM_USED * 100 / MEM_TOTAL)) + + if [ $MEM_PERCENT -lt 70 ]; then + echo -e "内存使用: ${GREEN}${MEM_USED}MB/${MEM_TOTAL}MB (${MEM_PERCENT}%)${NC}" + elif [ $MEM_PERCENT -lt 90 ]; then + echo -e "内存使用: ${YELLOW}${MEM_USED}MB/${MEM_TOTAL}MB (${MEM_PERCENT}%)${NC}" + else + echo -e "内存使用: ${RED}${MEM_USED}MB/${MEM_TOTAL}MB (${MEM_PERCENT}%)${NC}" + fi + + # 磁盘使用 + DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}' | cut -d'%' -f1) + DISK_TOTAL=$(df -h / | awk 'NR==2 {print $2}') + DISK_USED=$(df -h / | awk 'NR==2 {print $3}') + + if [ $DISK_USAGE -lt 70 ]; then + echo -e "磁盘使用: ${GREEN}${DISK_USED}/${DISK_TOTAL} (${DISK_USAGE}%)${NC}" + elif [ $DISK_USAGE -lt 90 ]; then + echo -e "磁盘使用: ${YELLOW}${DISK_USED}/${DISK_TOTAL} (${DISK_USAGE}%)${NC}" + else + echo -e "磁盘使用: ${RED}${DISK_USED}/${DISK_TOTAL} (${DISK_USAGE}%)${NC}" + fi +} + +# 函数:检查容器资源使用 +check_container_resources() { + echo -e "\n${BLUE}=== 容器资源 ===${NC}" + + if ! command -v docker &> /dev/null; then + echo -e "${RED}错误: docker 未安装${NC}" + return + fi + + # 获取所有运行中的容器 + CONTAINERS=$(docker ps --format "{{.Names}}") + + for container in $CONTAINERS; do + # 获取容器资源使用 + STATS=$(docker stats --no-stream --format "{{.CPUPerc}} {{.MemUsage}} {{.MemPerc}}" "$container" 2>/dev/null || echo "0% 0B/0B 0%") + + CPU=$(echo "$STATS" | awk '{print $1}') + MEM_USAGE=$(echo "$STATS" | awk '{print $2}') + MEM_PERCENT=$(echo "$STATS" | awk '{print $3}' | cut -d'%' -f1) + + # 检查是否属于本项目 + if echo "$container" | grep -q "h3c-onu-ms"; then + COLOR="$CYAN" + else + COLOR="$PURPLE" + fi + + echo -e "${COLOR}$container${NC}: CPU=${CPU}, 内存=${MEM_USAGE} (${MEM_PERCENT}%)" + done +} + +# 函数:检查应用健康状态 +check_application_health() { + echo -e "\n${BLUE}=== 应用健康 ===${NC}" + + # 检查后端API + if curl -s -f http://localhost:8000/health > /dev/null 2>&1; then + echo -e "${GREEN}✓ 后端API: 健康${NC}" + + # 获取详细健康信息 + HEALTH_INFO=$(curl -s http://localhost:8000/health) + echo " 详细信息: $HEALTH_INFO" + else + echo -e "${RED}✗ 后端API: 不可用${NC}" + fi + + # 检查前端 + if curl -s -f http://localhost:8080 > /dev/null 2>&1; then + echo -e "${GREEN}✓ 前端应用: 可访问${NC}" + else + echo -e "${RED}✗ 前端应用: 不可访问${NC}" + fi + + # 检查Redis + REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis 2>/dev/null || echo "") + if [ -n "$REDIS_CONTAINER" ]; then + if docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; then + echo -e "${GREEN}✓ Redis: 运行正常${NC}" + else + echo -e "${RED}✗ Redis: 连接失败${NC}" + fi + fi + + # 检查数据库连接 + if [ -n "$DATABASE_URL" ]; then + # 简单检查数据库连接 + if command -v pg_isready &> /dev/null; then + # 从DATABASE_URL解析主机和端口 + DB_HOST=$(echo "$DATABASE_URL" | sed -n 's/.*@\([^:]*\).*/\1/p') + DB_PORT=$(echo "$DATABASE_URL" | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') + + if pg_isready -h "$DB_HOST" -p "$DB_PORT" > /dev/null 2>&1; then + echo -e "${GREEN}✓ 数据库: 可连接${NC}" + else + echo -e "${RED}✗ 数据库: 连接失败${NC}" + fi + fi + fi +} + +# 函数:检查网络连接 +check_network() { + echo -e "\n${BLUE}=== 网络连接 ===${NC}" + + # 检查互联网连接 + if ping -c 1 -W 2 8.8.8.8 > /dev/null 2>&1; then + echo -e "${GREEN}✓ 互联网: 可连接${NC}" + else + echo -e "${YELLOW}⚠ 互联网: 连接失败${NC}" + fi + + # 检查DNS + if nslookup google.com > /dev/null 2>&1; then + echo -e "${GREEN}✓ DNS: 工作正常${NC}" + else + echo -e "${YELLOW}⚠ DNS: 解析失败${NC}" + fi + + # 检查端口监听 + echo -e "端口监听:" + PORTS="80 443 8000 8080 5432 6379" + for port in $PORTS; do + if ss -tuln | grep -q ":$port "; then + SERVICE=$(ss -tuln | grep ":$port " | awk '{print $5}' | cut -d':' -f2) + echo -e " ${GREEN}✓ 端口 $port: 被监听 ($SERVICE)${NC}" + else + echo -e " ${YELLOW}⚠ 端口 $port: 未监听${NC}" + fi + done +} + +# 函数:检查日志错误 +check_log_errors() { + echo -e "\n${BLUE}=== 日志检查 ===${NC}" + + # 检查最近错误日志 + LOG_FILES="/var/log/syslog /var/log/messages /var/log/docker/*.log ../../backend/logs/*.log" + + for log_file in $LOG_FILES; do + if [ -f "$log_file" ]; then + ERROR_COUNT=$(tail -100 "$log_file" | grep -i "error\|exception\|failed\|critical" | wc -l) + if [ "$ERROR_COUNT" -gt 0 ]; then + echo -e "${YELLOW}⚠ $log_file: 最近有 $ERROR_COUNT 个错误${NC}" + + # 显示最近3个错误 + tail -100 "$log_file" | grep -i "error\|exception\|failed\|critical" | tail -3 | while read line; do + echo " - $line" + done + fi + fi + done +} + +# 函数:检查定时任务 +check_scheduled_tasks() { + echo -e "\n${BLUE}=== 定时任务 ===${NC}" + + # 检查Celery Beat + BEAT_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q celery-beat 2>/dev/null || echo "") + if [ -n "$BEAT_CONTAINER" ]; then + if docker logs --tail 10 "$BEAT_CONTAINER" 2>/dev/null | grep -q "beat"; then + echo -e "${GREEN}✓ Celery Beat: 运行中${NC}" + else + echo -e "${YELLOW}⚠ Celery Beat: 可能异常${NC}" + fi + fi + + # 检查Celery Worker + WORKER_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q celery-worker 2>/dev/null || echo "") + if [ -n "$WORKER_CONTAINER" ]; then + ACTIVE_TASKS=$(docker exec "$WORKER_CONTAINER" celery -A app.core.celery_app inspect active 2>/dev/null | grep -c "worker") + if [ "$ACTIVE_TASKS" -gt 0 ]; then + echo -e "${GREEN}✓ Celery Worker: 有 $ACTIVE_TASKS 个活跃任务${NC}" + else + echo -e "${CYAN}ℹ Celery Worker: 无活跃任务${NC}" + fi + fi +} + +# 函数:生成监控报告 +generate_report() { + local timestamp=$(date "+%Y-%m-%d %H:%M:%S") + + echo -e "\n${PURPLE}========================================${NC}" + echo -e "${PURPLE} 监控报告 - $timestamp${NC}" + echo -e "${PURPLE}========================================${NC}" + + check_services_status + check_system_resources + check_container_resources + check_application_health + check_network + check_log_errors + check_scheduled_tasks + + echo -e "\n${PURPLE}========================================${NC}" +} + +# 函数:连续监控 +continuous_monitoring() { + local start_time=$(date +%s) + local iteration=1 + + echo -e "${GREEN}开始监控,间隔: ${INTERVAL}秒${NC}" + if [ "$DURATION" -gt 0 ]; then + echo -e "监控时长: ${DURATION}秒${NC}" + else + echo -e "监控时长: 无限${NC}" + fi + echo "按 Ctrl+C 停止监控" + echo "" + + while true; do + # 检查是否达到监控时长 + if [ "$DURATION" -gt 0 ]; then + local current_time=$(date +%s) + local elapsed=$((current_time - start_time)) + + if [ $elapsed -ge $DURATION ]; then + echo -e "\n${GREEN}监控时长达到,停止监控${NC}" + break + fi + fi + + # 清屏并显示报告 + clear + echo -e "${BLUE}监控迭代 #$iteration - $(date "+%H:%M:%S")${NC}" + generate_report + + # 等待间隔 + sleep "$INTERVAL" + iteration=$((iteration + 1)) + done +} + +# 函数:单次监控 +single_monitoring() { + generate_report +} + +# 主程序 +main() { + # 显示标题 + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN} H3C ONU设备管理系统 - 系统监控${NC}" + echo -e "${GREEN}========================================${NC}" + echo "" + + # 检查参数 + if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + show_help + exit 0 + fi + + # 检查Docker Compose文件 + if [ ! -f "$COMPOSE_FILE" ]; then + echo -e "${YELLOW}警告: $COMPOSE_FILE 不存在,使用默认配置${NC}" + fi + + # 选择监控模式 + if [ "$DURATION" -eq 0 ] && [ "$INTERVAL" -eq 5 ]; then + # 默认单次监控 + single_monitoring + else + # 连续监控 + continuous_monitoring + fi +} + +# 执行主程序 +main "$@" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b65918c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + backend: + build: ./backend + ports: + - "8000:8000" + env_file: + - ./backend/.env + volumes: + - ./backend/logs:/app/logs + restart: unless-stopped + + celery-worker: + build: ./backend + command: celery -A app.core.celery_app worker --loglevel=info + env_file: + - ./backend/.env + restart: unless-stopped + + celery-beat: + build: ./backend + command: celery -A app.core.celery_app beat --loglevel=info + env_file: + - ./backend/.env + restart: unless-stopped + + frontend: + build: ./frontend + ports: + - "5173:5173" + restart: unless-stopped diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..db6ef60 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ef19410 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + H3C ONU设备管理系统 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..250f74e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1778 @@ +{ + "name": "h3c-onu-ms-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "h3c-onu-ms-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.5", + "echarts": "^5.6.0", + "element-plus": "^2.5.0", + "pinia": "^2.1.7", + "vue": "^3.4.0", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.31", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.31.tgz", + "integrity": "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.31", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.31", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz", + "integrity": "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.31", + "@vue/shared": "3.5.31" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.31", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz", + "integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.31", + "@vue/compiler-dom": "3.5.31", + "@vue/compiler-ssr": "3.5.31", + "@vue/shared": "3.5.31", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.31", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.31.tgz", + "integrity": "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.31", + "@vue/shared": "3.5.31" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.31", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.31.tgz", + "integrity": "sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.31" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.31", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.31.tgz", + "integrity": "sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.31", + "@vue/shared": "3.5.31" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.31", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.31.tgz", + "integrity": "sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.31", + "@vue/runtime-core": "3.5.31", + "@vue/shared": "3.5.31", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.31", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.31.tgz", + "integrity": "sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.31", + "@vue/shared": "3.5.31" + }, + "peerDependencies": { + "vue": "3.5.31" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.31", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.31.tgz", + "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.13.6", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.6.tgz", + "integrity": "sha512-XHgwXr8Fjz6i+6BaqFhAbae/dJbG7bBAAlHrY3pWL7dpj+JcqcOyKYt4Oy5KP86FQwS1k4uIZDjCx2FyUR5lDg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/lodash": { + "version": "4.18.0", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.0.tgz", + "integrity": "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.31", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.31.tgz", + "integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.31", + "@vue/compiler-sfc": "3.5.31", + "@vue/runtime-dom": "3.5.31", + "@vue/server-renderer": "3.5.31", + "@vue/shared": "3.5.31" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5f63723 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "h3c-onu-ms-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.6.5", + "echarts": "^5.6.0", + "element-plus": "^2.5.0", + "pinia": "^2.1.7", + "vue": "^3.4.0", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..d372e96 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,6 @@ + + + diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..5fa7bb6 --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,7 @@ +import request from '../utils/request' + +export const getLoginUrl = () => request.get('/auth/login') + +export const handleCallback = (code, state) => request.post('/auth/callback', { code, state }) + +export const getProfile = () => request.get('/auth/profile') diff --git a/frontend/src/api/check.js b/frontend/src/api/check.js new file mode 100644 index 0000000..6df8539 --- /dev/null +++ b/frontend/src/api/check.js @@ -0,0 +1,3 @@ +import request from '../utils/request' + +export const triggerCheck = () => request.post('/check/status') diff --git a/frontend/src/api/device.js b/frontend/src/api/device.js new file mode 100644 index 0000000..ee6129b --- /dev/null +++ b/frontend/src/api/device.js @@ -0,0 +1,17 @@ +import request from '../utils/request' + +export const getDevices = (params) => request.get('/devices', { params }) + +export const getDevice = (id) => request.get(`/devices/${id}`) + +export const getRegions = () => request.get('/devices/regions') + +export const updateDevice = (id, data) => request.put(`/devices/${id}`, data) + +export const provisionService = (data) => request.post('/provision/service', data) + +export const clearAllStatus = () => request.delete('/devices/status/all') + +export const refreshAllStatus = () => request.post('/olt/quick-scan') + +export const refreshDevice = (id) => request.post(`/devices/${id}/refresh`) diff --git a/frontend/src/api/import.js b/frontend/src/api/import.js new file mode 100644 index 0000000..87492a6 --- /dev/null +++ b/frontend/src/api/import.js @@ -0,0 +1,13 @@ +import request from '../utils/request' + +export const uploadExcel = (formData) => { + return request.post('/import/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + +export const downloadTemplate = () => { + return request.get('/import/template', { + responseType: 'blob' + }) +} diff --git a/frontend/src/api/stats.js b/frontend/src/api/stats.js new file mode 100644 index 0000000..5ecba01 --- /dev/null +++ b/frontend/src/api/stats.js @@ -0,0 +1,7 @@ +import request from '../utils/request' + +export const getSummary = () => request.get('/stats/summary') + +export const getTrend = (days = 7) => request.get('/stats/trend', { params: { days } }) + +export const getByRegion = () => request.get('/stats/by-region') diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue new file mode 100644 index 0000000..7232932 --- /dev/null +++ b/frontend/src/components/Layout.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..6760c11 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.use(ElementPlus) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..fffe5c8 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,67 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Layout from '../components/Layout.vue' +import { useAuthStore } from '../stores/auth' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('../views/Login.vue') + }, + { + path: '/callback', + name: 'Callback', + component: () => import('../views/Callback.vue') + }, + { + path: '/', + component: Layout, + redirect: '/dashboard', + meta: { requiresAuth: true }, + children: [ + { + path: '/dashboard', + name: 'Dashboard', + component: () => import('../views/Dashboard.vue') + }, + { + path: '/devices', + name: 'DeviceList', + component: () => import('../views/DeviceList.vue') + }, + { + path: '/import', + name: 'ImportData', + component: () => import('../views/ImportData.vue') + }, + { + path: '/charts', + name: 'Charts', + component: () => import('../views/Charts.vue') + }, + { + path: '/olt', + name: 'OltManage', + component: () => import('../views/OltManage.vue') + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +router.beforeEach((to, from, next) => { + const authStore = useAuthStore() + if (to.meta.requiresAuth && !authStore.token) { + next('/login') + } else if (to.path === '/login' && authStore.token) { + next('/dashboard') + } else { + next() + } +}) + +export default router diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..effaca7 --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,21 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import * as authApi from '../api/auth' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('token') || '') + const user = ref(null) + + const setToken = (newToken) => { + token.value = newToken + localStorage.setItem('token', newToken) + } + + const logout = () => { + token.value = '' + user.value = null + localStorage.removeItem('token') + } + + return { token, user, setToken, logout } +}) diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js new file mode 100644 index 0000000..3821d64 --- /dev/null +++ b/frontend/src/utils/request.js @@ -0,0 +1,16 @@ +import axios from 'axios' + +const request = axios.create({ + baseURL: '/api', + timeout: 30000 +}) + +request.interceptors.request.use(config => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +export default request diff --git a/frontend/src/views/Callback.vue b/frontend/src/views/Callback.vue new file mode 100644 index 0000000..646ca7d --- /dev/null +++ b/frontend/src/views/Callback.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/frontend/src/views/Charts.vue b/frontend/src/views/Charts.vue new file mode 100644 index 0000000..357f127 --- /dev/null +++ b/frontend/src/views/Charts.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..2e9f2e1 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/frontend/src/views/DeviceList.vue b/frontend/src/views/DeviceList.vue new file mode 100644 index 0000000..edc7ff1 --- /dev/null +++ b/frontend/src/views/DeviceList.vue @@ -0,0 +1,454 @@ + + + + + diff --git a/frontend/src/views/ImportData.vue b/frontend/src/views/ImportData.vue new file mode 100644 index 0000000..29d090b --- /dev/null +++ b/frontend/src/views/ImportData.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..cb54b30 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/frontend/src/views/OltManage.vue b/frontend/src/views/OltManage.vue new file mode 100644 index 0000000..fb916d3 --- /dev/null +++ b/frontend/src/views/OltManage.vue @@ -0,0 +1,644 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..54b12d3 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true + } + } + } +}) diff --git a/run_celery.py b/run_celery.py new file mode 100755 index 0000000..ffd0091 --- /dev/null +++ b/run_celery.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +import sys +import os + +# 切换到项目根目录 +os.chdir('/home/v6ole/pyproject/H3ConuMS2') + +# 设置 PYTHONPATH +sys.path.insert(0, '/home/v6ole/pyproject/H3ConuMS2/backend') + +# 设置环境变量 +os.environ['SECRET_KEY'] = 'your-secret-key-change-this' +os.environ['DATABASE_URL'] = 'postgresql://H3C_onu_ms:nEHBFFpBsJfiZp3N@10.10.10.14:5432/H3C_onu_ms' +os.environ['REDIS_URL'] = 'redis://:redis_c4FFJQ@10.10.10.14:6379' +os.environ['CASDOOR_ENDPOINT'] = 'https://casdoor.dhdx.fun' +os.environ['CASDOOR_CLIENT_ID'] = '2289912424bcea5f9859' +os.environ['CASDOOR_CLIENT_SECRET'] = 'cfa97de106762c8293901a8daef11e04f1b10043' +os.environ['CASDOOR_REDIRECT_URL'] = 'http://10.10.10.14:5173/callback' +os.environ['CASDOOR_ORG_NAME'] = 'dahua' +os.environ['CASDOOR_APP_NAME'] = 'H3ConuMS' +os.environ['CASDOOR_CERTIFICATE'] = 'token_jwt_key.pem' + +# 加载 celery app +from celery_worker import celery_app + +# 运行 worker +if __name__ == '__main__': + celery_app.worker_main(['worker', '--loglevel=info']) diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..0776bbc --- /dev/null +++ b/setup.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# 快速启动脚本 + +echo "=== H3C ONU 设备管理系统 ===" +echo "" + +# 检查虚拟环境 +if [ ! -d "backend/venv" ]; then + echo "创建虚拟环境..." + cd backend && python3 -m venv venv && cd .. +fi + +# 激活虚拟环境 +source backend/venv/bin/activate + +# 检查依赖 +if ! python -c "import fastapi" 2>/dev/null; then + echo "安装依赖..." + pip install -r backend/requirements.txt +fi + +# 初始化数据库 +echo "初始化数据库..." +python backend/scripts/init_db.py + +echo "" +echo "✅ 初始化完成!" +echo "" +echo "启动服务:" +echo " 后端: cd backend && uvicorn app.main:app --reload" +echo " 前端: cd frontend && npm run dev" diff --git a/start-backend.sh b/start-backend.sh new file mode 100755 index 0000000..fed18b1 --- /dev/null +++ b/start-backend.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# 启动开发环境 + +echo "启动后端服务..." +cd backend +source venv/bin/activate 2>/dev/null || python -m venv venv && source venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 diff --git a/start-celery.sh b/start-celery.sh new file mode 100755 index 0000000..e334829 --- /dev/null +++ b/start-celery.sh @@ -0,0 +1,18 @@ +#!/bin/bash +cd /home/v6ole/pyproject/H3ConuMS2 + +export PYTHONPATH=/home/v6ole/pyproject/H3ConuMS2/backend + +# 设置环境变量 +export SECRET_KEY="your-secret-key-change-this" +export DATABASE_URL="postgresql://H3C_onu_ms:nEHBFFpBsJfiZp3N@10.10.10.14:5432/H3C_onu_ms" +export REDIS_URL="redis://:redis_c4FFJQ@10.10.10.14:6379" +export CASDOOR_ENDPOINT="https://casdoor.dhdx.fun" +export CASDOOR_CLIENT_ID="2289912424bcea5f9859" +export CASDOOR_CLIENT_SECRET="cfa97de106762c8293901a8daef11e04f1b10043" +export CASDOOR_REDIRECT_URL="http://10.10.10.14:5173/callback" +export CASDOOR_ORG_NAME="dahua" +export CASDOOR_APP_NAME="H3ConuMS" +export CASDOOR_CERTIFICATE="token_jwt_key.pem" + +exec /home/v6ole/pyproject/H3ConuMS2/backend/venv/bin/celery -A celery_worker.celery worker --loglevel=info diff --git a/start-frontend.sh b/start-frontend.sh new file mode 100755 index 0000000..1321913 --- /dev/null +++ b/start-frontend.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# 启动前端服务 + +echo "启动前端服务..." +cd frontend +npm install +npm run dev diff --git a/test_write.txt b/test_write.txt new file mode 100644 index 0000000..e69de29 diff --git a/下发业务流程.md b/下发业务流程.md new file mode 100644 index 0000000..182ccbb --- /dev/null +++ b/下发业务流程.md @@ -0,0 +1,22 @@ +# H3C ONU设备下发上网业务步骤 +1. 先查看需要下发业务的设备MAC在OLT的哪个口下面,需要进入OLT对应的槽位,如slot 1 +`display onu slot 1 | include 1484-778e-a860` +• OLT返回内容 +`1484-778e-a860 4 12414 Onu1/0/2:4 WA6520H-EGPON/A 106/ Up N/A` +2. 进入系统配置 +`system-view` +3. 进入端口设备的端口 +`interface Onu1/0/2:4` +4. 配置ONU +``` +uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010 +port link-type trunk +undo port trunk permit vlan 1 +port trunk permit vlan 2000 3000 to 3010 4094 +``` +5. 保存(输入save force后需要稍等一下才会出现Saved the current configuration to mainboard device successfully.) +``` +[16OLT-Onu1/0/2:4]save force +Validating file. Please wait... +Saved the current configuration to mainboard device successfully. +``` \ No newline at end of file diff --git a/启动指南.md b/启动指南.md new file mode 100644 index 0000000..4ed5d9f --- /dev/null +++ b/启动指南.md @@ -0,0 +1,58 @@ +# 快速启动指南 + +## ✅ 当前状态 +- 后端服务:http://localhost:8000 ✅ 运行中 +- 前端服务:http://localhost:5173 ✅ 运行中 +- 数据库:已初始化 ✅ +- API 文档:http://localhost:8000/docs ✅ + +## 访问系统 +打开浏览器访问:**http://localhost:5173** + +--- + +## 后端服务 + +### 启动命令 +```bash +cd backend +source venv/bin/activate +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### 停止服务 +按 `Ctrl+C` + +## 前端服务 + +### 启动命令 +```bash +cd frontend +npm run dev +``` + +### 访问地址 +- 前端:http://localhost:5173 +- 后端 API:http://localhost:8000 +- API 文档:http://localhost:8000/docs + +## 环境配置 + +确保 `backend/.env` 文件已配置: +- DATABASE_URL +- REDIS_URL +- CASDOOR_* 配置 +- SECRET_KEY + +## 故障排除 + +### 后端启动失败 +1. 检查虚拟环境:`source venv/bin/activate` +2. 检查依赖:`pip list` +3. 检查 .env 配置 + +### 数据库连接失败 +检查 DATABASE_URL 配置是否正确 + +--- +**最后更新**:2026-04-01 diff --git a/开发总结.md b/开发总结.md new file mode 100644 index 0000000..e4cf630 --- /dev/null +++ b/开发总结.md @@ -0,0 +1,128 @@ +# H3C ONU设备管理系统 - 开发总结 + +## 项目完成情况 + +### 已完成阶段 + +**第一阶段(2周)** - ✅ 完成 +- 后端 FastAPI 框架搭建 +- 前端 Vue 3 + Element Plus 框架 +- Casdoor 统一认证集成 +- JWT 令牌管理 +- Docker 部署配置 + +**第二阶段(4周)** - ✅ 完成 +- 数据库模型设计(OLT、ONU、用户、历史) +- SSH 连接服务(支持 More 分页) +- ONU 状态解析逻辑 +- 设备管理 API(列表、详情、筛选) +- Celery 异步任务系统 +- 定时状态检查(每30分钟) +- Excel 批量导入功能 +- 权限管理基础框架 + +## 核心功能实现 + +### 1. SSH 连接和状态检查 +- ✅ Paramiko SSH 连接 +- ✅ 自动处理 "More" 分页 +- ✅ 解析 ONU 在线/离线状态 +- ✅ 支持多 OLT 设备 + +### 2. 认证和权限 +- ✅ Casdoor OAuth 2.0 登录 +- ✅ JWT 令牌管理 +- ✅ 基于角色的权限控制(RBAC) +- ✅ 用户信息同步 + +### 3. 设备管理 +- ✅ 设备列表(分页、筛选) +- ✅ 设备详情查看 +- ✅ 按区域/学校筛选 +- ✅ Excel 批量导入 + +### 4. 异步任务 +- ✅ Celery Worker 配置 +- ✅ Celery Beat 定时任务 +- ✅ 手动触发状态检查 +- ✅ 自动定时检查(30分钟) + +## 技术栈 + +**后端**: +- Python 3.11 +- FastAPI +- SQLAlchemy +- Celery + Redis +- Paramiko +- Pandas + +**前端**: +- Vue 3 +- Element Plus +- Pinia +- Axios +- Vite + +**部署**: +- Docker +- Docker Compose +- PostgreSQL(外部) +- Redis(外部) + +## 项目结构 + +``` +H3ConuMS2/ +├── backend/ +│ ├── app/ +│ │ ├── api/v1/ # API 路由 +│ │ ├── core/ # 核心配置 +│ │ ├── models/ # 数据模型 +│ │ ├── schemas/ # Pydantic Schema +│ │ ├── services/ # 业务逻辑 +│ │ ├── tasks/ # Celery 任务 +│ │ └── middleware/ # 中间件 +│ ├── alembic/ # 数据库迁移 +│ └── requirements.txt +├── frontend/ +│ └── src/ +│ ├── api/ # API 调用 +│ ├── views/ # 页面组件 +│ ├── stores/ # 状态管理 +│ └── router/ # 路由配置 +├── .claude/rules/ # Claude 开发规则 +├── CLAUDE.md # 开发指南 +└── docker-compose.yml +``` + +## 下一步开发建议 + +### 短期(1-2周) +1. 完善权限管理系统 +2. 添加统计图表和仪表板 +3. 实现设备变更审核流程 +4. 优化性能和缓存 + +### 中期(1个月) +1. 库存管理模块 +2. 告警通知系统 +3. 报表导出功能 +4. 移动端适配 + +### 长期 +1. 系统监控和日志分析 +2. 数据备份和恢复 +3. 多租户支持 +4. API 版本管理 + +## 启动指南 + +1. 配置环境变量:`cp backend/.env.example backend/.env` +2. 启动服务:`docker-compose up -d` +3. 访问系统:http://localhost:5173 + +--- + +**开发完成日期**:2026年4月1日 +**当前版本**:v0.5.0 diff --git a/开发计划.md b/开发计划.md new file mode 100644 index 0000000..f352b33 --- /dev/null +++ b/开发计划.md @@ -0,0 +1,395 @@ +# H3C ONU设备管理系统 - 开发计划 + +## 项目概述 +基于Python FastAPI + Vue 3的H3C OLT设备监控管理系统,用于监控4000+ ONU设备的在线状态。 + +## 开发周期 +**总周期**: 8-12周 +**开始日期**: 2026年4月 +**预计完成**: 2026年6月 + +## 第一阶段:项目基础搭建 (2周) + +### 第1周:环境搭建和基础框架 +**目标**: 完成开发环境搭建和基础项目结构 + +#### 后端任务 +- [ ] 创建FastAPI项目结构 +- [ ] 配置数据库连接(PostgreSQL + SQLAlchemy) +- [ ] 设置Alembic数据库迁移 +- [ ] 配置日志系统 +- [ ] 创建基础配置管理 +- [ ] 设置单元测试框架 + +#### 前端任务 +- [ ] 创建Vue 3项目(Vite) +- [ ] 集成Element Plus UI库 +- [ ] 配置路由系统(Vue Router) +- [ ] 设置状态管理(Pinia) +- [ ] 配置API请求封装(Axios) +- [ ] 设置开发环境配置 + +#### 部署任务 +- [ ] 创建Dockerfile(前后端) +- [ ] 编写docker-compose.yml +- [ ] 配置Nginx反向代理 +- [ ] 设置环境变量管理 + +### 第2周:认证系统和基础API +**目标**: 实现Casdoor集成和基础API + +#### 后端任务 +- [ ] 集成Casdoor SDK +- [ ] 实现OAuth 2.0登录流程 +- [ ] 创建JWT令牌管理 +- [ ] 实现用户信息同步 +- [ ] 创建基础CRUD API框架 +- [ ] 实现API文档(Swagger/ReDoc) + +#### 前端任务 +- [ ] 实现Casdoor登录页面 +- [ ] 创建登录状态管理 +- [ ] 实现路由守卫(权限控制) +- [ ] 创建基础布局组件 +- [ ] 实现API调用封装 +- [ ] 创建错误处理机制 + +#### 测试任务 +- [ ] 编写认证相关测试 +- [ ] 测试API端点 +- [ ] 测试前端组件 + +## 第二阶段:核心功能开发 (4周) + +### 第3周:数据库设计和设备模型 +**目标**: 完成数据库设计和设备管理基础功能 + +#### 后端任务 +- [ ] 设计并创建数据库表结构 + - [ ] olt_devices表 + - [ ] onu_devices表 + - [ ] device_status_history表 + - [ ] 用户和权限相关表 +- [ ] 创建SQLAlchemy模型 +- [ ] 实现数据库迁移脚本 +- [ ] 创建设备管理API +- [ ] 实现数据验证(Pydantic) + +#### 前端任务 +- [ ] 创建设备列表页面 +- [ ] 实现表格组件 +- [ ] 创建分页组件 +- [ ] 实现搜索和筛选功能 +- [ ] 创建设备详情页面 + +#### 数据库任务 +- [ ] 初始化数据库 +- [ ] 创建测试数据 +- [ ] 配置数据库备份脚本 + +### 第4周:SSH连接和状态检查 +**目标**: 实现SSH连接和设备状态检查功能 + +#### 后端任务 +- [ ] 集成Paramiko SSH库 +- [ ] 实现SSH连接池管理 +- [ ] 创建命令执行和结果解析 +- [ ] 实现设备状态检查服务 +- [ ] 集成Celery异步任务 +- [ ] 配置Redis消息队列 + +#### 前端任务 +- [ ] 创建状态检查控制面板 +- [ ] 实现手动刷新功能 +- [ ] 创建检查进度显示 +- [ ] 实现状态颜色标识 +- [ ] 创建检查历史页面 + +#### 测试任务 +- [ ] 测试SSH连接功能 +- [ ] 测试命令解析逻辑 +- [ ] 测试异步任务处理 + +### 第5周:Excel导入和数据管理 +**目标**: 实现Excel数据导入和批量管理功能 + +#### 后端任务 +- [ ] 集成Pandas库 +- [ ] 实现Excel文件解析 +- [ ] 创建数据导入服务 +- [ ] 实现数据验证和清洗 +- [ ] 创建批量导入API +- [ ] 实现导入历史记录 + +#### 前端任务 +- [ ] 创建数据导入页面 +- [ ] 实现文件上传组件 +- [ ] 创建导入预览功能 +- [ ] 实现导入进度显示 +- [ ] 创建导入历史列表 + +#### 数据处理任务 +- [ ] 编写数据转换脚本 +- [ ] 创建数据验证规则 +- [ ] 实现错误数据处理 + +### 第6周:权限管理系统 +**目标**: 实现完整的RBAC权限管理系统 + +#### 后端任务 +- [ ] 设计权限模型(RBAC) +- [ ] 实现角色和权限管理 +- [ ] 创建权限检查中间件 +- [ ] 实现数据级权限过滤 +- [ ] 集成Casdoor权限同步 +- [ ] 创建权限管理API + +#### 前端任务 +- [ ] 创建用户管理页面 +- [ ] 实现角色管理界面 +- [ ] 创建权限分配组件 +- [ ] 实现权限指令(v-permission) +- [ ] 创建用户资料页面 + +#### 安全任务 +- [ ] 实现密码加密存储 +- [ ] 配置安全中间件 +- [ ] 实现登录尝试限制 + +## 第三阶段:高级功能开发 (3周) + +### 第7周:统计图表和仪表板 +**目标**: 实现数据统计和可视化功能 + +#### 后端任务 +- [ ] 创建统计计算服务 +- [ ] 实现数据聚合查询 +- [ ] 创建统计API端点 +- [ ] 实现趋势分析功能 +- [ ] 创建区域/学校统计 + +#### 前端任务 +- [ ] 集成ECharts图表库 +- [ ] 创建仪表板页面 +- [ ] 实现统计卡片组件 +- [ ] 创建图表组件 +- [ ] 实现数据筛选联动 + +#### 数据任务 +- [ ] 优化统计查询性能 +- [ ] 实现数据缓存 +- [ ] 创建统计数据导出 + +### 第8周:系统设置和审核流程 +**目标**: 实现系统配置和设备变更审核 + +#### 后端任务 +- [ ] 创建系统配置管理 +- [ ] 实现OLT设备配置 +- [ ] 创建定时任务配置 +- [ ] 实现设备变更审核流程 +- [ ] 创建审核通知机制 + +#### 前端任务 +- [ ] 创建系统设置页面 +- [ ] 实现OLT配置管理 +- [ ] 创建任务配置界面 +- [ ] 实现审核中心页面 +- [ ] 创建变更请求表单 + +#### 工作流任务 +- [ ] 设计审核流程状态机 +- [ ] 实现通知系统 +- [ ] 创建审核日志 + +### 第9周:性能优化和测试 +**目标**: 优化系统性能和完善测试 + +#### 性能优化 +- [ ] 数据库查询优化 +- [ ] 实现缓存策略 +- [ ] 优化SSH连接池 +- [ ] 前端资源优化 +- [ ] 实现懒加载 + +#### 测试完善 +- [ ] 编写集成测试 +- [ ] 进行性能测试 +- [ ] 安全漏洞扫描 +- [ ] 兼容性测试 + +#### 文档完善 +- [ ] 完善API文档 +- [ ] 编写用户手册 +- [ ] 创建部署文档 +- [ ] 编写维护指南 + +## 第四阶段:部署和上线 (1-2周) + +### 第10周:生产环境部署 +**目标**: 完成生产环境部署和配置 + +#### 部署任务 +- [ ] 配置生产环境变量 +- [ ] 设置SSL证书 +- [ ] 配置域名和DNS +- [ ] 设置防火墙规则 +- [ ] 配置监控告警 + +#### 数据迁移 +- [ ] 准备生产数据库 +- [ ] 迁移现有数据 +- [ ] 配置数据库备份 +- [ ] 设置数据恢复流程 + +#### 上线准备 +- [ ] 进行最终测试 +- [ ] 准备上线检查清单 +- [ ] 制定回滚计划 +- [ ] 准备用户培训材料 + +### 第11周:上线和监控 +**目标**: 系统正式上线和运行监控 + +#### 上线任务 +- [ ] 执行上线部署 +- [ ] 验证系统功能 +- [ ] 监控系统运行状态 +- [ ] 处理上线问题 + +#### 监控任务 +- [ ] 配置系统监控 +- [ ] 设置性能监控 +- [ ] 配置日志收集 +- [ ] 设置告警规则 + +#### 用户支持 +- [ ] 提供用户培训 +- [ ] 收集用户反馈 +- [ ] 处理用户问题 +- [ ] 收集使用数据 + +## 后续功能规划 + +### 第12周及以后:功能扩展 + +#### 库存管理模块 (2-3周) +- [ ] 库存物品管理 +- [ ] 出入库记录 +- [ ] 库存预警系统 +- [ ] 供应商管理 + +#### 告警通知模块 (1-2周) +- [ ] 设备离线告警 +- [ ] 库存预警通知 +- [ ] 多通道通知(邮件、微信、钉钉) +- [ ] 告警规则配置 + +#### 报表导出模块 (1周) +- [ ] 设备状态报表 +- [ ] 历史记录报表 +- [ ] 统计图表导出 +- [ ] 自定义报表模板 + +#### 移动端适配 (2-3周) +- [ ] 响应式移动端界面 +- [ ] PWA支持 +- [ ] 移动端专属功能 +- [ ] 离线数据支持 + +## 风险管理 + +### 技术风险 +1. **SSH连接稳定性** + - 影响:设备状态检查失败 + - 缓解:连接池、重试机制、备用方案 + +2. **性能问题** + - 影响:系统响应缓慢 + - 缓解:异步处理、缓存优化、分批查询 + +3. **安全性** + - 影响:数据泄露或系统被攻击 + - 缓解:加密存储、访问控制、安全审计 + +### 项目风险 +1. **需求变更** + - 影响:开发进度延迟 + - 缓解:敏捷开发、定期沟通、优先级管理 + +2. **资源不足** + - 影响:开发质量下降 + - 缓解:合理规划、外部支持、功能裁剪 + +3. **集成问题** + - 影响:系统功能不完整 + - 缓解:早期集成测试、备用方案、分阶段集成 + +## 质量保证 + +### 代码质量 +- 代码审查流程 +- 自动化测试覆盖 +- 代码规范检查 +- 性能基准测试 + +### 测试策略 +- 单元测试:核心逻辑 +- 集成测试:模块间交互 +- 系统测试:端到端流程 +- 性能测试:负载和压力 + +### 文档质量 +- 技术文档完整 +- API文档准确 +- 用户手册易懂 +- 部署文档详细 + +## 团队协作 + +### 开发团队角色 +- 后端开发工程师(2人) +- 前端开发工程师(1人) +- 测试工程师(1人) +- DevOps工程师(1人) + +### 沟通机制 +- 每日站会(15分钟) +- 每周迭代评审 +- 每月项目回顾 +- 即时通讯工具支持 + +### 工具支持 +- 代码仓库:Git + GitHub/GitLab +- 项目管理:Jira/Trello +- 文档协作:Confluence/Notion +- 持续集成:Jenkins/GitHub Actions + +## 成功标准 + +### 功能完成度 +- [ ] 核心功能100%完成 +- [ ] 高级功能80%完成 +- [ ] 用户体验满意度≥90% + +### 性能指标 +- [ ] 页面加载时间<3秒 +- [ ] API响应时间<500ms +- [ ] 系统可用性≥99.5% + +### 质量指标 +- [ ] 测试覆盖率≥80% +- [ ] 缺陷密度<0.5/千行代码 +- [ ] 用户反馈满意度≥85% + +### 项目指标 +- [ ] 按时交付率≥90% +- [ ] 预算控制率≥95% +- [ ] 团队满意度≥85% + +--- + +**计划版本**: v1.0 +**制定日期**: 2026年4月1日 +**下次评审**: 2026年4月15日 +**负责人**: 项目经理 \ No newline at end of file diff --git a/开发进度.md b/开发进度.md new file mode 100644 index 0000000..2b8addb --- /dev/null +++ b/开发进度.md @@ -0,0 +1,140 @@ +# H3C ONU设备管理系统 - 开发进度 + +## 项目状态 +**当前版本**: v0.5.0 +**开发日期**: 2026年4月1日 +**状态**: 核心功能已完成 + +--- + +## ✅ 已完成功能 + +### 第一阶段:项目基础搭建 (2周) - 100% + +#### 第1周:环境搭建和基础框架 ✅ +- ✅ 创建FastAPI项目结构 +- ✅ 配置数据库连接(PostgreSQL + SQLAlchemy) +- ✅ 设置Alembic数据库迁移 +- ✅ 创建基础配置管理 +- ✅ 创建Vue 3项目(Vite) +- ✅ 集成Element Plus UI库 +- ✅ 配置路由系统(Vue Router) +- ✅ 设置状态管理(Pinia) +- ✅ 配置API请求封装(Axios) +- ✅ 创建Dockerfile(前后端) +- ✅ 编写docker-compose.yml +- ✅ 设置环境变量管理 + +#### 第2周:认证系统和基础API ✅ +- ✅ 集成Casdoor SDK +- ✅ 实现OAuth 2.0登录流程 +- ✅ 创建JWT令牌管理 +- ✅ 实现用户信息同步 +- ✅ 创建基础CRUD API框架 +- ✅ 实现API文档(Swagger/ReDoc) +- ✅ 创建登录状态管理 +- ✅ 创建基础布局组件 +- ✅ 实现API调用封装 + +--- + +### 第二阶段:核心功能开发 (4周) - 100% + +#### 第3周:数据库设计和设备模型 ✅ +- ✅ 设计并创建数据库表结构 + - ✅ olt_devices表 + - ✅ onu_devices表 + - ✅ device_status_history表 + - ✅ users表 + - ✅ permissions表 +- ✅ 创建SQLAlchemy模型 +- ✅ 创建设备管理API +- ✅ 实现数据验证(Pydantic) +- ✅ 创建设备列表页面 +- ✅ 实现表格组件 +- ✅ 创建分页组件 +- ✅ 实现搜索和筛选功能 +- ✅ 初始化数据库脚本 + +#### 第4周:SSH连接和状态检查 ✅ +- ✅ 集成Paramiko SSH库 +- ✅ 创建命令执行和结果解析 +- ✅ 实现设备状态检查服务 +- ✅ 集成Celery异步任务 +- ✅ 配置Redis消息队列 +- ✅ 创建状态检查控制面板 +- ✅ 实现手动刷新功能 +- ✅ 支持"More"分页处理 + +#### 第5周:Excel导入和数据管理 ✅ +- ✅ 集成Pandas库 +- ✅ 实现Excel文件解析 +- ✅ 创建数据导入服务 +- ✅ 实现数据验证和清洗 +- ✅ 创建批量导入API +- ✅ 创建数据导入页面 +- ✅ 实现文件上传组件 +- ✅ 实现导入进度显示 + +#### 第6周:权限管理和统计 ✅ +- ✅ 设计权限模型(RBAC) +- ✅ 创建权限检查中间件 +- ✅ 创建统计API +- ✅ 实现仪表板统计卡片 +- ✅ 创建布局和导航组件 +- ✅ Celery Beat定时任务(每30分钟) + +--- + +## 📋 待开发功能 + +### 第三阶段:高级功能 (预计2-3周) + +#### 统计图表和可视化 +- ✅ ECharts图表集成 +- ✅ 设备状态趋势图 +- ✅ 区域/学校统计图表 +- [ ] 历史数据可视化 + +#### 系统设置和审核 +- ✅ OLT设备配置管理 +- [ ] 定时任务配置界面 +- [ ] 设备变更审核流程 +- [ ] 审核中心页面 + +#### 性能优化 +- [ ] 数据库查询优化 +- [ ] Redis缓存策略 +- [ ] 前端资源优化 +- [ ] 懒加载实现 + +--- + +## 🎯 核心功能清单 + +| 功能模块 | 状态 | 完成度 | +|---------|------|--------| +| 项目框架 | ✅ | 100% | +| 认证系统 | ✅ | 100% | +| 设备管理 | ✅ | 100% | +| SSH连接 | ✅ | 100% | +| 状态检查 | ✅ | 100% | +| Excel导入 | ✅ | 100% | +| 权限管理 | ✅ | 80% | +| 统计功能 | ✅ | 80% | +| 图表可视化 | ⏳ | 0% | +| 审核流程 | ⏳ | 0% | + +--- + +## 📊 开发统计 + +- **总任务数**: 60+ +- **已完成**: 50+ +- **完成率**: 83% +- **代码行数**: 约3000+行 +- **文件数**: 40+个 + +--- + +**更新日期**: 2026年4月1日 diff --git a/环路检测方法.md b/环路检测方法.md new file mode 100644 index 0000000..6390d19 --- /dev/null +++ b/环路检测方法.md @@ -0,0 +1,34 @@ +在“OLT管理”页面新增“环路检测”按钮,点击后可以检测所有OLT设备是否有环路,并在列表新增一列环路检测列,如果设备有环路,则显示环路的详细信息。环路检测的逻辑如下。 +1. 连接OLT并使用命令 `display loopback-detection` 查看 +2. OLT返回结果表明loopback-detection已启用环路检测功能 +3. 返回 `No loop is detected.`则说明设备正常,没有环路。如果返回“Loop is detected on following interfaces”,就说明有环路了。(可以参考下面返回的结果) +4. 如果是有环路的情况,通过查找当前OLT的端口来确认MAC地址,比如OLT返回“Onu3/0/7:10 ”,就查找这个端口的MAC地址然后列出这个MAC的区域、学校、楼宇、房间号等信息。 + +``` +# 有环路的情况OLT返回的结果 +<7503OLT>display loopback-detection +Loop detection is enabled. +Global loop detection interval is 35 second(s). +Loop is detected on following interfaces: +* indicates the loop protection action was not triggered. + Interface Action mode VLANs/VSI + Onu3/0/7:10 Shutdown 3000 +``` + +``` +# 没有环路的情况OLT返回的结果 +<8OLT>display loopback-detection +Loop detection is enabled. +Global loop detection interval is 35 second(s). +No loop is detected. +``` + +在设备列表页面的设备详情页面中新增一个“更新”按钮,点击后可以单独更新这个MAC的设备状态,流程如下: +1. 通过SSH连接这个MAC所属的OLT +2. 执行命令 `display onu slot 1 | include 1484-778e-a860`,也就是 `display onu slot {槽位} | include {MAC}` +3. OLT返回内容`1484-778e-a860 4 12414 Onu1/0/2:4 WA6520H-EGPON/A 106/ Up N/A`,分析返回的内容来更新设备状态和距离。 + + +设备管理页面的“全部更新”按钮和OLT管理页面的“快速扫描”按钮现在都无法正常使用了,全部更新按钮点击后所有OLT设备都是失败的,快速扫描按钮报错'CheckService' object has no attribute 'scan_and_discover'。 + +设备管理页面的设备详情中的“更新”按钮有点问题,点击后他获取的设备信息都是离线的,但是点击“全部更新”后,设备是在线的,说明这个更新按钮有点问题。 \ No newline at end of file diff --git a/系统设计文档.md b/系统设计文档.md new file mode 100644 index 0000000..8b41b46 --- /dev/null +++ b/系统设计文档.md @@ -0,0 +1,441 @@ +# H3C ONU设备管理系统 - 系统设计文档 + +## 项目概述 +基于Python后端 + Vue前端的H3C OLT设备监控管理系统,用于监控4000+ ONU设备的在线状态,支持权限管理、数据导入、状态检查等功能。 + +## 设计讨论记录 +**讨论时间**: 2026年4月1日 +**参与人员**: 阿森、小柚(AI助手) + +## 一、核心需求 + +### 1.1 设备状态监控 +- 通过SSH连接H3C OLT设备查询ONU状态 +- 支持不同插槽命令:`display onu slot 1`、`display onu slot 3`等 +- 状态判断:返回结果包含"Up"为在线,"Offline"为离线 +- 4000+设备MAC地址和物理位置信息管理 + +### 1.2 数据管理 +- Excel表格导入(4000条记录) +- PostgreSQL数据库存储 +- 设备信息包括:区域、学校名称、楼宇、场所类型、房间号、MAC地址、状态、备注 + +### 1.3 更新频率 +- 自动更新:每30分钟 +- 手动刷新:5分钟冷却限制 +- 历史记录:保存90天 + +### 1.4 权限管理 +- 多级用户权限(超级管理员、管理员、区域管理员、学校管理员、普通用户) +- Casdoor统一认证集成 +- 基于RBAC的权限控制 +- 设备信息变更审核流程 + +### 1.5 部署要求 +- Docker Compose编排 +- 外部PostgreSQL和Redis +- 环境变量配置(.env文件) +- 支持后续功能扩展(库存管理等) + +## 二、系统架构 + +### 2.1 技术栈 +**后端**: +- Python FastAPI +- PostgreSQL +- Paramiko (SSH) +- Celery + Redis (异步任务) +- Pandas (Excel处理) +- Casdoor SDK (认证) + +**前端**: +- Vue 3 + Composition API +- Element Plus UI +- Axios +- Vue Router +- Pinia + +### 2.2 部署架构 +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Nginx反向代理 │ │ FastAPI后端 │ │ Celery Worker │ +│ │◄──►│ │◄──►│ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Vue前端静态文件 │ │ PostgreSQL数据库 │ │ Redis │ +│ │ │ (外部) │ │ (外部) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Casdoor │ + │ 认证服务器 │ + └─────────────────┘ +``` + +## 三、数据库设计 + +### 3.1 核心表结构 + +#### olt_devices (OLT设备表) +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | BIGSERIAL PRIMARY KEY | 主键 | +| ip_address | VARCHAR(45) | OLT设备IP地址 | +| username | VARCHAR(100) | SSH用户名 | +| password | TEXT | SSH密码(加密存储) | +| slot_command | VARCHAR(50) | 插槽命令,如"display onu slot 1" | +| description | TEXT | 设备描述 | +| created_at | TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | 更新时间 | + +#### onu_devices (ONU设备表) +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | BIGSERIAL PRIMARY KEY | 主键 | +| mac_address | VARCHAR(17) | MAC地址 (1484-7790-5200格式) | +| olt_id | BIGINT REFERENCES olt_devices(id) | 关联的OLT设备ID | +| slot_number | INTEGER | 插槽号 | +| port_number | INTEGER | 端口号 | +| region | VARCHAR(100) | 区域(共和乡) | +| school_name | VARCHAR(200) | 学校名称 | +| building | VARCHAR(100) | 楼宇 | +| location_type | VARCHAR(100) | 场所类型 | +| room_number | VARCHAR(50) | 房间号 | +| notes | TEXT | 备注 | +| created_at | TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | 更新时间 | + +#### device_status_history (设备状态历史表) +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | BIGSERIAL PRIMARY KEY | 主键 | +| onu_device_id | BIGINT REFERENCES onu_devices(id) | ONU设备ID | +| status | VARCHAR(20) | 状态(online/offline) | +| checked_at | TIMESTAMP | 检查时间 | +| response_data | TEXT | 原始响应数据 | +| created_at | TIMESTAMP | 创建时间 | + +#### users (用户表 - Casdoor集成) +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | BIGSERIAL PRIMARY KEY | 主键 | +| casdoor_id | VARCHAR(100) UNIQUE | Casdoor用户ID | +| username | VARCHAR(100) | 用户名 | +| email | VARCHAR(255) | 邮箱 | +| role | VARCHAR(50) | 角色 | +| assigned_area | VARCHAR(100) | 分配区域 | +| assigned_school | VARCHAR(200) | 分配学校 | +| is_active | BOOLEAN DEFAULT true | 是否激活 | +| last_login | TIMESTAMP | 最后登录 | +| sync_at | TIMESTAMP | 最后同步时间 | +| created_at | TIMESTAMP | 创建时间 | + +#### permissions (权限表) +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | BIGSERIAL PRIMARY KEY | 主键 | +| name | VARCHAR(100) | 权限名称 | +| code | VARCHAR(50) UNIQUE | 权限代码 | +| module | VARCHAR(50) | 所属模块 | +| description | TEXT | 权限描述 | + +#### role_permissions (角色权限关联表) +| 字段名 | 类型 | 说明 | +|--------|------|------| +| role_id | BIGINT | 角色ID | +| permission_id | BIGINT | 权限ID | +| PRIMARY KEY (role_id, permission_id) | | | + +#### device_change_requests (设备变更请求表) +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | BIGSERIAL PRIMARY KEY | 主键 | +| device_id | BIGINT REFERENCES onu_devices(id) | 设备ID | +| requested_by | BIGINT REFERENCES users(id) | 请求用户 | +| change_type | VARCHAR(50) | 变更类型 | +| current_data | JSONB | 当前数据 | +| requested_data | JSONB | 请求数据 | +| status | VARCHAR(20) | 状态 | +| reviewed_by | BIGINT REFERENCES users(id) | 审核人 | +| reviewed_at | TIMESTAMP | 审核时间 | +| change_reason | TEXT | 变更原因 | +| created_at | TIMESTAMP | 创建时间 | + +## 四、API接口设计 + +### 4.1 认证相关 +``` +POST /api/auth/login # Casdoor登录回调 +GET /api/auth/profile # 获取用户信息 +POST /api/auth/logout # 退出登录 +``` + +### 4.2 设备管理 +``` +GET /api/devices # 获取设备列表(分页、筛选) +GET /api/devices/{id} # 获取设备详情 +GET /api/devices/{id}/history # 获取设备历史状态 +POST /api/devices/import # 导入Excel文件 +PUT /api/devices/{id} # 更新设备信息(管理员) +POST /api/devices/{id}/change-request # 提交变更请求 +``` + +### 4.3 状态检查 +``` +POST /api/check/status # 手动触发状态检查 +GET /api/check/progress # 获取检查进度 +GET /api/check/history # 获取检查历史 +GET /api/check/schedule # 获取定时任务配置 +PUT /api/check/schedule # 更新定时任务配置 +``` + +### 4.4 统计信息 +``` +GET /api/stats/summary # 获取统计摘要 +GET /api/stats/trend # 获取趋势数据 +GET /api/stats/area # 按区域统计 +GET /api/stats/school # 按学校统计 +``` + +### 4.5 权限管理 +``` +GET /api/users # 获取用户列表 +GET /api/users/{id} # 获取用户详情 +PUT /api/users/{id} # 更新用户信息 +GET /api/roles # 获取角色列表 +GET /api/permissions # 获取权限列表 +GET /api/change-requests # 获取变更请求列表 +PUT /api/change-requests/{id}/review # 审核变更请求 +``` + +## 五、前端界面设计 + +### 5.1 页面结构 +1. **登录页面** - Casdoor统一登录 +2. **仪表板** - 统计卡片、图表、手动刷新 +3. **设备列表** - 表格展示、筛选、搜索 +4. **设备详情** - 详细信息、历史图表 +5. **数据导入** - Excel上传、预览、历史 +6. **审核中心** - 变更请求审核 +7. **系统设置** - OLT设备管理、任务配置 +8. **用户管理** - 用户列表、权限分配 + +### 5.2 权限控制 +- **页面级权限**:路由守卫控制 +- **组件级权限**:v-permission指令 +- **数据级权限**:API过滤用户数据 +- **操作级权限**:按钮显示控制 + +## 六、Casdoor集成方案 + +### 6.1 配置参数 +```python +CASDOOR = { + "endpoint": "https://casdoor.example.com", + "client_id": "your_client_id", + "client_secret": "your_client_secret", + "certificate": "your_certificate", + "org_name": "your_org_name", + "app_name": "h3c-onu-ms", + "redirect_url": "http://localhost:8000/api/auth/callback" +} +``` + +### 6.2 登录流程 +1. 前端重定向到Casdoor登录页 +2. 用户登录后回调到系统 +3. 后端验证code获取用户信息 +4. 同步用户信息到本地数据库 +5. 生成JWT令牌返回前端 + +### 6.3 权限映射 +- Casdoor角色 → 系统角色(配置映射) +- Casdoor属性 → 分配区域/学校 +- 支持动态权限同步 + +## 七、Docker部署配置 + +### 7.1 docker-compose.yml +```yaml +version: '3.8' + +services: + backend: + build: ./backend + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql://user:pass@host:5432/dbname + - REDIS_URL=redis://host:6379/0 + - CASDOOR_CONFIG=${CASDOOR_CONFIG} + volumes: + - ./logs:/app/logs + depends_on: + - redis + restart: unless-stopped + + celery-worker: + build: ./backend + command: celery -A app.celery_app worker --loglevel=info + environment: + - DATABASE_URL=postgresql://user:pass@host:5432/dbname + - REDIS_URL=redis://host:6379/0 + volumes: + - ./logs:/app/logs + depends_on: + - redis + restart: unless-stopped + + celery-beat: + build: ./backend + command: celery -A app.celery_app beat --loglevel=info + environment: + - DATABASE_URL=postgresql://user:pass@host:5432/dbname + - REDIS_URL=redis://host:6379/0 + volumes: + - ./logs:/app/logs + depends_on: + - redis + restart: unless-stopped + + frontend: + build: ./frontend + ports: + - "8080:80" + environment: + - VITE_API_BASE_URL=http://localhost:8000 + restart: unless-stopped + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - ./ssl:/etc/nginx/ssl + depends_on: + - backend + - frontend + restart: unless-stopped + + redis: + image: redis:alpine + restart: unless-stopped +``` + +### 7.2 .env.example +```env +# 数据库配置 +DATABASE_URL=postgresql://username:password@host:5432/dbname +REDIS_URL=redis://host:6379/0 + +# Casdoor配置 +CASDOOR_ENDPOINT=https://casdoor.example.com +CASDOOR_CLIENT_ID=your_client_id +CASDOOR_CLIENT_SECRET=your_client_secret +CASDOOR_CERTIFICATE=your_certificate +CASDOOR_ORG_NAME=your_org +CASDOOR_APP_NAME=h3c-onu-ms + +# 应用配置 +SECRET_KEY=your-secret-key-here +DEBUG=false +ALLOWED_HOSTS=localhost,127.0.0.1 + +# SSH配置 +SSH_TIMEOUT=30 +SSH_MAX_CONNECTIONS=10 +``` + +## 八、后续功能规划 + +### 8.1 库存管理模块 +- 设备库存管理 +- 出入库记录 +- 库存预警 +- 供应商管理 + +### 8.2 告警通知模块 +- 设备离线告警 +- 库存预警通知 +- 支持邮件、企业微信、钉钉通知 +- 告警规则配置 + +### 8.3 报表导出模块 +- 设备状态报表 +- 历史记录报表 +- 统计图表导出 +- 自定义报表模板 + +### 8.4 移动端适配 +- 响应式移动端界面 +- PWA支持 +- 移动端专属功能 + +## 九、开发计划 + +### 9.1 第一阶段(基础功能) +1. 项目框架搭建 +2. 数据库设计和迁移 +3. Casdoor集成 +4. 基础API开发 +5. 前端框架搭建 + +### 9.2 第二阶段(核心功能) +1. Excel导入功能 +2. SSH连接和状态检查 +3. 设备列表和详情页 +4. 定时任务调度 +5. 基础权限控制 + +### 9.3 第三阶段(高级功能) +1. 权限管理系统 +2. 变更审核流程 +3. 统计图表 +4. 系统设置页面 +5. 性能优化 + +### 9.4 第四阶段(扩展功能) +1. 库存管理模块 +2. 告警通知系统 +3. 报表导出功能 +4. 移动端适配 + +## 十、风险评估和应对 + +### 10.1 技术风险 +1. **SSH连接稳定性** + - 风险:网络波动导致连接失败 + - 应对:连接池、重试机制、超时设置 + +2. **性能问题** + - 风险:4000+设备查询性能 + - 应对:异步处理、分批查询、缓存优化 + +3. **安全性** + - 风险:SSH凭证存储安全 + - 应对:加密存储、访问控制、审计日志 + +### 10.2 业务风险 +1. **数据准确性** + - 风险:设备状态误判 + - 应对:多重验证、人工复核机制 + +2. **用户接受度** + - 风险:操作复杂度过高 + - 应对:用户培训、简化流程、良好UI + +## 十一、总结 + +本系统设计基于实际业务需求,采用现代化的技术栈和架构,具备良好的扩展性和维护性。通过Casdoor集成实现统一认证,基于RBAC的权限管理系统支持复杂的权限控制需求,Docker化部署确保环境一致性。 + +系统将分阶段实施,优先保证核心功能的稳定运行,逐步扩展高级功能。建议在开发过程中保持与业务人员的密切沟通,及时调整需求。 + +--- +**文档版本**: v1.0 +**最后更新**: 2026年4月1日 +**下次评审**: 2026年4月15日 \ No newline at end of file diff --git a/项目总结.md b/项目总结.md new file mode 100644 index 0000000..2b9dec3 --- /dev/null +++ b/项目总结.md @@ -0,0 +1,318 @@ +# H3C ONU设备管理系统 - 项目总结 + +## 项目概述 + +基于Python FastAPI + Vue 3的H3C OLT设备监控管理系统,用于监控和管理4000+ ONU设备的在线状态。系统通过SSH连接H3C OLT设备,查询ONU设备状态,提供Web界面进行设备管理、状态监控、数据导入和权限管理。 + +## 设计讨论要点总结 + +### 1. 核心需求确认 +- **设备状态监控**: 通过SSH连接H3C OLT设备,使用`display onu slot`命令查询ONU状态 +- **数据规模**: 4000+设备MAC地址和物理位置信息管理 +- **数据源**: Excel表格导入,PostgreSQL数据库存储 +- **更新频率**: 自动每30分钟更新,手动刷新5分钟冷却限制 +- **历史记录**: 保存90天状态历史 +- **权限管理**: Casdoor统一认证,RBAC权限控制,区域/学校级别数据隔离 +- **部署要求**: Docker Compose编排,外部数据库支持,环境变量配置 + +### 2. 技术架构决策 +- **后端**: Python FastAPI + PostgreSQL + Celery + Redis +- **前端**: Vue 3 + Element Plus + Pinia +- **认证**: Casdoor统一认证集成 +- **部署**: Docker容器化,Nginx反向代理 +- **监控**: 健康检查,性能监控,日志收集 + +### 3. 权限系统设计 +- **角色层级**: 超级管理员 → 管理员 → 区域管理员 → 学校管理员 → 操作员 → 查看员 +- **权限控制**: 基于RBAC,支持数据级权限过滤 +- **变更审核**: 设备信息变更需要管理员审核 +- **Casdoor集成**: 统一认证,用户信息同步 + +### 4. 功能模块规划 +1. **基础框架**: 项目结构,环境配置,Docker化 +2. **认证系统**: Casdoor集成,JWT管理,权限中间件 +3. **设备管理**: Excel导入,设备CRUD,状态检查 +4. **SSH连接**: 连接池管理,命令执行,结果解析 +5. **任务调度**: 定时检查,手动刷新,异步处理 +6. **权限系统**: 角色管理,权限分配,数据过滤 +7. **统计图表**: 仪表板,趋势分析,报表导出 +8. **系统设置**: OLT配置,任务配置,审核流程 + +## 系统架构设计 + +### 整体架构 +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ 用户浏览器 │ │ Nginx反向代理 │ │ FastAPI后端 │ +│ │◄──►│ │◄──►│ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ Vue前端应用 │ │ Celery Worker │ + │ │ │ │ + └─────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ PostgreSQL数据库 │ │ Redis │ + │ (外部) │ │ (外部) │ + └─────────────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Casdoor │ + │ 认证服务器 │ + └─────────────────┘ +``` + +### 数据库设计核心表 +1. **olt_devices**: OLT设备信息(IP、SSH凭证、插槽命令) +2. **onu_devices**: ONU设备信息(MAC、位置、关联OLT) +3. **device_status_history**: 设备状态历史记录 +4. **users**: 用户信息(Casdoor集成) +5. **roles/permissions**: 角色和权限管理 +6. **device_change_requests**: 设备变更审核记录 + +### API设计原则 +- RESTful风格,版本控制(/api/v1/) +- JWT认证,权限中间件 +- 数据验证(Pydantic) +- 异步处理耗时操作 +- 完善的错误处理和日志 + +## 关键技术实现 + +### 1. SSH连接管理 +- **连接池**: 复用SSH连接,提高性能 +- **超时重试**: 网络波动容错 +- **命令解析**: 解析`display onu slot`命令输出 +- **批量处理**: 分批查询,避免阻塞 + +### 2. 异步任务处理 +- **Celery**: 异步任务队列 +- **Redis**: 消息代理和结果存储 +- **定时任务**: Celery Beat调度 +- **进度跟踪**: 任务状态监控 + +### 3. 数据导入处理 +- **Pandas**: Excel文件解析 +- **数据验证**: 格式检查,重复检测 +- **批量插入**: 性能优化 +- **进度反馈**: 实时导入进度 + +### 4. 权限系统实现 +- **RBAC模型**: 角色-权限关联 +- **数据过滤**: SQL级别权限控制 +- **中间件**: 请求级别权限检查 +- **Casdoor同步**: 用户信息自动同步 + +### 5. 前端架构 +- **组件化**: 可复用组件设计 +- **状态管理**: Pinia集中状态 +- **路由守卫**: 页面访问控制 +- **响应式设计**: 移动端适配 + +## 部署方案 + +### 环境要求 +- Docker 20.10+ +- Docker Compose 2.0+ +- 外部PostgreSQL数据库 +- 外部Redis服务 +- Casdoor认证服务器 + +### 配置文件 +- **.env**: 环境变量配置 +- **docker-compose.yml**: 服务编排 +- **nginx.conf**: 反向代理配置 +- **部署脚本**: 自动化部署和监控 + +### 监控和运维 +- **健康检查**: /health端点 +- **性能监控**: /metrics端点(Prometheus格式) +- **日志收集**: 结构化日志,日志轮转 +- **备份策略**: 数据库定期备份 + +## 开发计划 + +### 第一阶段(2周):项目基础 +- 环境搭建,基础框架 +- Casdoor集成,基础API +- 前端框架搭建 + +### 第二阶段(4周):核心功能 +- 数据库设计,设备管理 +- SSH连接,状态检查 +- Excel导入,数据管理 +- 权限系统实现 + +### 第三阶段(3周):高级功能 +- 统计图表,仪表板 +- 系统设置,审核流程 +- 性能优化,测试完善 + +### 第四阶段(1-2周):部署上线 +- 生产环境部署 +- 监控配置 +- 用户培训 + +## 风险评估和应对 + +### 技术风险 +1. **SSH连接稳定性** + - 风险:网络问题导致连接失败 + - 应对:连接池、重试机制、超时设置 + +2. **性能问题** + - 风险:4000+设备查询性能 + - 应对:异步处理、缓存优化、分批查询 + +3. **安全性** + - 风险:SSH凭证存储安全 + - 应对:加密存储、访问控制、审计日志 + +### 项目风险 +1. **需求变更** + - 风险:开发进度延迟 + - 应对:敏捷开发、定期沟通 + +2. **集成问题** + - 风险:Casdoor集成问题 + - 应对:早期测试、备用方案 + +## 扩展性考虑 + +### 水平扩展 +- 无状态API服务,支持多实例 +- 数据库读写分离 +- Redis集群支持 +- 负载均衡配置 + +### 功能扩展 +1. **库存管理模块**(计划中) + - 设备库存管理 + - 出入库记录 + - 库存预警 + +2. **告警通知模块**(计划中) + - 设备离线告警 + - 多通道通知 + - 告警规则配置 + +3. **报表导出模块**(计划中) + - 自定义报表 + - 数据导出 + - 统计图表 + +4. **移动端适配**(计划中) + - 响应式移动端 + - PWA支持 + - 离线功能 + +## 成功标准 + +### 功能完成度 +- 核心功能100%实现 +- 高级功能80%实现 +- 用户体验满意度≥90% + +### 性能指标 +- 页面加载时间<3秒 +- API响应时间<500ms +- 系统可用性≥99.5% + +### 质量指标 +- 测试覆盖率≥80% +- 缺陷密度<0.5/千行代码 +- 用户反馈满意度≥85% + +### 项目指标 +- 按时交付率≥90% +- 预算控制率≥95% +- 团队满意度≥85% + +## 项目文档清单 + +### 已创建文档 +1. **系统设计文档.md** - 详细系统设计说明 +2. **README.md** - 项目概述和快速开始 +3. **PROJECT_STRUCTURE.md** - 项目结构说明 +4. **开发计划.md** - 详细开发计划 +5. **项目总结.md** - 本项目总结文档 + +### 部署配置 +1. **deploy/docker-compose.yml** - Docker编排配置 +2. **deploy/.env.example** - 环境变量示例 +3. **deploy/nginx/** - Nginx配置 +4. **deploy/scripts/** - 部署和运维脚本 + +### 待创建文档 +1. **API文档** - 详细API接口说明 +2. **用户手册** - 最终用户使用指南 +3. **部署指南** - 生产环境部署步骤 +4. **开发指南** - 开发者贡献指南 + +## 后续步骤建议 + +### 短期(1-2周) +1. 搭建开发环境 +2. 创建基础项目结构 +3. 配置Casdoor集成 +4. 实现基础认证功能 + +### 中期(1-2月) +1. 完成核心功能开发 +2. 进行系统测试 +3. 准备生产环境部署 +4. 用户培训材料准备 + +### 长期(2-3月) +1. 系统上线运行 +2. 收集用户反馈 +3. 优化系统性能 +4. 规划扩展功能开发 + +## 技术选型理由 + +### 后端技术选型 +- **FastAPI**: 高性能,异步支持好,自动API文档 +- **PostgreSQL**: 关系型数据库,事务支持,JSONB扩展 +- **Celery**: 成熟的异步任务框架,社区支持好 +- **Paramiko**: Python SSH库,功能完善,文档齐全 + +### 前端技术选型 +- **Vue 3**: 渐进式框架,学习曲线平缓,生态丰富 +- **Element Plus**: 企业级UI组件,设计规范,功能全面 +- **Pinia**: Vue 3官方推荐状态管理,TypeScript支持好 + +### 部署技术选型 +- **Docker**: 容器化标准,环境一致性,易于部署 +- **Nginx**: 高性能Web服务器,反向代理成熟方案 +- **Casdoor**: 开源统一认证,支持OAuth 2.0,易于集成 + +## 项目价值 + +### 业务价值 +1. **效率提升**: 自动化设备状态监控,减少人工检查 +2. **数据集中**: 统一管理4000+设备信息,便于查询统计 +3. **实时监控**: 及时发现设备离线,快速响应 +4. **权限控制**: 多级权限管理,数据安全有保障 + +### 技术价值 +1. **现代化架构**: 采用最新技术栈,易于维护扩展 +2. **容器化部署**: 环境一致,部署简单,易于运维 +3. **开源集成**: 利用成熟开源方案,降低开发成本 +4. **良好扩展性**: 模块化设计,便于功能扩展 + +## 总结 + +本项目是一个完整的设备监控管理系统,从需求分析、架构设计到部署方案都进行了详细规划。系统采用现代化的技术栈,具备良好的扩展性和维护性。通过分阶段实施,可以确保项目稳步推进,最终交付一个稳定、高效、易用的系统。 + +项目已经完成了详细的设计和规划,具备了开始实施的所有必要条件。建议按照开发计划分阶段实施,定期进行进度评审和调整,确保项目成功交付。 + +--- +**文档版本**: v1.0 +**创建日期**: 2026年4月1日 +**最后更新**: 2026年4月1日 +**项目状态**: 设计完成,准备实施 \ No newline at end of file