fix: 修复环路检测失败 + 多项UX增强
修复: - 环路检测正则 \s+(Onu\S+)\s+ → \s+(Onu\S+) (splitlines移除换行后尾随\s无法匹配) - 权限中间件 Header(...) → Header(None) 避免缺失Auth头返回422而非401 - 环路检测请求超时30s→120s (SSH连接30+台OLT实测需58秒) 重构 (ssh_service.py): - 提取 _send_and_wait 为私有方法,消除3处重复内部函数 - 添加 __enter__/__exit__ 上下文管理器支持 - 加固 execute_command prompt检测 (按行匹配<DEVICE_NAME>) - 移除未使用的settings import - olt.py/devices.py 调用方改用 with 语法 新功能: - 侧边栏退出登录上方显示当前用户名和角色 - 版本号从VERSION文件自动读取 (后端/health返回,前端动态显示) - 基于广西南宁经纬度计算日落时间,自动切换深色/浅色主题 - /api/olt/loopback-detection 响应增加raw字段便于排查 基础设施: - CLAUDE.md 加入 .gitignore - 新增 .claude/rules/07-remote-operations.md (远程部署操作) - 新增 .claude/rules/08-frp-notes.md (frp隧道注意事项) - 新增 VERSION 文件 (版本号 0.10.0) - 新增环路检测解析测试用例 (5个) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ logs/
|
|||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
# Deployment docs (contain credentials)
|
# Deployment docs (contain credentials)
|
||||||
*部署交接文档.md
|
*部署交接文档.md
|
||||||
|
|||||||
@@ -1,717 +0,0 @@
|
|||||||
# H3C ONU设备管理系统 - Claude开发指南
|
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。
|
|
||||||
|
|
||||||
**当前版本**: v0.10.0
|
|
||||||
**开发状态**: 生产环境优化完成,运维增强功能持续迭代
|
|
||||||
|
|
||||||
**已实现功能**:
|
|
||||||
- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页、终端控制字符清理)
|
|
||||||
- ✅ Excel 数据导入和批量管理
|
|
||||||
- ✅ 定时自动状态检查(可配置间隔,最小5分钟)+ 手动触发
|
|
||||||
- ✅ Casdoor 统一认证和 JWT 令牌管理
|
|
||||||
- ✅ 设备管理(列表、详情、筛选、分页)
|
|
||||||
- ✅ 统计仪表板(总数、在线、离线)+ 区域分布饼图 + 7天趋势折线图
|
|
||||||
- ✅ 完整 RBAC 权限管理(角色、权限、用户管理页面)
|
|
||||||
- ✅ OLT 管理(增删改查、批量导入、区域动态同步)
|
|
||||||
- ✅ OLT 端口管理(查看端口状态、开关端口)
|
|
||||||
- ✅ 重复 MAC 检测与清除
|
|
||||||
- ✅ 新设备发现与信息补全(扫描发现 → 补全信息 → 入库)
|
|
||||||
- ✅ 快速扫描(多线程并发)
|
|
||||||
- ✅ 环路检测
|
|
||||||
- ✅ 系统设置(管理员可配置检查间隔,显示下次扫描时间/扫描中状态)
|
|
||||||
- ✅ 库存管理模块(物料、序列号设备、出入库、盘点)
|
|
||||||
- ✅ 每日状态快照(凌晨1点聚合,用于趋势图性能优化)
|
|
||||||
- ✅ 审计日志(全量操作记录、筛选查询、CSV 导出)
|
|
||||||
- ✅ 设备更换记录(MAC 地址更换历史,与库存序列号联动)
|
|
||||||
- ✅ 操作记录日志(查看指定设备的上下线事件历史)
|
|
||||||
- ✅ OLT 时间同步(NTP 服务器配置同步到所有 OLT)
|
|
||||||
- ✅ IMC 网管服务集成(ONU 远程重启、光功率查询)
|
|
||||||
- ✅ 企业微信告警通知(设备离线、全离线学校检测)
|
|
||||||
- ✅ WebSocket 实时推送(仪表板状态更新、检查进度)
|
|
||||||
- ✅ iOS PWA 主屏幕支持(standalone 模式 safe area 适配)
|
|
||||||
- ✅ 生产环境 HTTPS 部署(OpenResty + SSL + 安全头)
|
|
||||||
- ✅ 前端生产构建(多阶段 Dockerfile,nginx:alpine 静态服务)
|
|
||||||
- ✅ 数据库连接池优化(pool_size=20, max_overflow=40)
|
|
||||||
|
|
||||||
**技术栈**:
|
|
||||||
- 后端: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}` - 获取设备详情
|
|
||||||
- `PUT /api/devices/{id}` - 更新设备信息
|
|
||||||
- `GET /api/devices/{id}/events` - 获取设备上下线事件记录
|
|
||||||
- `POST /api/devices/{id}/reboot` - 远程重启 ONU(IMC)
|
|
||||||
- `GET /api/devices/{id}/optical-power` - 获取 ONU 光功率(IMC)
|
|
||||||
|
|
||||||
### 状态检查
|
|
||||||
- `POST /api/check/status` - 手动触发全量状态检查(Celery 异步)
|
|
||||||
- `GET /api/check/status/{task_id}` - 查询检查任务进度
|
|
||||||
- `POST /api/check/scan/{olt_id}` - 扫描单台 OLT(预览,不入库)
|
|
||||||
- `POST /api/check/discover/{olt_id}` - 扫描单台 OLT 并入库新设备
|
|
||||||
|
|
||||||
### OLT 管理
|
|
||||||
- `GET /api/olt/devices` - OLT 设备列表
|
|
||||||
- `POST /api/olt/devices` - 新增 OLT
|
|
||||||
- `PUT /api/olt/devices/{id}` - 编辑 OLT
|
|
||||||
- `DELETE /api/olt/devices/{id}` - 删除 OLT
|
|
||||||
- `GET /api/olt/regions` - 获取 OLT 区域列表
|
|
||||||
- `POST /api/olt/quick-scan` - 多线程快速扫描所有 OLT
|
|
||||||
- `GET /api/olt/ports/{olt_id}` - 获取 OLT 端口状态
|
|
||||||
- `POST /api/olt/ports/{olt_id}/toggle` - 开关 OLT 端口
|
|
||||||
- `POST /api/olt/sync-ntp` - 同步 NTP 时间服务器配置
|
|
||||||
- `POST /api/olt/loopback-detection` - 环路检测
|
|
||||||
- `GET /api/olt/new-devices` - 获取新发现设备列表
|
|
||||||
- `PUT /api/olt/new-devices/{id}` - 补全新设备信息
|
|
||||||
- `DELETE /api/olt/new-devices/{id}` - 忽略新设备
|
|
||||||
|
|
||||||
### 数据导入
|
|
||||||
- `POST /api/import/upload` - 上传并导入 Excel 文件
|
|
||||||
- `GET /api/import/template` - 下载导入模板
|
|
||||||
|
|
||||||
### 统计信息
|
|
||||||
- `GET /api/stats/summary` - 获取统计摘要
|
|
||||||
- `GET /api/stats/trend` - 7天趋势数据
|
|
||||||
- `GET /api/stats/region-distribution` - 区域分布
|
|
||||||
|
|
||||||
### 权限管理
|
|
||||||
- `GET /api/roles` - 角色列表
|
|
||||||
- `POST /api/roles` - 创建角色
|
|
||||||
- `PUT /api/roles/{id}` - 编辑角色
|
|
||||||
- `DELETE /api/roles/{id}` - 删除角色
|
|
||||||
- `GET /api/permissions` - 权限列表
|
|
||||||
|
|
||||||
### 用户管理
|
|
||||||
- `GET /api/users` - 用户列表
|
|
||||||
- `PUT /api/users/{id}` - 编辑用户
|
|
||||||
- `DELETE /api/users/{id}` - 删除用户
|
|
||||||
|
|
||||||
### 库存管理
|
|
||||||
- `GET /api/inventory/materials` - 物料列表
|
|
||||||
- `POST /api/inventory/materials` - 新增物料
|
|
||||||
- `GET /api/inventory/serial-devices` - 序列号设备
|
|
||||||
- `POST /api/inventory/stock-in` - 入库
|
|
||||||
- `POST /api/inventory/stock-out` - 出库
|
|
||||||
- `POST /api/inventory/check` - 盘点
|
|
||||||
|
|
||||||
### 审计日志
|
|
||||||
- `GET /api/audit/logs` - 审计日志列表(筛选、分页)
|
|
||||||
- `GET /api/audit/logs/export` - 导出审计日志 CSV
|
|
||||||
|
|
||||||
### 设备更换记录
|
|
||||||
- `GET /api/devices/{id}/replacements` - 查看设备更换历史
|
|
||||||
- `GET /api/replacements` - 全量更换记录列表
|
|
||||||
|
|
||||||
### 系统设置
|
|
||||||
- `GET /api/settings` - 获取系统设置
|
|
||||||
- `PUT /api/settings` - 更新系统设置
|
|
||||||
|
|
||||||
### 企业微信
|
|
||||||
- `GET /api/wechat/callback` - 企业微信回调 URL 验证
|
|
||||||
- `POST /api/wechat/callback` - 企业微信消息接收
|
|
||||||
- `POST /api/wechat/menu/create` - 创建企业微信菜单
|
|
||||||
|
|
||||||
### WebSocket
|
|
||||||
- `WS /api/ws/dashboard` - 仪表板实时状态推送(Redis pub/sub)
|
|
||||||
|
|
||||||
### 任务监控
|
|
||||||
- `GET /api/monitor/tasks` - 查看正在运行的后台任务
|
|
||||||
|
|
||||||
### 系统
|
|
||||||
- `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 格式:
|
|
||||||
```
|
|
||||||
<type>(<scope>): <subject>
|
|
||||||
|
|
||||||
类型: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
|
|
||||||
|
|
||||||
### 开发规则
|
|
||||||
|
|
||||||
#### 提交前清理
|
|
||||||
- 每次提交前必须清理项目中不需要的文件
|
|
||||||
- 包括:测试文件(`test_*.py`)、调试脚本、不再使用的 shell 脚本、冗余 markdown 文档
|
|
||||||
- 检查是否有硬编码的敏感信息(密码、密钥、token)
|
|
||||||
- 检查 `.gitignore` 是否覆盖了所有不应提交的文件(`*.pem`、`.env`、`__pycache__` 等)
|
|
||||||
|
|
||||||
#### 环境配置
|
|
||||||
- 开发环境使用 `.env.development`
|
|
||||||
- 生产环境使用 `.env.production`
|
|
||||||
- 不提交 `.env` 文件到 Git
|
|
||||||
- 提供 `.env.example` 模板
|
|
||||||
- 敏感信息(数据库密码、Casdoor 密钥等)仅通过环境变量注入
|
|
||||||
|
|
||||||
#### 测试要求
|
|
||||||
- 核心业务逻辑需要单元测试
|
|
||||||
- API 端点需要集成测试
|
|
||||||
- 测试覆盖率目标:≥80%
|
|
||||||
|
|
||||||
#### 文档要求
|
|
||||||
- API 变更及时更新 Swagger 文档
|
|
||||||
- 复杂业务逻辑添加注释
|
|
||||||
- 重要配置添加说明
|
|
||||||
|
|
||||||
### 部署规则
|
|
||||||
|
|
||||||
#### Docker 部署
|
|
||||||
- 使用 Docker Compose 编排
|
|
||||||
- 外部 PostgreSQL 和 Redis(不在容器内)
|
|
||||||
- 日志挂载到宿主机
|
|
||||||
- 使用 Nginx 反向代理
|
|
||||||
|
|
||||||
#### 环境变量
|
|
||||||
必需配置:
|
|
||||||
- `DATABASE_URL`:PostgreSQL 连接字符串
|
|
||||||
- `REDIS_URL`:Redis 连接字符串
|
|
||||||
- `CASDOOR_*`:Casdoor 认证配置(`ENDPOINT`, `CLIENT_ID`, `CLIENT_SECRET`, `ORG_NAME`, `APP_NAME`)
|
|
||||||
- `CASDOOR_REDIRECT_URL`:Casdoor 登录回调地址(生产必填,无默认值)
|
|
||||||
- `SECRET_KEY`:应用密钥
|
|
||||||
|
|
||||||
可选配置:
|
|
||||||
- `CORS_ORIGINS`:CORS 允许的来源(逗号分隔)
|
|
||||||
- `FRONTEND_URL`:前端访问地址(用于微信帮助消息等,默认 `https://onu.dhdx.fun`)
|
|
||||||
- `NTP_OLD_SERVER` / `NTP_NEW_SERVER`:NTP 同步服务器 IP
|
|
||||||
- `IMC_API_*`:iMC 网管 API 配置(ONU 重启/光功率查询)
|
|
||||||
- `WECHAT_*`:企业微信告警配置
|
|
||||||
|
|
||||||
#### 监控告警
|
|
||||||
- 健康检查端点:`/health`
|
|
||||||
- 性能指标端点:`/metrics`
|
|
||||||
- 日志级别:生产环境使用 INFO
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 部署陷阱与经验教训
|
|
||||||
|
|
||||||
### 修改代码后必须重新构建镜像
|
|
||||||
|
|
||||||
**问题**:修改了宿主机上的源码后,直接 `docker compose up -d` 或 `docker compose restart` **不会**让容器使用新代码。容器运行的是构建时打包进镜像的旧代码。
|
|
||||||
|
|
||||||
**正确流程**:
|
|
||||||
```bash
|
|
||||||
# 1. 重新构建镜像(必须加 --no-cache 确保不用旧层)
|
|
||||||
docker compose build --no-cache backend # 或 frontend,或两者
|
|
||||||
|
|
||||||
# 2. 重新创建容器(必须用 rm + up,或 up --force-recreate)
|
|
||||||
docker compose rm -f backend
|
|
||||||
docker compose up -d backend
|
|
||||||
|
|
||||||
# 3. 如有数据库模型变更,执行迁移
|
|
||||||
docker compose exec backend alembic upgrade head
|
|
||||||
```
|
|
||||||
|
|
||||||
**不要用**:
|
|
||||||
- `docker compose restart`:只重启进程,不更新镜像
|
|
||||||
- `docker compose up -d`(无 build):容器配置变了会重建,但镜像不变
|
|
||||||
|
|
||||||
### 端口被占用导致容器启动但无端口映射
|
|
||||||
|
|
||||||
**问题**:如果宿主机端口已被其他容器占用,新容器会启动成功(`docker compose up` 不报错),但端口映射为空 `{}`,外部无法访问。
|
|
||||||
|
|
||||||
**排查方法**:
|
|
||||||
```bash
|
|
||||||
# 检查端口映射是否正常
|
|
||||||
docker inspect <container> --format '{{json .NetworkSettings.Ports}}'
|
|
||||||
|
|
||||||
# 查看谁占用了端口
|
|
||||||
docker ps | grep <port>
|
|
||||||
```
|
|
||||||
|
|
||||||
**解决方法**:先停掉占用端口的旧容器,再 `docker compose rm -f && docker compose up -d`。
|
|
||||||
|
|
||||||
### 前端生产构建(v0.10.0+)
|
|
||||||
|
|
||||||
**问题**:早期版本前端容器运行 `npm run dev`(Vite 开发服务器),存在热更新开销、源码暴露、无压缩等问题。
|
|
||||||
|
|
||||||
**生产构建流程**:
|
|
||||||
```dockerfile
|
|
||||||
# 多阶段构建 — frontend/Dockerfile
|
|
||||||
# Stage 1: vite build → dist/
|
|
||||||
# Stage 2: nginx:alpine 静态文件服务
|
|
||||||
```
|
|
||||||
|
|
||||||
**生产模式下不需要 `VITE_*` 环境变量**:前端所有 API 调用使用相对路径 `/api`,由 OpenResty/Nginx 在边缘层代理。Vite 的 proxy 仅用于本地开发。
|
|
||||||
|
|
||||||
**前端部署到远程服务器**:
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
docker build -t h3conums2-frontend:latest .
|
|
||||||
docker save h3conums2-frontend:latest | ssh -p 7072 root@<remote> "docker load"
|
|
||||||
ssh -p 7072 root@<remote> "docker stop h3conums2-frontend && docker rm h3conums2-frontend && docker run -d --name h3conums2-frontend --restart always -p 18062:80 h3conums2-frontend:latest"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 生产部署架构(v0.10.0+)
|
|
||||||
|
|
||||||
**部署拓扑**:前端在远程服务器,后端在本地服务器,通过 frp 隧道通信。
|
|
||||||
|
|
||||||
```
|
|
||||||
用户浏览器 → onu.dhdx.fun (HTTPS)
|
|
||||||
│
|
|
||||||
OpenResty (80/443)
|
|
||||||
│
|
|
||||||
┌───────────┴───────────┐
|
|
||||||
▼ ▼
|
|
||||||
前端容器(:18062) frp 隧道(:18060)
|
|
||||||
nginx:alpine │
|
|
||||||
静态文件服务 frpc → frps → 本机后端(:8000)
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键配置**:
|
|
||||||
- OpenResty 由 1Panel 管理,配置文件位于 `/opt/1panel/apps/openresty/openresty/conf/conf.d/`
|
|
||||||
- SSL 证书位于 `/www/sites/onu.dhdx.fun/ssl/`(OpenResty 容器内路径)
|
|
||||||
- 前端容器端口映射:`18062:80`
|
|
||||||
- frp 后端隧道:远程 `127.0.0.1:18060` → 本机 `127.0.0.1:8000`
|
|
||||||
|
|
||||||
### 新增环境变量(v0.10.0)
|
|
||||||
|
|
||||||
生产环境新增配置项:
|
|
||||||
```bash
|
|
||||||
# CORS & 前端
|
|
||||||
CORS_ORIGINS=http://localhost:5173,http://localhost:18002,https://onu.dhdx.fun
|
|
||||||
FRONTEND_URL=https://onu.dhdx.fun
|
|
||||||
|
|
||||||
# NTP 同步
|
|
||||||
NTP_OLD_SERVER=172.16.0.254
|
|
||||||
NTP_NEW_SERVER=172.16.1.252
|
|
||||||
|
|
||||||
# iMC 网管 API
|
|
||||||
IMC_API_URL=https://172.16.1.252:8443
|
|
||||||
IMC_API_USERNAME=admin
|
|
||||||
IMC_API_PASSWORD=...
|
|
||||||
IMC_API_VERIFY_SSL=false
|
|
||||||
|
|
||||||
# Casdoor 回调(生产必填,无默认值)
|
|
||||||
CASDOOR_REDIRECT_URL=https://onu.dhdx.fun/callback
|
|
||||||
|
|
||||||
# 企业微信代理(无默认值,按需配置)
|
|
||||||
WECHAT_PROXY_API_URL=https://api.v6ole.top
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据库连接池
|
|
||||||
|
|
||||||
**配置**(`backend/app/core/database.py`):
|
|
||||||
```python
|
|
||||||
engine = create_engine(
|
|
||||||
settings.DATABASE_URL,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
pool_size=20,
|
|
||||||
max_overflow=40,
|
|
||||||
pool_recycle=3600, # 1小时回收,防 PostgreSQL 断闲置连接
|
|
||||||
pool_timeout=30,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### SSH 主机密钥策略
|
|
||||||
|
|
||||||
**当前使用 `WarningPolicy`**:记录未知主机密钥警告但允许连接。生产环境 OLT 设备在内网,安全风险可接受。如需严格验证,改为 `RejectPolicy` 并预置 `known_hosts` 文件。
|
|
||||||
|
|
||||||
### OpenResty / 1Panel 注意事项
|
|
||||||
|
|
||||||
- 配置文件由 1Panel 管理,直接修改文件后需重载:`docker exec openresty openresty -s reload`
|
|
||||||
- 1Panel 面板重新保存站点配置会覆盖手动修改
|
|
||||||
- WebSocket 通过 HTTP/1.1 升级,需确保 `/api/` location 传递 `Upgrade` 和 `Connection` 头
|
|
||||||
- 当前使用自签名证书,可通过 1Panel 面板申请 Let's Encrypt 正式证书
|
|
||||||
|
|
||||||
### 新增数据库模型后必须执行迁移
|
|
||||||
|
|
||||||
**问题**:新增了 SQLAlchemy 模型(如 `DeviceDailySnapshot`、`SystemSetting`),重建镜像后如果不执行 `alembic upgrade head`,表不存在会导致 500 错误。
|
|
||||||
|
|
||||||
**每次新增模型的完整流程**:
|
|
||||||
```bash
|
|
||||||
# 1. 创建迁移文件(在宿主机或容器内)
|
|
||||||
docker compose exec backend alembic revision --autogenerate -m "描述"
|
|
||||||
|
|
||||||
# 2. 重建镜像
|
|
||||||
docker compose build --no-cache backend
|
|
||||||
|
|
||||||
# 3. 重启容器
|
|
||||||
docker compose rm -f backend && docker compose up -d backend
|
|
||||||
|
|
||||||
# 4. 执行迁移
|
|
||||||
docker compose exec backend alembic upgrade head
|
|
||||||
```
|
|
||||||
|
|
||||||
### Alembic 迁移文件 revision ID 不能重复
|
|
||||||
|
|
||||||
**问题**:手动创建迁移文件时,如果 `revision` 字段与已有文件重复,`alembic upgrade head` 会报 `Multiple head revisions are present` 错误。
|
|
||||||
|
|
||||||
**规则**:手动创建迁移文件时,revision ID 使用不与现有文件冲突的唯一字符串(如 `h8i9j0k1l2m3`),并确认 `down_revision` 指向正确的上一个版本。
|
|
||||||
|
|
||||||
### Celery worker 必须重建才能识别新任务
|
|
||||||
|
|
||||||
**问题**:新增 Celery 任务模块后,如果只重建 backend 而不重建 celery-worker,worker 不会注册新任务,消息会被丢弃并报 `KeyError`。
|
|
||||||
|
|
||||||
**规则**:新增任务模块后,backend 和 celery-worker 都必须重建:
|
|
||||||
```bash
|
|
||||||
docker compose build --no-cache backend celery-worker
|
|
||||||
docker compose rm -f backend celery-worker
|
|
||||||
docker compose up -d backend celery-worker
|
|
||||||
```
|
|
||||||
|
|
||||||
验证任务是否注册:
|
|
||||||
```bash
|
|
||||||
docker compose exec celery-worker celery -A celery_worker.celery_app inspect registered
|
|
||||||
```
|
|
||||||
|
|
||||||
### 区域管理员多区域过滤必须用 `.in_()` 而非 `==`
|
|
||||||
|
|
||||||
**问题**:`assigned_area` 字段存储逗号分隔的多个区域(如 `"城区,郊区"`),用 `==` 只能匹配整个字符串,导致多区域管理员只能看到第一个区域的数据。
|
|
||||||
|
|
||||||
**规则**:所有涉及 `assigned_area` 的过滤都必须先 split 再用 `.in_()`:
|
|
||||||
```python
|
|
||||||
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
|
|
||||||
query = query.filter(Model.region.in_(areas))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vite dev server 通过反向代理访问需配置 allowedHosts
|
|
||||||
|
|
||||||
**问题**:通过域名反向代理访问 Vite dev server 时,会报 `Blocked request. This host is not allowed`。
|
|
||||||
|
|
||||||
**解决**:在 `vite.config.js` 中设置:
|
|
||||||
```js
|
|
||||||
server: {
|
|
||||||
allowedHosts: ['all', 'your-domain.com'],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### iOS PWA standalone 模式与 Safari 的 safe area 差异
|
|
||||||
|
|
||||||
**问题**:`env(safe-area-inset-top)` 在 Safari 浏览器中为 0(有地址栏占位),在 standalone 模式(添加到主屏幕)下为真实刘海高度(44-59px)。直接使用会导致 Safari 中布局正常但 standalone 中顶栏/弹窗重叠。
|
|
||||||
|
|
||||||
**规则**:所有 safe area 相关样式必须包在 `@media (display-mode: standalone)` 中,Safari 不受影响:
|
|
||||||
```css
|
|
||||||
@media (display-mode: standalone) {
|
|
||||||
.mobile-topbar {
|
|
||||||
height: calc(52px + env(safe-area-inset-top));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`ElMessage` 的 offset 通过 `src/utils/message.js` 封装统一处理,所有页面从该文件导入而非直接从 `element-plus` 导入。
|
|
||||||
|
|
||||||
### Docker healthcheck 镜像内无 curl
|
|
||||||
|
|
||||||
**问题**:backend 镜像基于 Python slim,没有 `curl`,healthcheck 用 `curl` 会导致所有依赖服务启动失败。
|
|
||||||
|
|
||||||
**规则**:healthcheck 使用 Python 内置模块:
|
|
||||||
```yaml
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
start_period: 40s
|
|
||||||
```
|
|
||||||
|
|
||||||
### More 分页标记清除不能使用 `[^\n]*` 删除整行
|
|
||||||
|
|
||||||
**问题**:`_clean_output` 中 `---- More ----[^\n]*` 会将 `---- More ----` 及其后同行所有内容删除。当 OLT 输出的 More 提示与下一页第一条设备数据出现在同一行时(如 `---- More ----\r\r 1484-778f-aa60 ...`),该设备行会被错误丢弃,导致部分设备扫描不到。
|
|
||||||
|
|
||||||
**规则**:仅移除 More 标记文本本身,保留同行后续内容:
|
|
||||||
```python
|
|
||||||
# 错误:删除整行
|
|
||||||
output = re.sub(r'---- More ----[^\n]*', '', output)
|
|
||||||
|
|
||||||
# 正确:仅移除标记文本
|
|
||||||
output = re.sub(r'---- More ----', '', output)
|
|
||||||
```
|
|
||||||
|
|
||||||
此问题在 OLT `172.16.0.18`(大化县新城初中)上发现:93 台设备仅解析出 90 台,丢失 3 台。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
H3ConuMS2/
|
|
||||||
├── backend/ # Python 后端
|
|
||||||
│ ├── app/
|
|
||||||
│ │ ├── api/v1/ # API 路由
|
|
||||||
│ │ ├── core/ # 核心配置
|
|
||||||
│ │ ├── middleware/ # 中间件(权限、审计)
|
|
||||||
│ │ ├── models/ # 数据模型
|
|
||||||
│ │ ├── schemas/ # Pydantic 模式
|
|
||||||
│ │ ├── services/ # 业务逻辑(SSH、IMC、导入等)
|
|
||||||
│ │ └── tasks/ # Celery 任务
|
|
||||||
│ ├── alembic/ # 数据库迁移
|
|
||||||
│ ├── scripts/ # 初始化脚本
|
|
||||||
│ └── templates/ # Excel 导入模板
|
|
||||||
├── frontend/ # Vue 3 前端
|
|
||||||
│ ├── Dockerfile # 多阶段构建(vite build + nginx:alpine)
|
|
||||||
│ ├── nginx.conf # 生产 nginx 配置(Gzip、缓存、SPA fallback)
|
|
||||||
│ └── src/
|
|
||||||
│ ├── api/ # API 调用封装
|
|
||||||
│ ├── components/ # 公共组件
|
|
||||||
│ ├── composables/ # 组合式函数
|
|
||||||
│ ├── router/ # 路由
|
|
||||||
│ ├── stores/ # Pinia 状态管理
|
|
||||||
│ ├── styles/ # 主题样式
|
|
||||||
│ ├── utils/ # 工具函数
|
|
||||||
│ └── views/ # 页面组件
|
|
||||||
├── deploy/ # 部署配置
|
|
||||||
│ ├── docker-compose.yml
|
|
||||||
│ ├── openresty/ # OpenResty 站点配置
|
|
||||||
│ ├── nginx/
|
|
||||||
│ └── scripts/
|
|
||||||
├── docs/ # 文档
|
|
||||||
└── docker-compose.yml # 本地开发 Docker Compose
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 核心业务逻辑
|
|
||||||
|
|
||||||
### SSH 状态检查流程
|
|
||||||
1. 从数据库获取 OLT 设备配置
|
|
||||||
2. 建立 SSH 连接(使用连接池)
|
|
||||||
3. 执行命令:`display onu slot {slot_number}`
|
|
||||||
4. 解析返回结果:
|
|
||||||
- 包含 "Up" → 在线
|
|
||||||
- 包含 "Offline" → 离线
|
|
||||||
5. 更新设备状态到数据库
|
|
||||||
6. 记录历史状态
|
|
||||||
|
|
||||||
### 权限控制逻辑
|
|
||||||
- **超级管理员**:所有权限
|
|
||||||
- **管理员**:管理所有设备和用户
|
|
||||||
- **区域管理员**:管理指定区域的设备
|
|
||||||
- **学校管理员**:管理指定学校的设备
|
|
||||||
- **普通用户**:只读权限
|
|
||||||
|
|
||||||
### 数据导入流程
|
|
||||||
1. 上传 Excel 文件
|
|
||||||
2. 使用 Pandas 解析数据
|
|
||||||
3. 数据验证(MAC 地址格式、必填字段)
|
|
||||||
4. 批量插入数据库
|
|
||||||
5. 返回导入结果(成功/失败记录)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 开发指南
|
|
||||||
|
|
||||||
### 快速启动
|
|
||||||
|
|
||||||
**Docker 部署(推荐)**:
|
|
||||||
```bash
|
|
||||||
# 1. 配置环境变量
|
|
||||||
cp backend/.env.example backend/.env
|
|
||||||
# 编辑 .env 填入实际配置
|
|
||||||
|
|
||||||
# 2. 启动所有服务
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 3. 初始化数据库
|
|
||||||
docker compose exec backend python scripts/init_db.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**本地开发**:
|
|
||||||
```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
|
|
||||||
|
|
||||||
# 前端
|
|
||||||
cd frontend
|
|
||||||
npm install && npm run dev
|
|
||||||
|
|
||||||
# Celery Worker
|
|
||||||
cd backend
|
|
||||||
celery -A celery_worker.celery_app worker --loglevel=info
|
|
||||||
|
|
||||||
# Celery Beat
|
|
||||||
cd backend
|
|
||||||
celery -A celery_worker.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据库迁移
|
|
||||||
|
|
||||||
```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/)
|
|
||||||
- [现场ONU故障排查指南](./docs/现场ONU故障排查指南.md)
|
|
||||||
|
|
||||||
<!-- code-review-graph MCP tools -->
|
|
||||||
## MCP Tools: code-review-graph
|
|
||||||
|
|
||||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
|
||||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
|
||||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
|
||||||
you structural context (callers, dependents, test coverage) that file
|
|
||||||
scanning cannot.
|
|
||||||
|
|
||||||
### When to use graph tools FIRST
|
|
||||||
|
|
||||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
|
||||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
|
||||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
|
||||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
|
||||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
|
||||||
|
|
||||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
|
||||||
|
|
||||||
### Key Tools
|
|
||||||
|
|
||||||
| Tool | Use when |
|
|
||||||
| ------ | ---------- |
|
|
||||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
|
||||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
|
||||||
| `get_impact_radius` | Understanding blast radius of a change |
|
|
||||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
|
||||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
|
||||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
|
||||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
|
||||||
| `refactor_tool` | Planning renames, finding dead code |
|
|
||||||
|
|
||||||
### Workflow
|
|
||||||
|
|
||||||
1. The graph auto-updates on file changes (via hooks).
|
|
||||||
2. Use `detect_changes` for code review.
|
|
||||||
3. Use `get_affected_flows` to understand impact.
|
|
||||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0.10.0
|
||||||
@@ -668,10 +668,9 @@ def get_onu_events(
|
|||||||
raise HTTPException(status_code=404, detail="关联的 OLT 不存在")
|
raise HTTPException(status_code=404, detail="关联的 OLT 不存在")
|
||||||
|
|
||||||
from app.services.ssh_service import SSHService
|
from app.services.ssh_service import SSHService
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
events = ssh.get_onu_events(device.port_id)
|
events = ssh.get_onu_events(device.port_id)
|
||||||
return {
|
return {
|
||||||
"interface": f"Onu{device.port_id}",
|
"interface": f"Onu{device.port_id}",
|
||||||
"olt_location": olt.location,
|
"olt_location": olt.location,
|
||||||
@@ -679,8 +678,6 @@ def get_onu_events(
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
||||||
finally:
|
|
||||||
ssh.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{device_id}/optical-power-history")
|
@router.get("/{device_id}/optical-power-history")
|
||||||
|
|||||||
+11
-25
@@ -263,14 +263,11 @@ def clear_onu_port(
|
|||||||
if body.port_id not in port_ids:
|
if body.port_id not in port_ids:
|
||||||
raise HTTPException(status_code=400, detail="端口不在重复记录中")
|
raise HTTPException(status_code=400, detail="端口不在重复记录中")
|
||||||
|
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
ssh.clear_onu_port(body.port_id)
|
ssh.clear_onu_port(body.port_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
|
||||||
finally:
|
|
||||||
ssh.close()
|
|
||||||
|
|
||||||
# 从 ports 列表移除已清除的端口
|
# 从 ports 列表移除已清除的端口
|
||||||
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
|
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
|
||||||
@@ -451,10 +448,9 @@ def loopback_detection(
|
|||||||
onu_map = {(o.olt_id, o.port_id): o for o in all_onus if o.port_id}
|
onu_map = {(o.olt_id, o.port_id): o for o in all_onus if o.port_id}
|
||||||
|
|
||||||
def check_one(olt):
|
def check_one(olt):
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
detection = ssh.detect_loopback()
|
detection = ssh.detect_loopback()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
"olt_id": olt.id,
|
"olt_id": olt.id,
|
||||||
@@ -464,8 +460,6 @@ def loopback_detection(
|
|||||||
"has_loop": False,
|
"has_loop": False,
|
||||||
"loop_interfaces": [],
|
"loop_interfaces": [],
|
||||||
}
|
}
|
||||||
finally:
|
|
||||||
ssh.close()
|
|
||||||
|
|
||||||
loop_interfaces = []
|
loop_interfaces = []
|
||||||
for iface in detection.get("interfaces", []):
|
for iface in detection.get("interfaces", []):
|
||||||
@@ -488,6 +482,7 @@ def loopback_detection(
|
|||||||
"has_loop": detection["has_loop"],
|
"has_loop": detection["has_loop"],
|
||||||
"loop_interfaces": loop_interfaces,
|
"loop_interfaces": loop_interfaces,
|
||||||
"error": None,
|
"error": None,
|
||||||
|
"raw": detection.get("raw", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
results_map = {}
|
results_map = {}
|
||||||
@@ -522,10 +517,9 @@ def sync_ntp(
|
|||||||
olts = [o for o in olts if o.region in areas] if areas else []
|
olts = [o for o in olts if o.region in areas] if areas else []
|
||||||
|
|
||||||
def sync_one(olt):
|
def sync_one(olt):
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
ssh.sync_ntp(body.old_server, body.new_server)
|
ssh.sync_ntp(body.old_server, body.new_server)
|
||||||
return {
|
return {
|
||||||
"olt_ip": olt.ip_address,
|
"olt_ip": olt.ip_address,
|
||||||
"olt_location": olt.location or olt.ip_address,
|
"olt_location": olt.location or olt.ip_address,
|
||||||
@@ -539,8 +533,6 @@ def sync_ntp(
|
|||||||
"success": False,
|
"success": False,
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
}
|
}
|
||||||
finally:
|
|
||||||
ssh.close()
|
|
||||||
|
|
||||||
results_map = {}
|
results_map = {}
|
||||||
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
|
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
|
||||||
@@ -574,15 +566,12 @@ def get_olt_ports(
|
|||||||
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||||
if not olt:
|
if not olt:
|
||||||
raise HTTPException(status_code=404, detail="OLT 不存在")
|
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
ports = ssh.get_olt_ports()
|
ports = ssh.get_olt_ports()
|
||||||
return {"ports": ports}
|
return {"ports": ports}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
finally:
|
|
||||||
ssh.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices/{olt_id}/ports/toggle")
|
@router.post("/devices/{olt_id}/ports/toggle")
|
||||||
@@ -594,13 +583,10 @@ def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Se
|
|||||||
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||||
if not olt:
|
if not olt:
|
||||||
raise HTTPException(status_code=404, detail="OLT 不存在")
|
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
ssh.toggle_olt_port(port_name, body.action)
|
ssh.toggle_olt_port(port_name, body.action)
|
||||||
return {"message": f"端口 {port_name} 已{'关闭' if body.action == 'shutdown' else '开启'}"}
|
return {"message": f"端口 {port_name} 已{'关闭' if body.action == 'shutdown' else '开启'}"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
finally:
|
|
||||||
ssh.close()
|
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -81,9 +81,19 @@ async def startup():
|
|||||||
asyncio.create_task(ws._redis_listener())
|
asyncio.create_task(ws._redis_listener())
|
||||||
|
|
||||||
|
|
||||||
|
def _read_version() -> str:
|
||||||
|
"""读取项目版本号"""
|
||||||
|
version_paths = ["/app/VERSION", os.path.join(os.path.dirname(__file__), "../../VERSION")]
|
||||||
|
for p in version_paths:
|
||||||
|
if os.path.exists(p):
|
||||||
|
with open(p) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
status = {"status": "ok", "db": "ok", "redis": "ok"}
|
status = {"status": "ok", "db": "ok", "redis": "ok", "version": _read_version()}
|
||||||
try:
|
try:
|
||||||
import redis
|
import redis
|
||||||
import psycopg2
|
import psycopg2
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
|
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from fastapi import HTTPException, Depends, Header
|
from fastapi import HTTPException, Depends, Header
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -52,14 +55,19 @@ def invalidate_role_cache(role: str) -> None:
|
|||||||
def require_permission(permission: str):
|
def require_permission(permission: str):
|
||||||
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
|
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
|
||||||
def dependency(
|
def dependency(
|
||||||
authorization: str = Header(..., alias="Authorization"),
|
authorization: str = Header(None, alias="Authorization"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
if not authorization:
|
||||||
|
logger.warning("auth rejected: 缺少 Authorization 头 (permission=%s)", permission)
|
||||||
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
if not authorization.startswith("Bearer "):
|
if not authorization.startswith("Bearer "):
|
||||||
|
logger.warning("auth rejected: Authorization 格式错误 (permission=%s): %.50s", permission, authorization)
|
||||||
raise HTTPException(status_code=401, detail="未授权")
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
token = authorization[7:]
|
token = authorization[7:]
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
|
logger.warning("auth rejected: token 验证失败 (permission=%s): token前20字符=%.20s...", permission, token[:20])
|
||||||
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||||||
|
|
||||||
role = payload.get('role', 'user')
|
role = payload.get('role', 'user')
|
||||||
@@ -83,11 +91,11 @@ def require_permission(permission: str):
|
|||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
authorization: str = Header(..., alias="Authorization"),
|
authorization: str = Header(None, alias="Authorization"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""仅验证登录状态,不检查具体权限"""
|
"""仅验证登录状态,不检查具体权限"""
|
||||||
if not authorization.startswith("Bearer "):
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
raise HTTPException(status_code=401, detail="未授权")
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
token = authorization[7:]
|
token = authorization[7:]
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import re
|
|||||||
import time
|
import time
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ONUInfo:
|
class ONUInfo:
|
||||||
@@ -51,7 +49,7 @@ class SSHService:
|
|||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
if self.shell.recv_ready():
|
if self.shell.recv_ready():
|
||||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||||
if ">" in buf:
|
if re.search(r'<[^>]+>', buf):
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
@@ -77,14 +75,28 @@ class SSHService:
|
|||||||
if "---- More ----" in chunk:
|
if "---- More ----" in chunk:
|
||||||
self.shell.send(" ")
|
self.shell.send(" ")
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
elif ">" in chunk:
|
elif re.search(r'^<[^>]+>\s*$', chunk, re.MULTILINE):
|
||||||
# 命令提示符出现,说明输出完毕
|
# 匹配整行为 <DEVICE_NAME> 的提示符行(不匹配回显中的 ">")
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
def _send_and_wait(self, 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
|
||||||
|
|
||||||
def clear_onu_port(self, port_id: str) -> bool:
|
def clear_onu_port(self, port_id: str) -> bool:
|
||||||
"""清除指定端口的 ONU 配置(恢复默认)
|
"""清除指定端口的 ONU 配置(恢复默认)
|
||||||
流程: system-view -> interface Onu{port_id} -> default -> Y
|
流程: system-view -> interface Onu{port_id} -> default -> Y
|
||||||
@@ -92,26 +104,13 @@ class SSHService:
|
|||||||
if not self.shell:
|
if not self.shell:
|
||||||
raise Exception("SSH 未连接")
|
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", "]")
|
out = self._send_and_wait("system-view", "]")
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception("进入 system-view 失败")
|
raise Exception("进入 system-view 失败")
|
||||||
|
|
||||||
# 进入端口
|
# 进入端口
|
||||||
out = send_and_wait(f"interface Onu{port_id}", "]")
|
out = self._send_and_wait(f"interface Onu{port_id}", "]")
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception(f"进入端口 Onu{port_id} 失败")
|
raise Exception(f"进入端口 Onu{port_id} 失败")
|
||||||
|
|
||||||
@@ -138,8 +137,8 @@ class SSHService:
|
|||||||
self.shell.recv(4096)
|
self.shell.recv(4096)
|
||||||
|
|
||||||
# 退出到用户视图
|
# 退出到用户视图
|
||||||
send_and_wait("quit", "]", timeout=5)
|
self._send_and_wait("quit", "]", timeout=5)
|
||||||
send_and_wait("quit", ">", timeout=5)
|
self._send_and_wait("quit", ">", timeout=5)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -150,7 +149,7 @@ class SSHService:
|
|||||||
interfaces = []
|
interfaces = []
|
||||||
if has_loop:
|
if has_loop:
|
||||||
for line in output.splitlines():
|
for line in output.splitlines():
|
||||||
m = re.match(r'\s+(Onu\S+)\s+', line)
|
m = re.match(r'\s+(Onu\S+)', line)
|
||||||
if m:
|
if m:
|
||||||
interfaces.append(m.group(1))
|
interfaces.append(m.group(1))
|
||||||
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
|
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
|
||||||
@@ -319,33 +318,20 @@ class SSHService:
|
|||||||
if not self.shell:
|
if not self.shell:
|
||||||
raise Exception("SSH 未连接")
|
raise Exception("SSH 未连接")
|
||||||
|
|
||||||
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
|
out = self._send_and_wait("system-view", "]")
|
||||||
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:
|
if "]" not in out:
|
||||||
raise Exception("进入 system-view 失败")
|
raise Exception("进入 system-view 失败")
|
||||||
|
|
||||||
out = send_and_wait(f"interface {port_name}", "]")
|
out = self._send_and_wait(f"interface {port_name}", "]")
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception(f"进入端口 {port_name} 失败")
|
raise Exception(f"进入端口 {port_name} 失败")
|
||||||
|
|
||||||
out = send_and_wait(action, "]")
|
out = self._send_and_wait(action, "]")
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception(f"执行 {action} 失败")
|
raise Exception(f"执行 {action} 失败")
|
||||||
|
|
||||||
send_and_wait("quit", "]", timeout=5)
|
self._send_and_wait("quit", "]", timeout=5)
|
||||||
send_and_wait("quit", ">", timeout=5)
|
self._send_and_wait("quit", ">", timeout=5)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def sync_ntp(self, old_server: str, new_server: str) -> bool:
|
def sync_ntp(self, old_server: str, new_server: str) -> bool:
|
||||||
@@ -356,41 +342,28 @@ class SSHService:
|
|||||||
if not self.shell:
|
if not self.shell:
|
||||||
raise Exception("SSH 未连接")
|
raise Exception("SSH 未连接")
|
||||||
|
|
||||||
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
|
out = self._send_and_wait("system-view", "]")
|
||||||
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:
|
if "]" not in out:
|
||||||
raise Exception("进入 system-view 失败")
|
raise Exception("进入 system-view 失败")
|
||||||
|
|
||||||
# 删除旧 NTP 服务器(若不存在会报错,忽略即可)
|
# 删除旧 NTP 服务器(若不存在会报错,忽略即可)
|
||||||
send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
|
self._send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
|
||||||
|
|
||||||
# 添加新 NTP 服务器
|
# 添加新 NTP 服务器
|
||||||
out = send_and_wait(f"ntp-service unicast-server {new_server}", "]")
|
out = self._send_and_wait(f"ntp-service unicast-server {new_server}", "]")
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception(f"配置 NTP 服务器 {new_server} 失败")
|
raise Exception(f"配置 NTP 服务器 {new_server} 失败")
|
||||||
|
|
||||||
# 设置时区为北京时间
|
# 设置时区为北京时间
|
||||||
out = send_and_wait("clock timezone Beijing add 08:00:00", "]")
|
out = self._send_and_wait("clock timezone Beijing add 08:00:00", "]")
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception("配置时区失败")
|
raise Exception("配置时区失败")
|
||||||
|
|
||||||
# 退出系统视图
|
# 退出系统视图
|
||||||
send_and_wait("quit", ">")
|
self._send_and_wait("quit", ">")
|
||||||
|
|
||||||
# 强制保存配置
|
# 强制保存配置
|
||||||
send_and_wait("save force", ">", timeout=30)
|
self._send_and_wait("save force", ">", timeout=30)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -427,6 +400,16 @@ class SSHService:
|
|||||||
})
|
})
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""上下文管理器入口,自动连接"""
|
||||||
|
self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""上下文管理器出口,自动关闭连接"""
|
||||||
|
self.close()
|
||||||
|
return False
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""关闭 SSH 连接"""
|
"""关闭 SSH 连接"""
|
||||||
if self.client:
|
if self.client:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""SSH 输出解析测试"""
|
"""SSH 输出解析测试"""
|
||||||
|
import re
|
||||||
import pytest
|
import pytest
|
||||||
from app.services.ssh_service import SSHService
|
from app.services.ssh_service import SSHService
|
||||||
|
|
||||||
@@ -91,3 +92,71 @@ class TestCleanOutput:
|
|||||||
cleaned = svc._clean_output(output)
|
cleaned = svc._clean_output(output)
|
||||||
assert "1484-778f-aa60" in cleaned
|
assert "1484-778f-aa60" in cleaned
|
||||||
assert "---- More ----" not in cleaned
|
assert "---- More ----" not in cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectLoopback:
|
||||||
|
"""环路检测输出解析测试"""
|
||||||
|
|
||||||
|
def _parse(self, output: str):
|
||||||
|
"""模拟 detect_loopback 中的解析逻辑"""
|
||||||
|
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+)', line)
|
||||||
|
if m:
|
||||||
|
interfaces.append(m.group(1))
|
||||||
|
return has_loop, interfaces
|
||||||
|
|
||||||
|
def test_no_loop(self):
|
||||||
|
output = """
|
||||||
|
Loopback detection is enabled.
|
||||||
|
Loopback detection interval is 30 second(s).
|
||||||
|
No loopback is detected.
|
||||||
|
"""
|
||||||
|
has_loop, interfaces = self._parse(output)
|
||||||
|
assert not has_loop
|
||||||
|
assert interfaces == []
|
||||||
|
|
||||||
|
def test_has_loop_single(self):
|
||||||
|
output = """
|
||||||
|
Loopback detection is enabled.
|
||||||
|
Loopback detection interval is 30 second(s).
|
||||||
|
Loop is detected on following interfaces:
|
||||||
|
Onu1/0/1:1
|
||||||
|
"""
|
||||||
|
has_loop, interfaces = self._parse(output)
|
||||||
|
assert has_loop
|
||||||
|
assert interfaces == ["Onu1/0/1:1"]
|
||||||
|
|
||||||
|
def test_has_loop_multiple(self):
|
||||||
|
output = """
|
||||||
|
Loop is detected on following interfaces:
|
||||||
|
Onu1/0/1:1
|
||||||
|
Onu1/0/2:3
|
||||||
|
Onu2/0/5:10
|
||||||
|
"""
|
||||||
|
has_loop, interfaces = self._parse(output)
|
||||||
|
assert has_loop
|
||||||
|
assert interfaces == ["Onu1/0/1:1", "Onu1/0/2:3", "Onu2/0/5:10"]
|
||||||
|
|
||||||
|
def test_has_loop_with_extra_whitespace(self):
|
||||||
|
"""接口行有多余空白字符"""
|
||||||
|
output = """
|
||||||
|
Loop is detected on following interfaces:
|
||||||
|
Onu1/0/1:1
|
||||||
|
"""
|
||||||
|
has_loop, interfaces = self._parse(output)
|
||||||
|
assert has_loop
|
||||||
|
assert interfaces == ["Onu1/0/1:1"]
|
||||||
|
|
||||||
|
def test_no_false_positive_on_prompt(self):
|
||||||
|
"""确保设备提示符不被误识别为接口"""
|
||||||
|
output = """
|
||||||
|
Loop is detected on following interfaces:
|
||||||
|
Onu1/0/1:1
|
||||||
|
<H3C_Device>
|
||||||
|
"""
|
||||||
|
has_loop, interfaces = self._parse(output)
|
||||||
|
assert has_loop
|
||||||
|
assert interfaces == ["Onu1/0/1:1"]
|
||||||
|
|||||||
@@ -74,6 +74,13 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
<div v-if="authStore.user" class="sidebar-user">
|
||||||
|
<div class="sidebar-user-avatar">{{ (authStore.user.display_name || authStore.user.username || '?')[0] }}</div>
|
||||||
|
<div class="sidebar-user-info">
|
||||||
|
<div class="sidebar-user-name">{{ authStore.user.display_name || authStore.user.username }}</div>
|
||||||
|
<div class="sidebar-user-role">{{ roleLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="logout-btn" @click="handleLogout">
|
<button class="logout-btn" @click="handleLogout">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
@@ -116,7 +123,7 @@
|
|||||||
<circle cx="12" cy="12" r="10"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
<polyline points="12 6 12 12 16 14"/>
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
</svg>
|
</svg>
|
||||||
v0.5.0
|
v{{ appVersion }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -199,6 +206,7 @@ import { useMobile } from '../composables/useMobile'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const appVersion = ref('0.0.0')
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
|
||||||
@@ -218,6 +226,14 @@ const onKeydown = (e) => {
|
|||||||
if (e.ctrlKey && shortcuts[e.key]) { e.preventDefault(); router.push(shortcuts[e.key]) }
|
if (e.ctrlKey && shortcuts[e.key]) { e.preventDefault(); router.push(shortcuts[e.key]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchVersion = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/health')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.version) appVersion.value = data.version
|
||||||
|
} catch { /* 静默 */ }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
updateTime()
|
updateTime()
|
||||||
timer = setInterval(updateTime, 1000)
|
timer = setInterval(updateTime, 1000)
|
||||||
@@ -225,6 +241,7 @@ onMounted(async () => {
|
|||||||
if (authStore.token && !authStore.user) {
|
if (authStore.token && !authStore.user) {
|
||||||
await authStore.fetchProfile()
|
await authStore.fetchProfile()
|
||||||
}
|
}
|
||||||
|
fetchVersion()
|
||||||
})
|
})
|
||||||
onUnmounted(() => { clearInterval(timer); document.removeEventListener('keydown', onKeydown) })
|
onUnmounted(() => { clearInterval(timer); document.removeEventListener('keydown', onKeydown) })
|
||||||
|
|
||||||
@@ -351,6 +368,9 @@ const pageNameMap = {
|
|||||||
}
|
}
|
||||||
const currentPageName = computed(() => pageNameMap[route.path] || '页面')
|
const currentPageName = computed(() => pageNameMap[route.path] || '页面')
|
||||||
|
|
||||||
|
const roleLabels = { admin: '超级管理员', area_admin: '区域管理员', school_admin: '学校管理员', user: '普通用户' }
|
||||||
|
const roleLabel = computed(() => roleLabels[authStore.role] || authStore.role)
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -510,6 +530,49 @@ const handleLogout = () => {
|
|||||||
border-top: 1px solid var(--border-subtle);
|
border-top: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-info {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { isAfterSunset, getMsUntilNextSwitch } from '../utils/sunset'
|
||||||
|
|
||||||
export const useThemeStore = defineStore('theme', () => {
|
export const useThemeStore = defineStore('theme', () => {
|
||||||
const STORAGE_KEY = 'onu-theme'
|
const STORAGE_KEY = 'onu-theme'
|
||||||
const theme = ref(localStorage.getItem(STORAGE_KEY) || 'dark')
|
const savedTheme = localStorage.getItem(STORAGE_KEY)
|
||||||
|
// 无手动偏好时,根据广西日落时间自动选择
|
||||||
|
const theme = ref(savedTheme || (isAfterSunset() ? 'dark' : 'light'))
|
||||||
|
|
||||||
const applyTheme = (t) => {
|
const applyTheme = (t) => {
|
||||||
document.documentElement.setAttribute('data-theme', t)
|
document.documentElement.setAttribute('data-theme', t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let switchTimer = null
|
||||||
|
|
||||||
// 初始化时立即应用
|
// 初始化时立即应用
|
||||||
applyTheme(theme.value)
|
applyTheme(theme.value)
|
||||||
|
|
||||||
|
// 设置日落/日出自动切换定时器,仅在用户未手动选择时生效
|
||||||
|
const scheduleAutoSwitch = () => {
|
||||||
|
if (switchTimer) clearTimeout(switchTimer)
|
||||||
|
// 始终在日落/日出时自动切换
|
||||||
|
const delay = getMsUntilNextSwitch()
|
||||||
|
switchTimer = setTimeout(() => {
|
||||||
|
theme.value = isAfterSunset() ? 'dark' : 'light'
|
||||||
|
scheduleAutoSwitch() // 递归调度下一次
|
||||||
|
}, delay + 60000) // 加 1 分钟余量
|
||||||
|
}
|
||||||
|
scheduleAutoSwitch()
|
||||||
|
|
||||||
// 切换
|
// 切换
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* 广西(南宁)日落时间计算
|
||||||
|
* 纬度 22.82°N, 经度 108.37°E, 时区 UTC+8
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LAT = 22.82 // 南宁纬度
|
||||||
|
const LON = 108.37 // 南宁经度
|
||||||
|
|
||||||
|
function toRad(deg) { return deg * Math.PI / 180 }
|
||||||
|
function toDeg(rad) { return rad * 180 / Math.PI }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算指定日期的日落时间(北京时间)
|
||||||
|
* @param {Date} date
|
||||||
|
* @returns {{ hour: number, minute: number }} 日落时分
|
||||||
|
*/
|
||||||
|
export function getSunsetTime(date = new Date()) {
|
||||||
|
const dayOfYear = Math.floor((date - new Date(date.getFullYear(), 0, 0)) / 86400000)
|
||||||
|
|
||||||
|
// 太阳赤纬 (solar declination)
|
||||||
|
const declination = 23.45 * Math.sin(toRad(360 / 365 * (284 + dayOfYear)))
|
||||||
|
|
||||||
|
// 日落时角 cos(ω) = -tan(lat)*tan(δ)
|
||||||
|
const cosOmega = -Math.tan(toRad(LAT)) * Math.tan(toRad(declination))
|
||||||
|
const omega = Math.acos(Math.max(-1, Math.min(1, cosOmega))) // 弧度
|
||||||
|
|
||||||
|
// 日落地方太阳时(小时)
|
||||||
|
const solarHour = 12 + toDeg(omega) / 15
|
||||||
|
|
||||||
|
// 修正:时区经度(120°E)与本地经度差
|
||||||
|
const correction = (120 - LON) / 15 * 60 // 分钟
|
||||||
|
const totalMinutes = solarHour * 60 + correction
|
||||||
|
|
||||||
|
const hour = Math.floor(totalMinutes / 60) % 24
|
||||||
|
const minute = Math.round(totalMinutes % 60)
|
||||||
|
|
||||||
|
return { hour, minute }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前是否在日落之后(应使用深色模式)
|
||||||
|
*/
|
||||||
|
export function isAfterSunset() {
|
||||||
|
const now = new Date()
|
||||||
|
const sunset = getSunsetTime(now)
|
||||||
|
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||||
|
const sunsetMinutes = sunset.hour * 60 + sunset.minute
|
||||||
|
// 日出约为 12 - (sunset - 12) = 24 - sunset(粗略估算)
|
||||||
|
const sunriseMinutes = (24 * 60 - sunsetMinutes) % (24 * 60)
|
||||||
|
// 深色时间:日落之后 到 日出之前
|
||||||
|
return currentMinutes >= sunsetMinutes || currentMinutes < sunriseMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取距离下次切换的毫秒数
|
||||||
|
* 用于设置定时器在日落/日出时自动切换
|
||||||
|
*/
|
||||||
|
export function getMsUntilNextSwitch() {
|
||||||
|
const now = new Date()
|
||||||
|
const sunset = getSunsetTime(now)
|
||||||
|
const sunsetMin = sunset.hour * 60 + sunset.minute
|
||||||
|
const sunriseMin = (24 * 60 - sunsetMin) % (24 * 60)
|
||||||
|
const currentMin = now.getHours() * 60 + now.getMinutes()
|
||||||
|
|
||||||
|
let targetMin
|
||||||
|
if (currentMin >= sunsetMin || currentMin < sunriseMin) {
|
||||||
|
// 当前是深色时间,下次切换是日出
|
||||||
|
targetMin = sunriseMin
|
||||||
|
} else {
|
||||||
|
// 当前是浅色时间,下次切换是日落
|
||||||
|
targetMin = sunsetMin
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMin = (targetMin - currentMin + 24 * 60) % (24 * 60)
|
||||||
|
return diffMin * 60 * 1000
|
||||||
|
}
|
||||||
@@ -951,7 +951,7 @@ const runQuickScan = async () => {
|
|||||||
const runLoopbackDetection = async () => {
|
const runLoopbackDetection = async () => {
|
||||||
loopDetecting.value = true
|
loopDetecting.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await request.post('/olt/loopback-detection')
|
const { data } = await request.post('/olt/loopback-detection', null, { timeout: 120000 })
|
||||||
loopResults.value = data
|
loopResults.value = data
|
||||||
const map = {}
|
const map = {}
|
||||||
for (const item of data) {
|
for (const item of data) {
|
||||||
|
|||||||
Reference in New Issue
Block a user