commit 381ea7085d4ce6ad393569313927eb7870da6d6e Author: v6ole Date: Thu Apr 2 23:40:04 2026 +0800 初始化 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 0000000..740b2f6 Binary files /dev/null and b/backend/templates/OLT设备导入模板.xlsx differ diff --git a/backend/templates/设备导入模板.xlsx b/backend/templates/设备导入模板.xlsx new file mode 100644 index 0000000..58b652b Binary files /dev/null and b/backend/templates/设备导入模板.xlsx differ diff --git a/backend/test_parser.py b/backend/test_parser.py new file mode 100644 index 0000000..1117b7e --- /dev/null +++ b/backend/test_parser.py @@ -0,0 +1,30 @@ +"""测试 ONU 状态解析""" +import re + +def parse_onu_status(output: str): + """解析 ONU 状态""" + devices = {} + lines = output.split('\n') + + for line in lines: + 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 + +# 测试数据 +test_output = """ +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 +""" + +result = parse_onu_status(test_output) +print("解析结果:") +for mac, status in result.items(): + print(f" {mac}: {status}") diff --git a/backend/token_jwt_key.pem b/backend/token_jwt_key.pem new file mode 100644 index 0000000..ab68b12 --- /dev/null +++ b/backend/token_jwt_key.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIE2TCCAsGgAwIBAgIDAeJAMA0GCSqGSIb3DQEBCwUAMCYxDjAMBgNVBAoTBWFk +bWluMRQwEgYDVQQDDAtjZXJ0X3drMWVlZjAeFw0yNTEyMTAxMDAzMzVaFw00NTEy +MTAxMDAzMzVaMCYxDjAMBgNVBAoTBWFkbWluMRQwEgYDVQQDDAtjZXJ0X3drMWVl +ZjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK78ajnNnwnutdZ48l92 +hmqa2c8lP1IcpyB7CYVTKurQxSz5iQorYOVhR1UzluSpU8yiPPeFyTRD0pH+DzqG +otc+Alvxnka5DfP1z/P0asogkALJXouRR+YtUY6i1oo5tKlDbIGJNO2aQcOOak9b +YoqSRd9y/pq/bPjte7oww29hGQc03LgbNXmIb9n5NGznWCte1c88NUrTz9Dlpn2H +r3ncOmHpqpzg5NtXughnHsF4YCF+pPIgWlC0C6MtKk0fuJysCY5wpuA620pGL4zO +6QFv920MiWsdVWxcNo0aNQkXKGjEcy3LraXux2k/sH+E0e3GRXGhWYnkrK7i8kg+ +V+Nm1YFgyGbY7V3yZ0mXPxb+iMlIvz885ViGsRnKqlR0pN1v8NbmzXPu9EkAO3wi +T/L4fxnETW1hd/ph+AdQ0jEpeyAMRjcl3kMjauOBqfU/THl7L6aUMXB3d05+JK0Y +uTc1nrZ0Qjh/5EG5XyvRSuKNVxpCB1XcAlAaBTuE5art6jQkCJINvFoOjoHZB65K +HJVvfMtMuVr7dLSbYOPHH6YJB2fUijNKoXnclAbxldX3fEALsC/SW7zMFF+3FThg +Seq3OEpNdbkRlQp5sORHeTkWtMO9A60GHvTqDamIqppC4fk71zIRpmEPlfGsOno5 +gNhH8+NKH5Cvmfj4s7/S98D/AgMBAAGjEDAOMAwGA1UdEwEB/wQCMAAwDQYJKoZI +hvcNAQELBQADggIBAJLW6zjDKZdkfq00r88lM1IxaxRWmUEPQjINOa0GZH/DyAtd +fgYf35AN+NDEiIiDnJQTR3N8jbX1JySDClEOzv3wLXRiKhWcX8z4HOoieNmMu9lZ +6pf1lEaMX8WNxB475nfOueEEyHegbsbfpmYM/aVAiSxaKb87if7uDnxJeGqK2Ba0 +c3LJnQ+H87gcAb84G78KXn/XwjRtwaf+Gy06EzycEnckEeN0vni0pRdw8coSudcX +726Kb+kZ4961G/XxQx4fKX+E4lXkx1CdxPBAws/uOjfciLnuVdT4yp6URXJKRw+/ +R+flPtfvHXNDXB8BHj9ul4UJ4oSHGppx0BR80CmWWNDVdaoO+o0WieFOXG2U2PB+ +p2pleJzckTDuOh5wIe2GFj9WKZTIn4S7ZA6hS5j5ea4bOX5m6cxXVaS5FPHHfHcN +FyUOEE4f61tv9omsFyl23YD/gmQzotFY+5YPS8DTb1soSzydKQODCq7SobyK8QFo +3FnOw1cnKuWDZbqkZS6TYHFc3doNaH77bBxgSgIBPlJewd98eZZCv+m8/mCOXCbo ++7HOgmOHJ1cOEtweCtgrCc2VNvV0w6RSwcZ+WmUAZMajvyEqikVVQzR2LtOOhOyr +REkkNRAX3PX69GDQLM9QSgGLv0f6nA5uTX9SFj71Iik6YAuUsvIK6uPpn/2B +-----END CERTIFICATE----- diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..6499db0 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,185 @@ +# H3C ONU设备管理系统 - 环境变量配置示例 +# 复制此文件为 .env 并修改相应配置 + +# ============================================ +# 应用基础配置 +# ============================================ + +# 应用环境 (development, production) +APP_ENV=production + +# 调试模式 (true/false) +DEBUG=false + +# 应用密钥 (使用 openssl rand -hex 32 生成) +SECRET_KEY=your-secret-key-change-in-production + +# 时区设置 +TZ=Asia/Shanghai + +# ============================================ +# 数据库配置 (PostgreSQL) +# ============================================ + +# PostgreSQL连接字符串 +# 格式: postgresql://username:password@host:port/database +DATABASE_URL=postgresql://h3c_user:your_password@postgres-host:5432/h3c_onu_ms + +# 数据库连接池配置 +DATABASE_POOL_SIZE=20 +DATABASE_MAX_OVERFLOW=40 +DATABASE_POOL_RECYCLE=3600 + +# ============================================ +# Redis配置 +# ============================================ + +# Redis连接URL (如果使用外部Redis,修改为外部地址) +REDIS_URL=redis://:redispass@redis:6379/0 + +# Redis密码 (容器内Redis使用) +REDIS_PASSWORD=redispass + +# Redis连接池大小 +REDIS_POOL_SIZE=10 + +# ============================================ +# Casdoor认证配置 +# ============================================ + +# Casdoor服务地址 +CASDOOR_ENDPOINT=https://casdoor.example.com + +# 应用Client ID +CASDOOR_CLIENT_ID=your_casdoor_client_id + +# 应用Client Secret +CASDOOR_CLIENT_SECRET=your_casdoor_client_secret + +# 应用Certificate +CASDOOR_CERTIFICATE=your_casdoor_certificate + +# 组织名称 +CASDOOR_ORG_NAME=your_organization + +# 应用名称 +CASDOOR_APP_NAME=h3c-onu-ms + +# ============================================ +# SSH连接配置 +# ============================================ + +# SSH连接超时时间(秒) +SSH_TIMEOUT=30 + +# 最大并发连接数 +SSH_MAX_CONNECTIONS=10 + +# SSH重试次数 +SSH_RETRY_COUNT=3 + +# SSH连接保持时间(秒) +SSH_KEEPALIVE=60 + +# ============================================ +# 任务调度配置 +# ============================================ + +# 自动检查间隔(秒) - 30分钟 +CHECK_INTERVAL=1800 + +# 手动刷新冷却时间(秒) - 5分钟 +MANUAL_COOLDOWN=300 + +# 历史记录保留天数 +HISTORY_RETENTION=90 + +# 批量检查设备数量 +BATCH_CHECK_SIZE=100 + +# ============================================ +# 前端配置 +# ============================================ + +# API基础URL (前端访问后端地址) +VITE_API_BASE_URL=http://localhost:8000 + +# Casdoor前端配置 +VITE_CASDOOR_ENDPOINT=https://casdoor.example.com +VITE_CASDOOR_CLIENT_ID=your_casdoor_client_id +VITE_CASDOOR_ORG_NAME=your_organization +VITE_CASDOOR_APP_NAME=h3c-onu-ms + +# 应用标题 +VITE_APP_TITLE=H3C ONU设备管理系统 + +# ============================================ +# 日志配置 +# ============================================ + +# 日志级别 (DEBUG, INFO, WARNING, ERROR) +LOG_LEVEL=INFO + +# 日志文件路径 +LOG_FILE=/app/logs/app.log + +# 日志轮转大小 +LOG_ROTATION=10MB + +# 日志保留天数 +LOG_RETENTION=30 + +# ============================================ +# 邮件通知配置 (可选) +# ============================================ + +# SMTP服务器 +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER=your_email@example.com +# SMTP_PASSWORD=your_email_password +# SMTP_FROM=noreply@example.com + +# ============================================ +# 监控配置 +# ============================================ + +# 健康检查端口 +HEALTH_CHECK_PORT=8000 + +# 监控指标端口 +METRICS_PORT=8000 + +# ============================================ +# 安全配置 +# ============================================ + +# CORS允许的域名 (逗号分隔) +CORS_ORIGINS=http://localhost:8080,http://localhost:5173 + +# 速率限制配置 +RATE_LIMIT_PER_MINUTE=60 +RATE_LIMIT_PER_HOUR=1000 + +# ============================================ +# 备份配置 +# ============================================ + +# 数据库备份目录 +BACKUP_DIR=/app/backups + +# 备份保留天数 +BACKUP_RETENTION=30 + +# 自动备份时间 (cron格式) +BACKUP_SCHEDULE="0 2 * * *" + +# ============================================ +# 开发环境覆盖配置 +# ============================================ + +# 如果是开发环境,取消注释以下配置 +# DEBUG=true +# DATABASE_URL=postgresql://h3c_user:password@localhost:5432/h3c_onu_ms_dev +# REDIS_URL=redis://localhost:6379/0 +# VITE_API_BASE_URL=http://localhost:8000 \ No newline at end of file diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..9f14a68 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,104 @@ +services: + # 后端API服务 + backend: + build: + context: ../backend + dockerfile: Dockerfile + container_name: h3c-onu-ms-backend + ports: + - "8001:8000" + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=${REDIS_URL} + - CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT} + - CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID} + - CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET} + - CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE} + - CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME} + - CASDOOR_APP_NAME=${CASDOOR_APP_NAME} + - SECRET_KEY=${SECRET_KEY} + - DEBUG=${DEBUG:-false} + volumes: + - ../backend/logs:/app/logs + - ../backend/static:/app/static + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Celery Worker - 处理异步任务 + celery-worker: + build: + context: ../backend + dockerfile: Dockerfile + container_name: h3c-onu-ms-celery-worker + command: celery -A app.core.celery_app worker --loglevel=info --concurrency=4 + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=${REDIS_URL} + - CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT} + - CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID} + - CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET} + - CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE} + - CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME} + - CASDOOR_APP_NAME=${CASDOOR_APP_NAME} + - SECRET_KEY=${SECRET_KEY} + volumes: + - ../backend/logs:/app/logs + depends_on: + - backend + restart: unless-stopped + + # Celery Beat - 定时任务调度 + celery-beat: + build: + context: ../backend + dockerfile: Dockerfile + container_name: h3c-onu-ms-celery-beat + command: celery -A app.core.celery_app beat --loglevel=info + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=${REDIS_URL} + - CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT} + - CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID} + - CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET} + - CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE} + - CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME} + - CASDOOR_APP_NAME=${CASDOOR_APP_NAME} + - SECRET_KEY=${SECRET_KEY} + volumes: + - ../backend/logs:/app/logs + - ../backend/celerybeat-schedule:/app/celerybeat-schedule + depends_on: + - backend + restart: unless-stopped + + # 前端应用 + frontend: + build: + context: ../frontend + dockerfile: Dockerfile + container_name: h3c-onu-ms-frontend + ports: + - "8081:80" + environment: + - VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8001} + - VITE_CASDOOR_ENDPOINT=${VITE_CASDOOR_ENDPOINT} + - VITE_CASDOOR_CLIENT_ID=${VITE_CASDOOR_CLIENT_ID} + - VITE_CASDOOR_ORG_NAME=${VITE_CASDOOR_ORG_NAME} + - VITE_CASDOOR_APP_NAME=${VITE_CASDOOR_APP_NAME} + depends_on: + - backend + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + default: + name: h3c-onu-ms-network diff --git a/deploy/nginx/conf.d/h3c-onu-ms.conf b/deploy/nginx/conf.d/h3c-onu-ms.conf new file mode 100644 index 0000000..01da95a --- /dev/null +++ b/deploy/nginx/conf.d/h3c-onu-ms.conf @@ -0,0 +1,165 @@ +# H3C ONU设备管理系统 - Nginx站点配置 + +# 前端静态文件服务 +server { + listen 80; + server_name localhost; + + # 根目录指向前端构建文件 + root /usr/share/nginx/html; + index index.html; + + # Gzip压缩 + gzip_static on; + + # 静态文件缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # 前端路由支持 (Vue Router history模式) + location / { + try_files $uri $uri/ /index.html; + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + # API代理到后端 + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # 超时设置 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # 缓冲区设置 + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + proxy_busy_buffers_size 8k; + + # 禁用代理缓存 + proxy_cache off; + } + + # WebSocket支持 + location /ws/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # WebSocket超时设置 + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # 静态文件代理 (后端静态文件) + location /static/ { + alias /usr/share/nginx/static/; + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # 健康检查端点 + location /health { + proxy_pass http://backend:8000/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + access_log off; + } + + # 监控指标端点 + location /metrics { + proxy_pass http://backend:8000/metrics; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + access_log off; + } + + # API文档 + location /docs { + proxy_pass http://backend:8000/docs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /redoc { + proxy_pass http://backend:8000/redoc; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # 错误页面 + error_page 404 /index.html; + error_page 500 502 503 504 /50x.html; + + location = /50x.html { + root /usr/share/nginx/html; + } +} + +# HTTPS配置 (需要SSL证书) +# server { +# listen 443 ssl http2; +# server_name your-domain.com; +# +# # SSL证书配置 +# ssl_certificate /etc/nginx/ssl/your-domain.com.crt; +# ssl_certificate_key /etc/nginx/ssl/your-domain.com.key; +# +# # SSL优化配置 +# ssl_protocols TLSv1.2 TLSv1.3; +# ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384; +# ssl_prefer_server_ciphers off; +# ssl_session_cache shared:SSL:10m; +# ssl_session_timeout 10m; +# +# # HSTS头 +# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; +# +# # 其他配置与HTTP相同 +# root /usr/share/nginx/html; +# index index.html; +# +# location / { +# try_files $uri $uri/ /index.html; +# } +# +# location /api/ { +# proxy_pass http://backend:8000; +# # ... 其他代理配置 +# } +# +# # 强制HTTP重定向到HTTPS +# # if ($scheme != "https") { +# # return 301 https://$host$request_uri; +# # } +# } + +# 重定向HTTP到HTTPS (如果启用HTTPS) +# server { +# listen 80; +# server_name your-domain.com; +# return 301 https://$server_name$request_uri; +# } \ No newline at end of file diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf new file mode 100644 index 0000000..a1d01ee --- /dev/null +++ b/deploy/nginx/nginx.conf @@ -0,0 +1,56 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日志格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + # 基础配置 + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 100M; + + # Gzip压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + + # 安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # 包含站点配置 + include /etc/nginx/conf.d/*.conf; +} \ No newline at end of file diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh new file mode 100755 index 0000000..9d190e6 --- /dev/null +++ b/deploy/scripts/deploy.sh @@ -0,0 +1,362 @@ +#!/bin/bash + +# H3C ONU设备管理系统 - 部署脚本 +# 使用方法: ./deploy.sh [环境] [操作] +# 环境: dev, prod (默认: prod) +# 操作: up, down, restart, logs, build (默认: up) + +set -e + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 默认值 +ENV=${1:-prod} +ACTION=${2:-up} +COMPOSE_FILE="docker-compose.yml" + +# 根据环境选择配置文件 +if [ "$ENV" = "dev" ]; then + COMPOSE_FILE="docker-compose.dev.yml" + echo -e "${BLUE}使用开发环境配置${NC}" +elif [ "$ENV" = "prod" ]; then + COMPOSE_FILE="docker-compose.yml" + echo -e "${BLUE}使用生产环境配置${NC}" +else + echo -e "${RED}错误: 未知环境 '$ENV'${NC}" + echo "可用环境: dev, prod" + exit 1 +fi + +# 检查Docker Compose文件是否存在 +if [ ! -f "$COMPOSE_FILE" ]; then + echo -e "${RED}错误: Docker Compose文件 '$COMPOSE_FILE' 不存在${NC}" + exit 1 +fi + +# 检查环境变量文件 +if [ ! -f ".env" ]; then + echo -e "${YELLOW}警告: .env 文件不存在,从 .env.example 复制${NC}" + if [ -f ".env.example" ]; then + cp .env.example .env + echo -e "${YELLOW}请编辑 .env 文件并设置正确的配置${NC}" + exit 1 + else + echo -e "${RED}错误: .env.example 文件也不存在${NC}" + exit 1 + fi +fi + +# 加载环境变量 +set -a +source .env +set +a + +# 函数:显示帮助信息 +show_help() { + echo "H3C ONU设备管理系统 - 部署脚本" + echo "" + echo "使用方法: $0 [环境] [操作]" + echo "" + echo "环境:" + echo " dev 开发环境" + echo " prod 生产环境 (默认)" + echo "" + echo "操作:" + echo " up 启动服务 (默认)" + echo " down 停止服务" + echo " restart 重启服务" + echo " logs 查看日志" + echo " build 构建镜像" + echo " ps 查看服务状态" + echo " exec 进入容器" + echo " backup 备份数据" + echo " restore 恢复数据" + echo "" + echo "示例:" + echo " $0 prod up # 启动生产环境" + echo " $0 dev logs # 查看开发环境日志" + echo " $0 prod restart # 重启生产环境" +} + +# 函数:检查Docker是否运行 +check_docker() { + if ! docker info > /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