Initial commit
This commit is contained in:
commit
b8974dce59
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Python
|
||||||
|
.venv/
|
||||||
|
backend/.venv/
|
||||||
|
Python/
|
||||||
|
**/__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyd
|
||||||
|
*.so
|
||||||
|
*.dll
|
||||||
|
*.dylib
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Python packaging / caches
|
||||||
|
backend/.deps-cache/
|
||||||
|
backend/.pip-cache/
|
||||||
|
backend/.pip-cache-py38/
|
||||||
|
backend/UNKNOWN.egg-info/
|
||||||
|
backend/emcp_backend.egg-info/
|
||||||
|
backend/.tmp_ws_check.py
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
backend/data/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
*.tsbuildinfo
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
454
.trae/skills/python-fullstack-scaffold/SKILL.md
Normal file
454
.trae/skills/python-fullstack-scaffold/SKILL.md
Normal file
@ -0,0 +1,454 @@
|
|||||||
|
---
|
||||||
|
name: "python-fullstack-scaffold"
|
||||||
|
description: "为 FastAPI + WebSocket + Web 前端系统生成前后端开发框架。用户需要搭建或重构 RK3568 电气量测控平台的 Python 全栈代码结构时调用。"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Python Fullstack Scaffold
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
当用户出现以下意图时,调用此技能:
|
||||||
|
|
||||||
|
- 要根据需求文档搭建 `FastAPI + WebSocket + Web前端` 的项目骨架
|
||||||
|
- 要为 `RK3568 / Ubuntu 20.04 / Python 3.8` 平台设计前后端分层结构
|
||||||
|
- 要从接口文档反推后端路由、数据模型、配置存储和前端页面模块
|
||||||
|
- 要统一实时数据、设备状态、报警事件、参数配置的代码组织方式
|
||||||
|
- 要为嵌入式 C 程序与 Python 服务之间的适配层预留稳定接口
|
||||||
|
|
||||||
|
如果用户只是修一个接口、改一个组件、补一个测试,不要调用此技能。
|
||||||
|
|
||||||
|
## 技能目标
|
||||||
|
|
||||||
|
基于“电气量测控平台系统需求分析与架构设计技术方案”,输出一套可直接落地的前后端开发框架,覆盖:
|
||||||
|
|
||||||
|
- 后端目录结构
|
||||||
|
- 前端目录结构
|
||||||
|
- 核心领域模型
|
||||||
|
- RESTful API 路由分层
|
||||||
|
- WebSocket 推送框架
|
||||||
|
- JSON/SQLite 数据存储组织
|
||||||
|
- 嵌入式适配层抽象
|
||||||
|
- 开发顺序、验收点与最小可运行骨架
|
||||||
|
|
||||||
|
## 固定技术约束
|
||||||
|
|
||||||
|
在生成方案或代码时,默认遵循以下约束,除非用户明确要求变更:
|
||||||
|
|
||||||
|
- 平台:`RK3568`,系统为 `Ubuntu 20.04`
|
||||||
|
- 后端:`Python 3.8`、`FastAPI`、`Uvicorn`
|
||||||
|
- 前端:Web 前端,优先采用当前项目已有技术栈;若项目未定型,则使用轻量、易部署的前端组织方式
|
||||||
|
- 通信:`HTTP RESTful API + WebSocket`
|
||||||
|
- 配置存储:`JSON`
|
||||||
|
- 报警存储:`SQLite`
|
||||||
|
- 实时数据:仅内存缓存,不落盘
|
||||||
|
- 实时刷新周期:`0.5 秒`
|
||||||
|
- 指令下发响应目标:`1 秒内`
|
||||||
|
- 系统需支持 `7 x 24` 连续运行
|
||||||
|
|
||||||
|
## 总体架构原则
|
||||||
|
|
||||||
|
始终按下面的职责边界组织代码:
|
||||||
|
|
||||||
|
1. 硬件层:`RK3568 + AI/AO + 开关量 + 网络/串口 + 显示屏`
|
||||||
|
2. 嵌入式 C 程序层:采集、控制执行、状态监测、报警生成
|
||||||
|
3. Python 服务层:接口、业务编排、缓存、存储、推送
|
||||||
|
4. Web 界面层:配置、状态、实时数据、报警、控制
|
||||||
|
|
||||||
|
禁止把以下职责混在一起:
|
||||||
|
|
||||||
|
- 路由层里直接写文件读写和数据库细节
|
||||||
|
- WebSocket 推送逻辑散落在多个业务模块中
|
||||||
|
- 前端页面组件里直接硬编码接口路径和协议细节
|
||||||
|
- C 程序适配逻辑与业务规则耦合
|
||||||
|
|
||||||
|
## 后端推荐目录
|
||||||
|
|
||||||
|
当用户要求搭建后端框架时,优先生成或对齐为如下结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
app/
|
||||||
|
main.py
|
||||||
|
core/
|
||||||
|
config.py
|
||||||
|
logging.py
|
||||||
|
security.py
|
||||||
|
response.py
|
||||||
|
exceptions.py
|
||||||
|
api/
|
||||||
|
deps.py
|
||||||
|
router.py
|
||||||
|
routes/
|
||||||
|
realtime.py
|
||||||
|
status.py
|
||||||
|
config_device.py
|
||||||
|
config_channel.py
|
||||||
|
config_alarm.py
|
||||||
|
config_system.py
|
||||||
|
alarms.py
|
||||||
|
control.py
|
||||||
|
schemas/
|
||||||
|
common.py
|
||||||
|
realtime.py
|
||||||
|
status.py
|
||||||
|
device_config.py
|
||||||
|
channel_config.py
|
||||||
|
alarm_setting.py
|
||||||
|
system_config.py
|
||||||
|
alarm_event.py
|
||||||
|
control.py
|
||||||
|
services/
|
||||||
|
realtime_service.py
|
||||||
|
status_service.py
|
||||||
|
config_service.py
|
||||||
|
alarm_service.py
|
||||||
|
control_service.py
|
||||||
|
sync_service.py
|
||||||
|
repositories/
|
||||||
|
json_config_repo.py
|
||||||
|
alarm_repo.py
|
||||||
|
adapters/
|
||||||
|
c_device_client.py
|
||||||
|
mock_device_client.py
|
||||||
|
protocol_models.py
|
||||||
|
ws/
|
||||||
|
manager.py
|
||||||
|
realtime_publisher.py
|
||||||
|
alarm_publisher.py
|
||||||
|
db/
|
||||||
|
sqlite.py
|
||||||
|
models.py
|
||||||
|
cache/
|
||||||
|
memory_store.py
|
||||||
|
tasks/
|
||||||
|
polling.py
|
||||||
|
utils/
|
||||||
|
time_utils.py
|
||||||
|
backup.py
|
||||||
|
config/
|
||||||
|
device.json
|
||||||
|
channel.json
|
||||||
|
setting.json
|
||||||
|
data/
|
||||||
|
alarm.db
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端推荐目录
|
||||||
|
|
||||||
|
当用户要求搭建前端框架时,优先生成或对齐为如下结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/
|
||||||
|
src/
|
||||||
|
main.ts
|
||||||
|
App.vue
|
||||||
|
router/
|
||||||
|
index.ts
|
||||||
|
api/
|
||||||
|
http.ts
|
||||||
|
realtime.ts
|
||||||
|
status.ts
|
||||||
|
config.ts
|
||||||
|
alarm.ts
|
||||||
|
control.ts
|
||||||
|
websocket/
|
||||||
|
realtime.ts
|
||||||
|
alarm.ts
|
||||||
|
reconnect.ts
|
||||||
|
stores/
|
||||||
|
realtime.ts
|
||||||
|
status.ts
|
||||||
|
alarm.ts
|
||||||
|
config.ts
|
||||||
|
views/
|
||||||
|
RealtimeView.vue
|
||||||
|
StatusView.vue
|
||||||
|
DeviceConfigView.vue
|
||||||
|
ChannelConfigView.vue
|
||||||
|
AlarmSettingView.vue
|
||||||
|
AlarmHistoryView.vue
|
||||||
|
ControlView.vue
|
||||||
|
SystemConfigView.vue
|
||||||
|
components/
|
||||||
|
status/
|
||||||
|
realtime/
|
||||||
|
alarm/
|
||||||
|
config/
|
||||||
|
control/
|
||||||
|
types/
|
||||||
|
api.ts
|
||||||
|
realtime.ts
|
||||||
|
status.ts
|
||||||
|
config.ts
|
||||||
|
alarm.ts
|
||||||
|
utils/
|
||||||
|
format.ts
|
||||||
|
validators.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
如果仓库已有前端目录,则优先复用现有技术栈、构建工具和状态管理方式,不要无意义重建。
|
||||||
|
|
||||||
|
## 领域模型拆分
|
||||||
|
|
||||||
|
构建代码框架时,必须先把需求拆成以下核心模型:
|
||||||
|
|
||||||
|
### 1. 实时数据模型
|
||||||
|
|
||||||
|
- `line_list`
|
||||||
|
- `switch`
|
||||||
|
- `ai_collect`
|
||||||
|
|
||||||
|
其中 `line_list` 需要体现:
|
||||||
|
|
||||||
|
- `line_no`
|
||||||
|
- 一次值 `pri_val`
|
||||||
|
- 二次值 `sec_val`
|
||||||
|
|
||||||
|
### 2. 设备状态模型
|
||||||
|
|
||||||
|
- `self_check`
|
||||||
|
- `net1`
|
||||||
|
- `net2`
|
||||||
|
- `uart1`
|
||||||
|
- `uart2`
|
||||||
|
|
||||||
|
### 3. 设备基础配置模型
|
||||||
|
|
||||||
|
- 操作密码
|
||||||
|
- 硬件版本信息
|
||||||
|
- 软件版本信息
|
||||||
|
- 网口配置
|
||||||
|
- 串口配置
|
||||||
|
|
||||||
|
### 4. 通道配置模型
|
||||||
|
|
||||||
|
- `ai_channel`
|
||||||
|
- `ao_channel`
|
||||||
|
|
||||||
|
### 5. 定值与 AI 报警设置模型
|
||||||
|
|
||||||
|
- 线路告警阈值
|
||||||
|
- 故障告警设置
|
||||||
|
- AI 通道报警设置
|
||||||
|
|
||||||
|
### 6. 系统配置模型
|
||||||
|
|
||||||
|
- 对时设置
|
||||||
|
- 亮度设置
|
||||||
|
- 屏保时间
|
||||||
|
|
||||||
|
### 7. 报警事件模型
|
||||||
|
|
||||||
|
- `alarm_type`
|
||||||
|
- `time`
|
||||||
|
- `no`
|
||||||
|
- `type`
|
||||||
|
- `content`
|
||||||
|
- `level`
|
||||||
|
|
||||||
|
### 8. 控制指令模型
|
||||||
|
|
||||||
|
- `ch`
|
||||||
|
- `action`
|
||||||
|
|
||||||
|
## API 设计规则
|
||||||
|
|
||||||
|
生成后端接口时,统一遵守以下规范:
|
||||||
|
|
||||||
|
- 所有 HTTP 接口统一响应结构:`code`、`msg`、`data`
|
||||||
|
- 路由层仅负责参数校验、调用服务、返回标准响应
|
||||||
|
- `GET /api/real-time-data` 作为实时数据备用查询接口
|
||||||
|
- `GET /api/device-status` 用于设备状态读取
|
||||||
|
- `POST /api/config/device` 持久化设备基础配置并下发设备
|
||||||
|
- `POST /api/config/channel` 持久化 AI/AO 通道配置并下发设备
|
||||||
|
- `POST /api/config/line_alarm_setting` 持久化线路定值与故障告警设置
|
||||||
|
- `POST /api/config/ai_alarm_setting` 持久化 AI 告警设置
|
||||||
|
- `POST /api/config/system` 处理对时、亮度、屏保配置,并实时下发
|
||||||
|
- `GET /api/alarm/list` 分页查询报警历史
|
||||||
|
- `POST /api/control/switch` 下发开关量控制指令
|
||||||
|
|
||||||
|
如果用户要求生成代码,优先先搭出路由、Schema、Service、Repository 的闭环,再补设备适配与前端页面。
|
||||||
|
|
||||||
|
## WebSocket 设计规则
|
||||||
|
|
||||||
|
必须把 WebSocket 当作一等公民设计,不要退化为轮询替代品。
|
||||||
|
|
||||||
|
### 实时数据通道
|
||||||
|
|
||||||
|
- 地址:`/ws/real-time`
|
||||||
|
- 推送周期:`0.5 秒`
|
||||||
|
- 推送内容:`real_time`
|
||||||
|
- 数据来源:内存缓存中的最新采样结果
|
||||||
|
|
||||||
|
### 报警通道
|
||||||
|
|
||||||
|
- 地址:`/ws/alarm`
|
||||||
|
- 触发方式:报警事件产生即推送
|
||||||
|
- 推送内容:单条报警事件或标准化事件消息
|
||||||
|
|
||||||
|
### WebSocket 实现要求
|
||||||
|
|
||||||
|
- 统一连接管理器
|
||||||
|
- 支持多客户端广播
|
||||||
|
- 支持断线清理
|
||||||
|
- 前端必须具备自动重连
|
||||||
|
- 推送数据结构必须和前端类型定义一致
|
||||||
|
|
||||||
|
## 存储设计规则
|
||||||
|
|
||||||
|
### JSON 配置
|
||||||
|
|
||||||
|
以下配置默认存放在 `config/`:
|
||||||
|
|
||||||
|
- `device.json`
|
||||||
|
- `channel.json`
|
||||||
|
- `setting.json`
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 保存前先做 Schema 校验
|
||||||
|
- 写入时支持原子替换
|
||||||
|
- 修改后自动备份旧版本
|
||||||
|
- 关键字段变更后调用设备下发接口
|
||||||
|
|
||||||
|
### SQLite 报警库
|
||||||
|
|
||||||
|
报警事件单独存放在 SQLite 中,至少包含:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `alarm_type`
|
||||||
|
- `time`
|
||||||
|
- `no`
|
||||||
|
- `type`
|
||||||
|
- `content`
|
||||||
|
- `level`
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 写入逻辑集中在 Repository
|
||||||
|
- 查询支持分页
|
||||||
|
- 前端历史页调用分页接口,不直接访问数据库
|
||||||
|
|
||||||
|
### 内存缓存
|
||||||
|
|
||||||
|
以下数据只保存在内存中:
|
||||||
|
|
||||||
|
- 实时量测数据
|
||||||
|
- 实时设备状态
|
||||||
|
- 最近待推送报警缓冲
|
||||||
|
|
||||||
|
## 嵌入式适配层规则
|
||||||
|
|
||||||
|
当用户要落地与 C 程序交互的代码框架时,必须建立设备适配层,不要让业务层直接依赖具体协议细节。
|
||||||
|
|
||||||
|
至少抽象以下能力:
|
||||||
|
|
||||||
|
- 读取实时数据
|
||||||
|
- 读取设备状态
|
||||||
|
- 读取报警事件
|
||||||
|
- 下发基础配置
|
||||||
|
- 下发通道配置
|
||||||
|
- 下发系统设置
|
||||||
|
- 下发开关控制
|
||||||
|
|
||||||
|
推荐做法:
|
||||||
|
|
||||||
|
- 定义统一客户端接口,例如 `DeviceClient`
|
||||||
|
- 提供 `MockDeviceClient` 供联调和前端开发使用
|
||||||
|
- 提供 `CDeviceClient` 作为真实嵌入式对接实现
|
||||||
|
- 通过配置切换 mock / real 模式
|
||||||
|
|
||||||
|
## 安全与可靠性规则
|
||||||
|
|
||||||
|
必须纳入以下要求:
|
||||||
|
|
||||||
|
- 密码不得明文长期存储,至少做哈希处理
|
||||||
|
- 配置接口增加基本权限校验能力
|
||||||
|
- WebSocket 支持断线重连
|
||||||
|
- 配置写入支持备份
|
||||||
|
- 服务异常场景需易于接入守护或自动重启机制
|
||||||
|
|
||||||
|
## 推荐开发顺序
|
||||||
|
|
||||||
|
当用户要求“开始搭框架”时,按以下顺序推进:
|
||||||
|
|
||||||
|
1. 先梳理接口、数据模型、目录结构
|
||||||
|
2. 再生成后端基础工程:配置、日志、异常、统一响应
|
||||||
|
3. 再生成 Schema、Repository、Service、Router
|
||||||
|
4. 再搭建内存缓存、轮询任务、WebSocket 管理器
|
||||||
|
5. 再补 SQLite 与 JSON 持久化
|
||||||
|
6. 再抽象设备适配层和 mock 数据源
|
||||||
|
7. 最后生成前端 API 封装、状态管理、页面骨架与 WebSocket 客户端
|
||||||
|
|
||||||
|
## 输出要求
|
||||||
|
|
||||||
|
调用本技能后,输出内容应尽量包含以下几项:
|
||||||
|
|
||||||
|
### 如果用户要“方案”
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
- 建议目录结构
|
||||||
|
- 模块职责划分
|
||||||
|
- 核心数据模型清单
|
||||||
|
- API / WebSocket 对应关系
|
||||||
|
- 开发阶段拆分
|
||||||
|
|
||||||
|
### 如果用户要“直接写代码”
|
||||||
|
|
||||||
|
输出并执行:
|
||||||
|
|
||||||
|
- 先识别现有仓库中 `backend/`、`frontend/` 的当前结构
|
||||||
|
- 尽量增量修改,不要推倒重来
|
||||||
|
- 先落最小可运行骨架,再补细节
|
||||||
|
- 优先保证接口契约、模块边界、可测试性
|
||||||
|
|
||||||
|
## 生成代码时的验收清单
|
||||||
|
|
||||||
|
生成或修改后,至少检查:
|
||||||
|
|
||||||
|
- 后端是否存在统一应用入口
|
||||||
|
- 路由、服务、仓储、适配层是否职责分离
|
||||||
|
- 是否覆盖需求文档中的全部 API
|
||||||
|
- 是否存在 `/ws/real-time` 与 `/ws/alarm`
|
||||||
|
- 实时数据是否只走内存缓存
|
||||||
|
- 报警历史是否落 SQLite
|
||||||
|
- 配置是否落 JSON
|
||||||
|
- 前端是否拆出页面、API、WebSocket、类型定义
|
||||||
|
- 是否保留 mock 联调能力
|
||||||
|
|
||||||
|
## 常用提示模板
|
||||||
|
|
||||||
|
### 模板 1:搭建完整框架
|
||||||
|
|
||||||
|
```text
|
||||||
|
请基于当前仓库和需求文档,搭建 RK3568 电气量测控平台的前后端代码框架。
|
||||||
|
后端使用 FastAPI,前端沿用仓库现有技术栈。
|
||||||
|
要求包含目录结构、统一响应、配置读写、SQLite 告警存储、WebSocket 实时推送、设备适配层与 mock 数据源。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模板 2:只搭后端骨架
|
||||||
|
|
||||||
|
```text
|
||||||
|
请按 python-fullstack-scaffold 技能,为该项目生成后端骨架。
|
||||||
|
要求覆盖 realtime、status、config、alarm、control 五类能力,并预留嵌入式 C 程序适配层。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模板 3:只搭前端骨架
|
||||||
|
|
||||||
|
```text
|
||||||
|
请按 python-fullstack-scaffold 技能,为该项目生成前端页面骨架和 API/WebSocket 接入层。
|
||||||
|
要求覆盖实时数据、设备状态、基础配置、通道配置、报警历史和控制页面。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 执行提醒
|
||||||
|
|
||||||
|
在实际执行前,先确认以下信息:
|
||||||
|
|
||||||
|
- 当前仓库是否已经存在 `backend/` 和 `frontend/`
|
||||||
|
- 前端当前使用的具体技术栈和构建工具
|
||||||
|
- 后端是否已有可复用入口、配置系统和测试结构
|
||||||
|
- 用户要的是“技能文件”、 “设计方案” 还是 “直接生成代码”
|
||||||
|
|
||||||
|
若信息不足,先补充上下文;若仓库已有实现,优先做兼容式演进,而不是重建。
|
||||||
16
backend/README.md
Normal file
16
backend/README.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# EMCP Backend
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .[dev]
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 默认使用 `MockDeviceClient` 产生实时数据和模拟告警
|
||||||
|
- 需要鉴权的接口使用请求头 `X-API-Token: admin123`
|
||||||
|
- WebSocket 通道:
|
||||||
|
- `/ws/real-time`
|
||||||
|
- `/ws/alarm`
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
157
backend/app/adapters/device_client.py
Normal file
157
backend/app/adapters/device_client.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import datetime
|
||||||
|
from random import Random
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from app.schemas.platform import (
|
||||||
|
AlarmEvent,
|
||||||
|
ChannelConfigIn,
|
||||||
|
DeviceConfigIn,
|
||||||
|
DeviceStatus,
|
||||||
|
LineAlarmSettingIn,
|
||||||
|
LineData,
|
||||||
|
RealtimeData,
|
||||||
|
SystemConfigIn,
|
||||||
|
SwitchControlIn,
|
||||||
|
ValueGroup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceClient(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def read_realtime_data(self) -> RealtimeData:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def read_device_status(self) -> DeviceStatus:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def read_alarm_events(self) -> List[AlarmEvent]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def send_device_config(self, payload: DeviceConfigIn) -> Dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def send_channel_config(self, payload: ChannelConfigIn) -> Dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def send_line_alarm_setting(self, payload: LineAlarmSettingIn) -> Dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def send_ai_alarm_setting(self, payload: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def send_system_config(self, payload: SystemConfigIn) -> Dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def send_switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class MockDeviceClient(DeviceClient):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._random = Random(3568)
|
||||||
|
self._tick = 0
|
||||||
|
|
||||||
|
def _value_group(self, base_u: float, base_i: float, base_p: float) -> ValueGroup:
|
||||||
|
delta = self._tick % 5
|
||||||
|
return ValueGroup(
|
||||||
|
Ua=base_u + delta,
|
||||||
|
Ub=base_u + 2 + delta,
|
||||||
|
Uc=base_u - 1 + delta,
|
||||||
|
Ia=base_i + 0.1 * delta,
|
||||||
|
Ib=base_i - 0.1 + 0.1 * delta,
|
||||||
|
Ic=base_i + 0.2 + 0.1 * delta,
|
||||||
|
Pa=base_p + delta,
|
||||||
|
Pb=base_p - 2 + delta,
|
||||||
|
Pc=base_p + 3 + delta,
|
||||||
|
Pt=base_p * 3 + delta,
|
||||||
|
Qa=12 + delta,
|
||||||
|
Qb=11 + delta,
|
||||||
|
Qc=13 + delta,
|
||||||
|
Qt=36 + delta,
|
||||||
|
Sa=base_p + 15 + delta,
|
||||||
|
Sb=base_p + 10 + delta,
|
||||||
|
Sc=base_p + 16 + delta,
|
||||||
|
St=base_p * 3 + 44 + delta,
|
||||||
|
PFa=0.98,
|
||||||
|
PFb=0.97,
|
||||||
|
PFc=0.99,
|
||||||
|
PFt=0.98,
|
||||||
|
Uab=base_u * 1.73,
|
||||||
|
Ubc=base_u * 1.72,
|
||||||
|
Uca=base_u * 1.73,
|
||||||
|
frq=50.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_realtime_data(self) -> RealtimeData:
|
||||||
|
self._tick += 1
|
||||||
|
line_list = [
|
||||||
|
LineData(line_no=1, pri_val=self._value_group(6000, 150, 820), sec_val=self._value_group(57.6, 1.2, 68)),
|
||||||
|
LineData(line_no=2, pri_val=self._value_group(5990, 148, 810), sec_val=self._value_group(57.2, 1.1, 66)),
|
||||||
|
LineData(line_no=3, pri_val=self._value_group(6010, 151, 830), sec_val=self._value_group(58.1, 1.3, 70)),
|
||||||
|
LineData(line_no=4, pri_val=self._value_group(6020, 149, 825), sec_val=self._value_group(58.4, 1.2, 69)),
|
||||||
|
]
|
||||||
|
switch = {f"di{i}": int((i + self._tick) % 2 == 0) for i in range(1, 13)}
|
||||||
|
switch.update({f"do{i}": int((i + self._tick + 1) % 2 == 0) for i in range(1, 13)})
|
||||||
|
ai_collect = {f"ai{i}": round(1 + self._random.random() * 5 + (self._tick % 3) * 0.1, 2) for i in range(1, 13)}
|
||||||
|
return RealtimeData(line_list=line_list, switch=switch, ai_collect=ai_collect)
|
||||||
|
|
||||||
|
def read_device_status(self) -> DeviceStatus:
|
||||||
|
return DeviceStatus(
|
||||||
|
self_check="正常",
|
||||||
|
net1="正常",
|
||||||
|
net2="正常",
|
||||||
|
uart1="正常",
|
||||||
|
uart2="正常" if self._tick % 4 else "断开",
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_alarm_events(self) -> List[AlarmEvent]:
|
||||||
|
if self._tick % 6 != 0:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
AlarmEvent(
|
||||||
|
alarm_type="line_alarm",
|
||||||
|
time=datetime.now(),
|
||||||
|
no=str((self._tick // 6) % 4 + 1),
|
||||||
|
type="电压",
|
||||||
|
content="模拟告警:线路电压越限",
|
||||||
|
level="中",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def send_device_config(self, payload: DeviceConfigIn) -> Dict[str, Any]:
|
||||||
|
return {"send_status": "成功", "target": "device", "items": len(payload.net) + len(payload.uart)}
|
||||||
|
|
||||||
|
def send_channel_config(self, payload: ChannelConfigIn) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"send_status": "成功",
|
||||||
|
"target": "channel",
|
||||||
|
"items": len(payload.ai_channel) + len(payload.ao_channel),
|
||||||
|
}
|
||||||
|
|
||||||
|
def send_line_alarm_setting(self, payload: LineAlarmSettingIn) -> Dict[str, Any]:
|
||||||
|
return {"send_status": "成功", "target": "line_alarm", "line_no": payload.line_no}
|
||||||
|
|
||||||
|
def send_ai_alarm_setting(self, payload: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
return {"send_status": "成功", "target": "ai_alarm", "items": len(payload)}
|
||||||
|
|
||||||
|
def send_system_config(self, payload: SystemConfigIn) -> Dict[str, Any]:
|
||||||
|
return {"send_status": "成功", "target": "system", "brightness": payload.brightness}
|
||||||
|
|
||||||
|
def send_switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
|
||||||
|
action_text = "合" if payload.action == 1 else "分"
|
||||||
|
return {"control_status": f"执行成功: 开关{payload.ch}{action_text}"}
|
||||||
|
|
||||||
|
|
||||||
|
class CDeviceClient(MockDeviceClient):
|
||||||
|
"""真实 C 程序接入前先复用 mock 行为,后续在此类中替换协议实现。"""
|
||||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
12
backend/app/api/router.py
Normal file
12
backend/app/api/router.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.routes import alarms, auth, config, control, realtime, status
|
||||||
|
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(realtime.router)
|
||||||
|
api_router.include_router(status.router)
|
||||||
|
api_router.include_router(auth.router)
|
||||||
|
api_router.include_router(config.router)
|
||||||
|
api_router.include_router(alarms.router)
|
||||||
|
api_router.include_router(control.router)
|
||||||
1
backend/app/api/routes/__init__.py
Normal file
1
backend/app/api/routes/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
13
backend/app/api/routes/alarms.py
Normal file
13
backend/app/api/routes/alarms.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
|
from app.core.response import success_response
|
||||||
|
from app.services.platform_service import platform_service
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/alarm", tags=["alarm"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
def list_alarms(page: int = Query(default=1, ge=1), size: int = Query(default=20, ge=1, le=100)) -> dict:
|
||||||
|
data = platform_service.list_alarms(page=page, size=size)
|
||||||
|
return success_response(data, msg="获取报警历史成功")
|
||||||
16
backend/app/api/routes/auth.py
Normal file
16
backend/app/api/routes/auth.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.core.response import success_response
|
||||||
|
from app.core.security import verify_access_password
|
||||||
|
from app.schemas.platform import PasswordVerifyIn
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify-password")
|
||||||
|
def verify_password_api(payload: PasswordVerifyIn) -> Dict[str, Any]:
|
||||||
|
is_valid = verify_access_password(payload.password)
|
||||||
|
return success_response(is_valid, msg="密码校验完成")
|
||||||
36
backend/app/api/routes/config.py
Normal file
36
backend/app/api/routes/config.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.core.response import success_response
|
||||||
|
from app.core.security import verify_api_token
|
||||||
|
from app.schemas.platform import AiAlarmSettingIn, ChannelConfigIn, DeviceConfigIn, LineAlarmSettingIn, SystemConfigIn
|
||||||
|
from app.services.platform_service import platform_service
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/config", tags=["config"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/device", dependencies=[Depends(verify_api_token)])
|
||||||
|
def save_device_config(payload: DeviceConfigIn) -> Dict[str, Any]:
|
||||||
|
return success_response(platform_service.save_device_config(payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/channel", dependencies=[Depends(verify_api_token)])
|
||||||
|
def save_channel_config(payload: ChannelConfigIn) -> Dict[str, Any]:
|
||||||
|
return success_response(platform_service.save_channel_config(payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/line_alarm_setting", dependencies=[Depends(verify_api_token)])
|
||||||
|
def save_line_alarm_setting(payload: LineAlarmSettingIn) -> Dict[str, Any]:
|
||||||
|
return success_response(platform_service.save_line_alarm_setting(payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ai_alarm_setting", dependencies=[Depends(verify_api_token)])
|
||||||
|
def save_ai_alarm_setting(payload: List[AiAlarmSettingIn]) -> Dict[str, Any]:
|
||||||
|
return success_response(platform_service.save_ai_alarm_setting(payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/system", dependencies=[Depends(verify_api_token)])
|
||||||
|
def save_system_config(payload: SystemConfigIn) -> Dict[str, Any]:
|
||||||
|
return success_response(platform_service.save_system_config(payload))
|
||||||
14
backend/app/api/routes/control.py
Normal file
14
backend/app/api/routes/control.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.core.response import success_response
|
||||||
|
from app.core.security import verify_api_token
|
||||||
|
from app.schemas.platform import SwitchControlIn
|
||||||
|
from app.services.platform_service import platform_service
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/control", tags=["control"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/switch", dependencies=[Depends(verify_api_token)])
|
||||||
|
def switch_control(payload: SwitchControlIn) -> dict:
|
||||||
|
return success_response(platform_service.switch_control(payload))
|
||||||
13
backend/app/api/routes/realtime.py
Normal file
13
backend/app/api/routes/realtime.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.core.response import success_response
|
||||||
|
from app.services.platform_service import platform_service
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["realtime"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/real-time-data")
|
||||||
|
def get_realtime_data() -> dict:
|
||||||
|
data = platform_service.get_realtime_data()
|
||||||
|
return success_response(data.model_dump(), msg="获取实时数据成功")
|
||||||
13
backend/app/api/routes/status.py
Normal file
13
backend/app/api/routes/status.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.core.response import success_response
|
||||||
|
from app.services.platform_service import platform_service
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["status"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/device-status")
|
||||||
|
def get_device_status() -> dict:
|
||||||
|
data = platform_service.get_device_status()
|
||||||
|
return success_response(data.model_dump(), msg="获取设备状态成功")
|
||||||
37
backend/app/cache/memory_store.py
vendored
Normal file
37
backend/app/cache/memory_store.py
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Deque, Optional
|
||||||
|
|
||||||
|
from app.schemas.platform import AlarmEvent, DeviceStatus, RealtimeData
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MemoryStore:
|
||||||
|
realtime_data: Optional[RealtimeData] = None
|
||||||
|
device_status: Optional[DeviceStatus] = None
|
||||||
|
recent_alarms: Deque[AlarmEvent] = field(default_factory=lambda: deque(maxlen=100))
|
||||||
|
_lock: Lock = field(default_factory=Lock)
|
||||||
|
|
||||||
|
def set_realtime(self, data: RealtimeData) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.realtime_data = data
|
||||||
|
|
||||||
|
def get_realtime(self) -> Optional[RealtimeData]:
|
||||||
|
with self._lock:
|
||||||
|
return self.realtime_data
|
||||||
|
|
||||||
|
def set_status(self, status: DeviceStatus) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.device_status = status
|
||||||
|
|
||||||
|
def get_status(self) -> Optional[DeviceStatus]:
|
||||||
|
with self._lock:
|
||||||
|
return self.device_status
|
||||||
|
|
||||||
|
def push_alarm(self, alarm: AlarmEvent) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.recent_alarms.appendleft(alarm)
|
||||||
|
|
||||||
|
|
||||||
|
memory_store = MemoryStore()
|
||||||
31
backend/app/core/config.py
Normal file
31
backend/app/core/config.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
app_name: str = "电气量测控平台"
|
||||||
|
app_version: str = "0.1.0"
|
||||||
|
api_prefix: str = "/api"
|
||||||
|
realtime_ws_path: str = "/ws/real-time"
|
||||||
|
alarm_ws_path: str = "/ws/alarm"
|
||||||
|
poll_interval_seconds: float = 0.5
|
||||||
|
auth_password: str = "admin123"
|
||||||
|
use_mock_device: bool = True
|
||||||
|
config_dir: Path = BASE_DIR / "config"
|
||||||
|
data_dir: Path = BASE_DIR / "data"
|
||||||
|
alarm_db_path: Path = BASE_DIR / "data" / "alarm.db"
|
||||||
|
backup_dir: Path = BASE_DIR / "data" / "backups"
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="EMCP_",
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
9
backend/app/core/response.py
Normal file
9
backend/app/core/response.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
def success_response(data: Any = None, msg: str = "操作成功", code: int = 200) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"msg": msg,
|
||||||
|
"data": data if data is not None else {},
|
||||||
|
}
|
||||||
27
backend/app/core/security.py
Normal file
27
backend/app/core/security.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Header, HTTPException, status
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, expected_hash: str) -> bool:
|
||||||
|
return hash_password(password) == expected_hash
|
||||||
|
|
||||||
|
|
||||||
|
def verify_access_password(password: str) -> bool:
|
||||||
|
return secrets.compare_digest(password, settings.auth_password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_api_token(x_api_token: Optional[str] = Header(default=None)) -> None:
|
||||||
|
if x_api_token != settings.auth_password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="无效的访问令牌",
|
||||||
|
)
|
||||||
33
backend/app/db/sqlite.py
Normal file
33
backend/app/db/sqlite.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection(db_path: Optional[Path] = None) -> sqlite3.Connection:
|
||||||
|
path = db_path or settings.alarm_db_path
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
connection = sqlite3.connect(path)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS alarm_event (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
alarm_type TEXT NOT NULL,
|
||||||
|
time TEXT NOT NULL,
|
||||||
|
no TEXT,
|
||||||
|
type TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
65
backend/app/main.py
Normal file
65
backend/app/main.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.router import api_router
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.sqlite import init_db
|
||||||
|
from app.tasks.polling import device_polling_loop, stop_task
|
||||||
|
from app.ws.manager import ws_manager
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
init_db()
|
||||||
|
poll_task = asyncio.create_task(device_polling_loop())
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await stop_task(poll_task)
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
version=settings.app_version,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(api_router, prefix=settings.api_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def healthcheck() -> dict:
|
||||||
|
return {"status": "ok", "service": settings.app_name}
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket(settings.realtime_ws_path)
|
||||||
|
async def realtime_ws(websocket: WebSocket) -> None:
|
||||||
|
await ws_manager.connect("real-time", websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
ws_manager.disconnect("real-time", websocket)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket(settings.alarm_ws_path)
|
||||||
|
async def alarm_ws(websocket: WebSocket) -> None:
|
||||||
|
await ws_manager.connect("alarm", websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
ws_manager.disconnect("alarm", websocket)
|
||||||
41
backend/app/repositories/alarm_repo.py
Normal file
41
backend/app/repositories/alarm_repo.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from app.db.sqlite import get_connection
|
||||||
|
from app.schemas.platform import AlarmEvent
|
||||||
|
|
||||||
|
|
||||||
|
class AlarmRepository:
|
||||||
|
def save_alarm(self, alarm: AlarmEvent) -> int:
|
||||||
|
with get_connection() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO alarm_event (alarm_type, time, no, type, content, level)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
alarm.alarm_type,
|
||||||
|
alarm.time.isoformat(sep=" ", timespec="seconds"),
|
||||||
|
alarm.no,
|
||||||
|
alarm.type,
|
||||||
|
alarm.content,
|
||||||
|
alarm.level,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
def list_alarms(self, page: int = 1, size: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
offset = (page - 1) * size
|
||||||
|
with get_connection() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, alarm_type, time, no, type, content, level
|
||||||
|
FROM alarm_event
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(size, offset),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
39
backend/app/repositories/json_config_repo.py
Normal file
39
backend/app/repositories/json_config_repo.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.utils.backup import backup_file
|
||||||
|
|
||||||
|
|
||||||
|
class JsonConfigRepository:
|
||||||
|
def __init__(self, config_dir: Optional[Path] = None) -> None:
|
||||||
|
self.config_dir = config_dir or settings.config_dir
|
||||||
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _write_json(self, filename: str, payload: Union[Dict[str, Any], List[Any]]) -> Path:
|
||||||
|
path = self.config_dir / filename
|
||||||
|
backup_file(path, settings.backup_dir)
|
||||||
|
temp_path = path.with_suffix(".tmp")
|
||||||
|
with temp_path.open("w", encoding="utf-8") as file:
|
||||||
|
json.dump(payload, file, ensure_ascii=False, indent=2)
|
||||||
|
temp_path.replace(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def write_device_config(self, payload: Dict[str, Any]) -> Path:
|
||||||
|
return self._write_json("device.json", payload)
|
||||||
|
|
||||||
|
def write_channel_config(self, payload: Dict[str, Any]) -> Path:
|
||||||
|
return self._write_json("channel.json", payload)
|
||||||
|
|
||||||
|
def write_setting_config(self, payload: Dict[str, Any]) -> Path:
|
||||||
|
return self._write_json("setting.json", payload)
|
||||||
|
|
||||||
|
def read_json(self, filename: str) -> Union[Dict[str, Any], List[Any]]:
|
||||||
|
path = self.config_dir / filename
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
with path.open("r", encoding="utf-8") as file:
|
||||||
|
return json.load(file)
|
||||||
14
backend/app/schemas/common.py
Normal file
14
backend/app/schemas/common.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ApiResponse(BaseModel):
|
||||||
|
code: int = 200
|
||||||
|
msg: str = "操作成功"
|
||||||
|
data: Any = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationQuery(BaseModel):
|
||||||
|
page: int = Field(default=1, ge=1)
|
||||||
|
size: int = Field(default=20, ge=1, le=100)
|
||||||
158
backend/app/schemas/platform.py
Normal file
158
backend/app/schemas/platform.py
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ValueGroup(BaseModel):
|
||||||
|
Ua: float = 0.0
|
||||||
|
Ub: float = 0.0
|
||||||
|
Uc: float = 0.0
|
||||||
|
Ia: float = 0.0
|
||||||
|
Ib: float = 0.0
|
||||||
|
Ic: float = 0.0
|
||||||
|
Pa: float = 0.0
|
||||||
|
Pb: float = 0.0
|
||||||
|
Pc: float = 0.0
|
||||||
|
Pt: float = 0.0
|
||||||
|
Qa: float = 0.0
|
||||||
|
Qb: float = 0.0
|
||||||
|
Qc: float = 0.0
|
||||||
|
Qt: float = 0.0
|
||||||
|
Sa: float = 0.0
|
||||||
|
Sb: float = 0.0
|
||||||
|
Sc: float = 0.0
|
||||||
|
St: float = 0.0
|
||||||
|
PFa: float = 0.0
|
||||||
|
PFb: float = 0.0
|
||||||
|
PFc: float = 0.0
|
||||||
|
PFt: float = 0.0
|
||||||
|
Uab: float = 0.0
|
||||||
|
Ubc: float = 0.0
|
||||||
|
Uca: float = 0.0
|
||||||
|
frq: float = 50.0
|
||||||
|
|
||||||
|
|
||||||
|
class LineData(BaseModel):
|
||||||
|
line_no: int
|
||||||
|
pri_val: ValueGroup = Field(default_factory=ValueGroup)
|
||||||
|
sec_val: ValueGroup = Field(default_factory=ValueGroup)
|
||||||
|
|
||||||
|
|
||||||
|
class RealtimeData(BaseModel):
|
||||||
|
line_list: List[LineData]
|
||||||
|
switch: Dict[str, int]
|
||||||
|
ai_collect: Dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
class RealtimeEnvelope(BaseModel):
|
||||||
|
type: str = "real_time"
|
||||||
|
data: RealtimeData
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceStatus(BaseModel):
|
||||||
|
self_check: str = "正常"
|
||||||
|
net1: str = "正常"
|
||||||
|
net2: str = "正常"
|
||||||
|
uart1: str = "正常"
|
||||||
|
uart2: str = "断开"
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareVersion(BaseModel):
|
||||||
|
board_version: str = "B001.001.001"
|
||||||
|
display_version: str = "S001.001.001"
|
||||||
|
other_version: str = "Y001.001.001"
|
||||||
|
|
||||||
|
|
||||||
|
class SoftwareVersion(BaseModel):
|
||||||
|
display_program: str = "001.001.001"
|
||||||
|
communication_program: str = "001.001.001"
|
||||||
|
measurement_program: str = "001.001.001"
|
||||||
|
|
||||||
|
|
||||||
|
class NetConfigItem(BaseModel):
|
||||||
|
nic: str
|
||||||
|
ip: str = ""
|
||||||
|
mask: str = ""
|
||||||
|
gateway: str = ""
|
||||||
|
protocol: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class UartConfigItem(BaseModel):
|
||||||
|
port: str
|
||||||
|
baud: int = 9600
|
||||||
|
parity: str = "NONE"
|
||||||
|
data_bits: int = 8
|
||||||
|
stop_bits: int = 1
|
||||||
|
protocol: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceConfigIn(BaseModel):
|
||||||
|
password: str = "123456"
|
||||||
|
hardware_version: HardwareVersion = Field(default_factory=HardwareVersion)
|
||||||
|
software_version: SoftwareVersion = Field(default_factory=SoftwareVersion)
|
||||||
|
net: List[NetConfigItem] = Field(default_factory=list)
|
||||||
|
uart: List[UartConfigItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelItem(BaseModel):
|
||||||
|
ch: int
|
||||||
|
singal_type: str
|
||||||
|
line_no: int
|
||||||
|
type: str
|
||||||
|
limit_low: float
|
||||||
|
limit_high: float
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelConfigIn(BaseModel):
|
||||||
|
ai_channel: List[ChannelItem] = Field(default_factory=list)
|
||||||
|
ao_channel: List[ChannelItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AlarmRule(BaseModel):
|
||||||
|
category: str
|
||||||
|
limit: Optional[float] = None
|
||||||
|
delay: int = 0
|
||||||
|
output_node: str = ""
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class LineAlarmSettingIn(BaseModel):
|
||||||
|
line_no: int
|
||||||
|
over_limit_alarm: List[AlarmRule] = Field(default_factory=list)
|
||||||
|
fault_alarm: List[AlarmRule] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AiAlarmSettingIn(BaseModel):
|
||||||
|
channel_no: int
|
||||||
|
singal_type: str
|
||||||
|
limit_low: float
|
||||||
|
limit_high: float
|
||||||
|
delay: int = 0
|
||||||
|
output_node: str = ""
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfigIn(BaseModel):
|
||||||
|
time_sync: str = "auto"
|
||||||
|
brightness: int = Field(default=80, ge=0, le=100)
|
||||||
|
screen_saver: int = Field(default=60, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordVerifyIn(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class AlarmEvent(BaseModel):
|
||||||
|
id: Optional[int] = None
|
||||||
|
alarm_type: str
|
||||||
|
time: datetime
|
||||||
|
no: str = ""
|
||||||
|
type: str = ""
|
||||||
|
content: str
|
||||||
|
level: str = "一般"
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchControlIn(BaseModel):
|
||||||
|
ch: int = Field(ge=1)
|
||||||
|
action: int = Field(ge=0, le=1)
|
||||||
105
backend/app/services/platform_service.py
Normal file
105
backend/app/services/platform_service.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from app.adapters.device_client import CDeviceClient, MockDeviceClient
|
||||||
|
from app.cache.memory_store import memory_store
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.repositories.alarm_repo import AlarmRepository
|
||||||
|
from app.repositories.json_config_repo import JsonConfigRepository
|
||||||
|
from app.schemas.platform import (
|
||||||
|
AiAlarmSettingIn,
|
||||||
|
ChannelConfigIn,
|
||||||
|
DeviceConfigIn,
|
||||||
|
DeviceStatus,
|
||||||
|
LineAlarmSettingIn,
|
||||||
|
RealtimeData,
|
||||||
|
SwitchControlIn,
|
||||||
|
SystemConfigIn,
|
||||||
|
)
|
||||||
|
from app.ws.manager import ws_manager
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.config_repo = JsonConfigRepository()
|
||||||
|
self.alarm_repo = AlarmRepository()
|
||||||
|
self.device_client = MockDeviceClient() if settings.use_mock_device else CDeviceClient()
|
||||||
|
|
||||||
|
def get_realtime_data(self) -> RealtimeData:
|
||||||
|
cached = memory_store.get_realtime()
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
realtime = self.device_client.read_realtime_data()
|
||||||
|
memory_store.set_realtime(realtime)
|
||||||
|
return realtime
|
||||||
|
|
||||||
|
def get_device_status(self) -> DeviceStatus:
|
||||||
|
cached = memory_store.get_status()
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
status = self.device_client.read_device_status()
|
||||||
|
memory_store.set_status(status)
|
||||||
|
return status
|
||||||
|
|
||||||
|
def save_device_config(self, payload: DeviceConfigIn) -> Dict[str, Any]:
|
||||||
|
data = payload.model_dump()
|
||||||
|
data["password"] = hash_password(payload.password)
|
||||||
|
path = self.config_repo.write_device_config(data)
|
||||||
|
device_result = self.device_client.send_device_config(payload)
|
||||||
|
return {"save_path": f"/config/{path.name}", **device_result}
|
||||||
|
|
||||||
|
def save_channel_config(self, payload: ChannelConfigIn) -> Dict[str, Any]:
|
||||||
|
path = self.config_repo.write_channel_config(payload.model_dump())
|
||||||
|
device_result = self.device_client.send_channel_config(payload)
|
||||||
|
return {"save_path": f"/config/{path.name}", **device_result}
|
||||||
|
|
||||||
|
def save_line_alarm_setting(self, payload: LineAlarmSettingIn) -> Dict[str, Any]:
|
||||||
|
current = self.config_repo.read_json("setting.json")
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
current = {}
|
||||||
|
current["line_alarm_setting"] = payload.model_dump()
|
||||||
|
path = self.config_repo.write_setting_config(current)
|
||||||
|
device_result = self.device_client.send_line_alarm_setting(payload)
|
||||||
|
return {"save_path": f"/config/{path.name}", **device_result}
|
||||||
|
|
||||||
|
def save_ai_alarm_setting(self, payload: List[AiAlarmSettingIn]) -> Dict[str, Any]:
|
||||||
|
current = self.config_repo.read_json("setting.json")
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
current = {}
|
||||||
|
current["ai_alarm_setting"] = [item.model_dump() for item in payload]
|
||||||
|
path = self.config_repo.write_setting_config(current)
|
||||||
|
device_result = self.device_client.send_ai_alarm_setting(current["ai_alarm_setting"])
|
||||||
|
return {"save_path": f"/config/{path.name}", **device_result}
|
||||||
|
|
||||||
|
def save_system_config(self, payload: SystemConfigIn) -> Dict[str, Any]:
|
||||||
|
current = self.config_repo.read_json("setting.json")
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
current = {}
|
||||||
|
current["system_config"] = payload.model_dump()
|
||||||
|
self.config_repo.write_setting_config(current)
|
||||||
|
return self.device_client.send_system_config(payload)
|
||||||
|
|
||||||
|
def list_alarms(self, page: int, size: int) -> List[Dict[str, Any]]:
|
||||||
|
return self.alarm_repo.list_alarms(page=page, size=size)
|
||||||
|
|
||||||
|
def switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
|
||||||
|
return self.device_client.send_switch_control(payload)
|
||||||
|
|
||||||
|
async def poll_device_once(self) -> None:
|
||||||
|
realtime = self.device_client.read_realtime_data()
|
||||||
|
status = self.device_client.read_device_status()
|
||||||
|
alarms = self.device_client.read_alarm_events()
|
||||||
|
|
||||||
|
memory_store.set_realtime(realtime)
|
||||||
|
memory_store.set_status(status)
|
||||||
|
await ws_manager.broadcast("real-time", {"type": "real_time", "data": realtime.model_dump()})
|
||||||
|
|
||||||
|
for alarm in alarms:
|
||||||
|
alarm.id = self.alarm_repo.save_alarm(alarm)
|
||||||
|
memory_store.push_alarm(alarm)
|
||||||
|
await ws_manager.broadcast("alarm", alarm.model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
platform_service = PlatformService()
|
||||||
22
backend/app/tasks/polling.py
Normal file
22
backend/app/tasks/polling.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.platform_service import platform_service
|
||||||
|
|
||||||
|
|
||||||
|
async def device_polling_loop() -> None:
|
||||||
|
while True:
|
||||||
|
await platform_service.poll_device_once()
|
||||||
|
await asyncio.sleep(settings.poll_interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_task(task: Optional[asyncio.Task]) -> None:
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
14
backend/app/utils/backup.py
Normal file
14
backend/app/utils/backup.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def backup_file(source: Path, backup_dir: Path) -> None:
|
||||||
|
if not source.exists():
|
||||||
|
return
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
target = backup_dir / f"{source.stem}_{timestamp}{source.suffix}"
|
||||||
|
shutil.copy2(source, target)
|
||||||
32
backend/app/ws/manager.py
Normal file
32
backend/app/ws/manager.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, DefaultDict, Dict, List, Set
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketManager:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._connections: DefaultDict[str, Set[WebSocket]] = defaultdict(set)
|
||||||
|
|
||||||
|
async def connect(self, channel: str, websocket: WebSocket) -> None:
|
||||||
|
await websocket.accept()
|
||||||
|
self._connections[channel].add(websocket)
|
||||||
|
|
||||||
|
def disconnect(self, channel: str, websocket: WebSocket) -> None:
|
||||||
|
self._connections[channel].discard(websocket)
|
||||||
|
|
||||||
|
async def broadcast(self, channel: str, payload: Dict[str, Any]) -> None:
|
||||||
|
disconnected: List[WebSocket] = []
|
||||||
|
for websocket in self._connections[channel]:
|
||||||
|
try:
|
||||||
|
await websocket.send_text(json.dumps(payload, ensure_ascii=False, default=str))
|
||||||
|
except Exception:
|
||||||
|
disconnected.append(websocket)
|
||||||
|
for websocket in disconnected:
|
||||||
|
self.disconnect(channel, websocket)
|
||||||
|
|
||||||
|
|
||||||
|
ws_manager = WebSocketManager()
|
||||||
22
backend/config/channel.json
Normal file
22
backend/config/channel.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"ai_channel": [
|
||||||
|
{
|
||||||
|
"ch": 1,
|
||||||
|
"singal_type": "4-20mA22222",
|
||||||
|
"line_no": 1,
|
||||||
|
"type": "UA",
|
||||||
|
"limit_low": 0.0,
|
||||||
|
"limit_high": 20.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ao_channel": [
|
||||||
|
{
|
||||||
|
"ch": 1,
|
||||||
|
"singal_type": "1-5v",
|
||||||
|
"line_no": 2,
|
||||||
|
"type": "UA",
|
||||||
|
"limit_low": 0.0,
|
||||||
|
"limit_high": 20.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
47
backend/config/device.json
Normal file
47
backend/config/device.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"password": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92",
|
||||||
|
"hardware_version": {
|
||||||
|
"board_version": "B001.001.002",
|
||||||
|
"display_version": "S001.001.001",
|
||||||
|
"other_version": "Y001.001.001"
|
||||||
|
},
|
||||||
|
"software_version": {
|
||||||
|
"display_program": "001.001.001",
|
||||||
|
"communication_program": "001.001.001",
|
||||||
|
"measurement_program": "001.001.001"
|
||||||
|
},
|
||||||
|
"net": [
|
||||||
|
{
|
||||||
|
"nic": "网卡一",
|
||||||
|
"ip": "192.168.1.10",
|
||||||
|
"mask": "255.255.255.0",
|
||||||
|
"gateway": "192.168.1.1",
|
||||||
|
"protocol": "Modbus TCP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nic": "网卡二",
|
||||||
|
"ip": "192.168.1.56",
|
||||||
|
"mask": "255.255.255.255",
|
||||||
|
"gateway": "192.168.1.56",
|
||||||
|
"protocol": "Modbus TCP"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"uart": [
|
||||||
|
{
|
||||||
|
"port": "COM1",
|
||||||
|
"baud": 9600,
|
||||||
|
"parity": "NONE",
|
||||||
|
"data_bits": 8,
|
||||||
|
"stop_bits": 1,
|
||||||
|
"protocol": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": "COM2",
|
||||||
|
"baud": 115200,
|
||||||
|
"parity": "NONE",
|
||||||
|
"data_bits": 8,
|
||||||
|
"stop_bits": 1,
|
||||||
|
"protocol": "Modbus RTU"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
39
backend/config/setting.json
Normal file
39
backend/config/setting.json
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"line_alarm_setting": {
|
||||||
|
"line_no": 1,
|
||||||
|
"over_limit_alarm": [
|
||||||
|
{
|
||||||
|
"category": "??",
|
||||||
|
"limit": 180.0,
|
||||||
|
"delay": 180,
|
||||||
|
"output_node": "??1",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fault_alarm": [
|
||||||
|
{
|
||||||
|
"category": "PT??",
|
||||||
|
"limit": null,
|
||||||
|
"delay": 180,
|
||||||
|
"output_node": "??1",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ai_alarm_setting": [
|
||||||
|
{
|
||||||
|
"channel_no": 1,
|
||||||
|
"singal_type": "4-20mA",
|
||||||
|
"limit_low": 0.0,
|
||||||
|
"limit_high": 20.0,
|
||||||
|
"delay": 180,
|
||||||
|
"output_node": "开出1",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"system_config": {
|
||||||
|
"time_sync": "manual",
|
||||||
|
"brightness": 83,
|
||||||
|
"screen_saver": 60
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/pyproject.toml
Normal file
26
backend/pyproject.toml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
[project]
|
||||||
|
name = "emcp-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "RK3568 电气量测控平台后端骨架"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115,<1.0",
|
||||||
|
"uvicorn[standard]>=0.30,<1.0",
|
||||||
|
"pydantic>=2.7,<3.0",
|
||||||
|
"pydantic-settings>=2.3,<3.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.2,<9.0",
|
||||||
|
"httpx>=0.27,<1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
testpaths = ["tests"]
|
||||||
21
backend/setup.py
Normal file
21
backend/setup.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="emcp-backend",
|
||||||
|
version="0.1.0",
|
||||||
|
description="RK3568 电气量测控平台后端骨架",
|
||||||
|
packages=find_packages(),
|
||||||
|
install_requires=[
|
||||||
|
"fastapi>=0.115,<1.0",
|
||||||
|
"uvicorn[standard]>=0.30,<1.0",
|
||||||
|
"pydantic>=2.7,<3.0",
|
||||||
|
"pydantic-settings>=2.3,<3.0",
|
||||||
|
],
|
||||||
|
extras_require={
|
||||||
|
"dev": [
|
||||||
|
"pytest>=8.2,<9.0",
|
||||||
|
"httpx>=0.27,<1.0",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
20
backend/tests/test_api.py
Normal file
20
backend/tests/test_api.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthcheck() -> None:
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_realtime_endpoint() -> None:
|
||||||
|
response = client.get("/api/real-time-data")
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["code"] == 200
|
||||||
|
assert "line_list" in payload["data"]
|
||||||
207
document/gui.h
Normal file
207
document/gui.h
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
#ifndef __GUI_H
|
||||||
|
#define __GUI_H
|
||||||
|
|
||||||
|
#include "time.h"
|
||||||
|
|
||||||
|
#define LINE_TOTAL_NUM 4 //线路数量
|
||||||
|
#define ANALOG_CH_NUM 12 //AI、AO模拟量通道数
|
||||||
|
|
||||||
|
|
||||||
|
/*************上行数据*************************************************小核数据 -> 大核****************************************************************************/
|
||||||
|
|
||||||
|
//实时量
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
//三相电压
|
||||||
|
float Ua; //A相电压
|
||||||
|
float Ub; //B相电压
|
||||||
|
float Uc; //C相电压
|
||||||
|
|
||||||
|
//三相电流
|
||||||
|
float Ia; //A相电流
|
||||||
|
float Ib; //B相电流
|
||||||
|
float Ic; //C相电流
|
||||||
|
|
||||||
|
//有功功率
|
||||||
|
float Pa;
|
||||||
|
float Pb;
|
||||||
|
float Pc;
|
||||||
|
float Pt; //总有功功率
|
||||||
|
|
||||||
|
//无功功率
|
||||||
|
float Qa;
|
||||||
|
float Qb;
|
||||||
|
float Qc;
|
||||||
|
float Qt; //总无功功率
|
||||||
|
|
||||||
|
//视在功率
|
||||||
|
float Sa;
|
||||||
|
float Sb;
|
||||||
|
float Sc;
|
||||||
|
float St; //总视在功率
|
||||||
|
|
||||||
|
//功率因数
|
||||||
|
float PFa;
|
||||||
|
float PFb;
|
||||||
|
float PFc;
|
||||||
|
float PFt; //总功率因数
|
||||||
|
|
||||||
|
//线电压
|
||||||
|
float Uab;
|
||||||
|
float Ubc;
|
||||||
|
float Uca;
|
||||||
|
|
||||||
|
//频率
|
||||||
|
float frq;
|
||||||
|
|
||||||
|
}measure_unit_t;
|
||||||
|
|
||||||
|
//B码对时,时间数据
|
||||||
|
typedef union
|
||||||
|
{
|
||||||
|
uint8_t b_code[6];
|
||||||
|
|
||||||
|
struct
|
||||||
|
{
|
||||||
|
uint8_t ascii_s_unit : 4;
|
||||||
|
uint8_t ascii_s_ten : 4;
|
||||||
|
uint8_t ascii_min_unit : 4;
|
||||||
|
uint8_t ascii_min_ten : 4;
|
||||||
|
uint8_t ascii_hour_unit : 4;
|
||||||
|
uint8_t ascii_hour_ten : 4;
|
||||||
|
uint8_t ascii_m_day_unit: 4;
|
||||||
|
uint8_t ascii_m_day_ten : 4;
|
||||||
|
uint8_t ascii_month_unit: 4; //4bit
|
||||||
|
uint8_t ascii_month_ten : 4; //4bit
|
||||||
|
uint8_t ascii_year_unit : 4; //4bit
|
||||||
|
uint8_t ascii_year_ten : 4; //4bit
|
||||||
|
}time;
|
||||||
|
}b_code_t;
|
||||||
|
|
||||||
|
|
||||||
|
//开入、开出
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
uint16_t in; //每一位对应一个通道状态 : 1 = 合, 0 = 分
|
||||||
|
uint16_t out; //每一位对应一个通道状态 : 1 = 合, 0 = 分
|
||||||
|
}d_io_t;
|
||||||
|
|
||||||
|
//模拟量:AI AO
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
float in[ANALOG_CH_NUM]; //模拟量输入
|
||||||
|
float out[ANALOG_CH_NUM]; //模拟量输出
|
||||||
|
}a_io_t;
|
||||||
|
|
||||||
|
//事件
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
struct tm t; //事件发生时间
|
||||||
|
uint32_t ms; //事件发生的毫秒时刻
|
||||||
|
uint8_t evt; //类型:0 = 无事件, 1 = 事件发生
|
||||||
|
}event_t;
|
||||||
|
|
||||||
|
//波形数据
|
||||||
|
typedef struct
|
||||||
|
{ uint32_t stamp; //时标
|
||||||
|
int16_t ch[8]; //4条线路波形数据时:8个通道:ua、ub、uc, ia、ib、ic、频率系数、开入量
|
||||||
|
//AI采集通道波形数据时 :8个通道对应各个输入点
|
||||||
|
}ch_data_t;
|
||||||
|
|
||||||
|
//波形数据
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
uint8_t line_index; //线路号 : 0 ~ 3 = 4路电压电流采样数据,4 = AI采集数据
|
||||||
|
ch_data_t dot[64]; //64个点组成一个周波
|
||||||
|
}waveform_data_t;
|
||||||
|
|
||||||
|
|
||||||
|
//小核数据 -> 大核
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
measure_unit_t measure_unit[LINE_TOTAL_NUM];//总共4条线路,按序排列
|
||||||
|
|
||||||
|
a_io_t d_io; //模拟量输入、输出
|
||||||
|
|
||||||
|
d_io_t a_io; //数字量,开入、开出
|
||||||
|
|
||||||
|
b_code_t b_code; //B码对时
|
||||||
|
|
||||||
|
event_t event[2]; //事件标志:event[0] = pt 断线, event[1] = CT断线
|
||||||
|
|
||||||
|
waveform_data_t waveform; //实时一周波数据
|
||||||
|
|
||||||
|
}gui_in_t;
|
||||||
|
|
||||||
|
|
||||||
|
/********下行数据******************************************************大核数据 -> 小核****************************************************************************/
|
||||||
|
//界面任一参数修改,即将整个参数表更新下发一次
|
||||||
|
|
||||||
|
|
||||||
|
//串口参数设置
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
uint32_t baud_tate; //波特率 : 9600 115200(默认)
|
||||||
|
uint8_t parity; //校验位 : 0 = 无校验, 1 = 奇校验, 2 = 偶校验
|
||||||
|
uint8_t data; //数据位 : 0 = 8位
|
||||||
|
uint8_t stop; //停止位 : 0 = 1位, 1 = 2位
|
||||||
|
uint8_t protocol; //通讯协议 :0 = 无协议,1 = modbus
|
||||||
|
}com_param_t;
|
||||||
|
|
||||||
|
|
||||||
|
//模拟通道参数值
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
uint8_t sig_type;//信号类型 : 0 = 4~20ma信号, 1 = 1~5V信号
|
||||||
|
uint8_t line_sub;//所属线路 : 1 = 线路1, 2 = 线路2, 3 =线路3, 4 = 线路4, 0 = 装置
|
||||||
|
uint8_t att; //类别 : 0 = UA, 1 = UB, 2 = UC, 3 = UAB, 4 = UBC, 5 = UCA, 6 = P, 7 = Q, 8 = F
|
||||||
|
uint8_t u_limit; //上限值 :
|
||||||
|
uint8_t d_limit; //下限值 :
|
||||||
|
}analog_ch_param_t;
|
||||||
|
|
||||||
|
|
||||||
|
//线路定值->开出设置
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
// uint8_t index; //线路号: 1 = 线路1, 2 = 线路2, 3 = 线路3, 4 = 线路4
|
||||||
|
uint8_t limit; //限值:
|
||||||
|
uint8_t delay; //延时: [1 ~ 10秒]
|
||||||
|
uint8_t out_num; //输出节点: [1 = 开出1、 2 = 开出2、...]
|
||||||
|
uint8_t select; //投退: [0 = 不投, 1 = 投入]
|
||||||
|
}line_param_t;
|
||||||
|
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
line_param_t type[7]; //7个类别:电压,电流,差流, 功率,频率,PT断线,CT断线
|
||||||
|
}line_type_t;
|
||||||
|
|
||||||
|
|
||||||
|
//AI 报警-> 开出设置
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
uint8_t u_limit; //上限值 :
|
||||||
|
uint8_t d_limit; //下限值 :
|
||||||
|
uint8_t delay; //延时: [1 ~ 10秒]
|
||||||
|
uint8_t out_num; //输出节点: [1 = 开出1、 2 = 开出2、...]
|
||||||
|
uint8_t select; //投退: [0 = 不投, 1 = 投入]
|
||||||
|
}ai_alarm_param_t;
|
||||||
|
|
||||||
|
|
||||||
|
//大核数据 -> 小核
|
||||||
|
//设置模拟量,数字量输出
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
struct tm t; //时间设置
|
||||||
|
|
||||||
|
d_io_t a_io; //数字量,开入、开出
|
||||||
|
|
||||||
|
com_param_t com_param[4]; //串口参数设置
|
||||||
|
line_type_t line_param[4]; //线路定值设置: 4路定值一起下发
|
||||||
|
analog_ch_param_t a_in_param[12]; //12路AI模拟输入通道设置
|
||||||
|
analog_ch_param_t a_out_param[12]; //12路AO模拟输出通道设置
|
||||||
|
ai_alarm_param_t ai_alarm_param[12];//12路AI模拟输入报警参数设置
|
||||||
|
|
||||||
|
}gui_out_t;
|
||||||
|
|
||||||
|
|
||||||
|
#endif
|
||||||
422
document/前后端启动说明.md
Normal file
422
document/前后端启动说明.md
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
# 电气量测控平台前后端启动说明
|
||||||
|
|
||||||
|
## 1. 文档说明
|
||||||
|
|
||||||
|
本文档用于说明 `EMCP` 项目前后端的开发环境准备、依赖安装、启动步骤、访问地址以及常见问题处理方式。
|
||||||
|
|
||||||
|
项目目录:
|
||||||
|
|
||||||
|
- 根目录:`d:\Trae_space\EMCP`
|
||||||
|
- 后端目录:`d:\Trae_space\EMCP\backend`
|
||||||
|
- 前端目录:`d:\Trae_space\EMCP\frontend`
|
||||||
|
- 文档目录:`d:\Trae_space\EMCP\document`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 当前项目技术栈
|
||||||
|
|
||||||
|
### 2.1 后端
|
||||||
|
|
||||||
|
- 语言:`Python 3.8`
|
||||||
|
- 当前虚拟环境版本:`Python 3.8.10`
|
||||||
|
- 框架:`FastAPI`
|
||||||
|
- 服务启动:`Uvicorn`
|
||||||
|
- 配置存储:`JSON`
|
||||||
|
- 报警存储:`SQLite`
|
||||||
|
- 实时通信:`WebSocket`
|
||||||
|
|
||||||
|
### 2.2 前端
|
||||||
|
|
||||||
|
- 框架:`Vue 3`
|
||||||
|
- 构建工具:`Vite`
|
||||||
|
- HTTP 请求:`Axios`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 启动前准备
|
||||||
|
|
||||||
|
### 3.1 后端准备
|
||||||
|
|
||||||
|
后端目录已创建虚拟环境:
|
||||||
|
|
||||||
|
- 虚拟环境路径:`d:\Trae_space\EMCP\backend\.venv`
|
||||||
|
|
||||||
|
如虚拟环境不存在,可在 `backend` 目录重新创建:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd d:\Trae_space\EMCP\backend
|
||||||
|
py -3.8 -m venv .venv
|
||||||
|
```
|
||||||
|
|
||||||
|
激活虚拟环境:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd d:\Trae_space\EMCP\backend
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 前端准备
|
||||||
|
|
||||||
|
确保本机已安装:
|
||||||
|
|
||||||
|
- `Node.js`
|
||||||
|
- `npm`
|
||||||
|
|
||||||
|
进入前端目录后通过 `npm` 安装依赖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 首次安装依赖
|
||||||
|
|
||||||
|
### 4.1 安装后端依赖
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd d:\Trae_space\EMCP\backend
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
pip install -e .[dev]
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 当前项目已包含 `setup.py` 与 `pyproject.toml`
|
||||||
|
- 若 `pip install -e .[dev]` 因环境兼容问题失败,可先升级 `pip` 后重试:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -e .[dev]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 安装前端依赖
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd d:\Trae_space\EMCP\frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 启动后端
|
||||||
|
|
||||||
|
### 5.1 启动命令
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd d:\Trae_space\EMCP\backend
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
.\.venv\Scripts\python.exe -m uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 启动成功标志
|
||||||
|
|
||||||
|
终端出现类似信息表示启动成功:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Uvicorn running on http://127.0.0.1:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 后端访问地址
|
||||||
|
|
||||||
|
- 健康检查:`http://127.0.0.1:8000/`
|
||||||
|
- 接口文档:`http://127.0.0.1:8000/docs`
|
||||||
|
- OpenAPI:`http://127.0.0.1:8000/openapi.json`
|
||||||
|
|
||||||
|
### 5.4 后端主要接口
|
||||||
|
|
||||||
|
- 实时数据:`GET /api/real-time-data`
|
||||||
|
- 设备状态:`GET /api/device-status`
|
||||||
|
- 设备配置:`POST /api/config/device`
|
||||||
|
- 通道配置:`POST /api/config/channel`
|
||||||
|
- 线路报警设置:`POST /api/config/line_alarm_setting`
|
||||||
|
- AI 报警设置:`POST /api/config/ai_alarm_setting`
|
||||||
|
- 系统设置:`POST /api/config/system`
|
||||||
|
- 报警历史:`GET /api/alarm/list`
|
||||||
|
- 控制指令:`POST /api/control/switch`
|
||||||
|
|
||||||
|
### 5.5 WebSocket 地址
|
||||||
|
|
||||||
|
- 实时数据:`ws://127.0.0.1:8000/ws/real-time`
|
||||||
|
- 报警推送:`ws://127.0.0.1:8000/ws/alarm`
|
||||||
|
|
||||||
|
### 5.6 后端 Debug 调试启动
|
||||||
|
|
||||||
|
开发阶段如果需要断点调试后端,推荐使用以下两种方式。
|
||||||
|
|
||||||
|
#### 方式一:在 IDE 中直接调试 `uvicorn`
|
||||||
|
|
||||||
|
适用于 `Trae / VS Code / Cursor` 一类支持 `launch.json` 的编辑器。
|
||||||
|
|
||||||
|
可在工作区创建或补充调试配置文件:
|
||||||
|
|
||||||
|
- `d:\Trae_space\EMCP\.vscode\launch.json`
|
||||||
|
|
||||||
|
示例配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug FastAPI Backend",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "uvicorn",
|
||||||
|
"cwd": "d:\\Trae_space\\EMCP\\backend",
|
||||||
|
"args": [
|
||||||
|
"app.main:app",
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--port",
|
||||||
|
"8000",
|
||||||
|
"--reload"
|
||||||
|
],
|
||||||
|
"jinja": true,
|
||||||
|
"justMyCode": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
|
||||||
|
1. 打开项目根目录 `d:\Trae_space\EMCP`
|
||||||
|
2. 选择 `Debug FastAPI Backend`
|
||||||
|
3. 启动调试
|
||||||
|
4. 在 `backend/app/` 下设置断点
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `cwd` 必须指向 `backend`,否则 `app.main:app` 可能导入失败
|
||||||
|
- 调试时建议使用后端虚拟环境解释器:`d:\Trae_space\EMCP\backend\.venv\Scripts\python.exe`
|
||||||
|
- 如果 `--reload` 导致断点命中不稳定,可临时去掉 `--reload`
|
||||||
|
|
||||||
|
#### 方式二:使用 `debugpy` 启动后再附加调试
|
||||||
|
|
||||||
|
先安装调试依赖:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd d:\Trae_space\EMCP\backend
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
pip install debugpy
|
||||||
|
```
|
||||||
|
|
||||||
|
然后使用以下命令启动:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd d:\Trae_space\EMCP\backend
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
python -m debugpy --listen 5678 -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后可在 IDE 中使用“附加到进程/Attach”方式连接:
|
||||||
|
|
||||||
|
- 主机:`127.0.0.1`
|
||||||
|
- 端口:`5678`
|
||||||
|
|
||||||
|
如果不需要热重载,也可以使用更稳定的命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd d:\Trae_space\EMCP\backend
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
python -m debugpy --listen 5678 -m uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Debug 调试建议
|
||||||
|
|
||||||
|
- 需要稳定断点时,优先关闭 `--reload`
|
||||||
|
- 排查接口逻辑时,建议在以下位置打断点:
|
||||||
|
- `backend/app/api/routes/`
|
||||||
|
- `backend/app/services/platform_service.py`
|
||||||
|
- `backend/app/adapters/device_client.py`
|
||||||
|
- 如果要调试模拟采集链路,可重点查看:
|
||||||
|
- `backend/app/tasks/polling.py`
|
||||||
|
- `backend/app/cache/memory_store.py`
|
||||||
|
- `backend/app/ws/manager.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 启动前端
|
||||||
|
|
||||||
|
### 6.1 启动命令
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd d:\Trae_space\EMCP\frontend
|
||||||
|
npm run dev -- --host 127.0.0.1 --port 5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 启动成功标志
|
||||||
|
|
||||||
|
终端出现类似地址表示启动成功:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Local: http://127.0.0.1:5173/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 前端访问地址
|
||||||
|
|
||||||
|
- 前端页面:`http://127.0.0.1:5173/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 推荐启动顺序
|
||||||
|
|
||||||
|
建议按以下顺序启动:
|
||||||
|
|
||||||
|
1. 先启动后端
|
||||||
|
2. 再启动前端
|
||||||
|
3. 浏览器打开前端地址进行联调
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 前端默认请求后端接口:`http://127.0.0.1:8000/api`
|
||||||
|
- 前端默认连接后端 WebSocket:
|
||||||
|
- `ws://127.0.0.1:8000/ws/real-time`
|
||||||
|
- `ws://127.0.0.1:8000/ws/alarm`
|
||||||
|
|
||||||
|
前端接口配置位置:
|
||||||
|
|
||||||
|
- `frontend/src/api/http.ts`
|
||||||
|
|
||||||
|
默认请求头中已携带:
|
||||||
|
|
||||||
|
- `X-API-Token: admin123`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 模拟系统说明
|
||||||
|
|
||||||
|
当前项目后端默认启用模拟设备采集。
|
||||||
|
|
||||||
|
后端配置位置:
|
||||||
|
|
||||||
|
- `backend/app/core/config.py`
|
||||||
|
|
||||||
|
当前关键配置:
|
||||||
|
|
||||||
|
```python
|
||||||
|
use_mock_device: bool = True
|
||||||
|
poll_interval_seconds: float = 0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `use_mock_device = True` 表示使用模拟设备数据源
|
||||||
|
- 后端会按 `0.5` 秒周期采集模拟数据
|
||||||
|
- 前端可用于联调实时数据、设备状态、报警历史、控制指令、配置保存等功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 当前已打通页面
|
||||||
|
|
||||||
|
当前前端页面均已可以进入,已完成联调的页面如下:
|
||||||
|
|
||||||
|
- 实时数据
|
||||||
|
- 设备状态
|
||||||
|
- 设备配置
|
||||||
|
- 通道配置
|
||||||
|
- 报警设置
|
||||||
|
- 报警历史
|
||||||
|
- 控制指令
|
||||||
|
- 系统设置
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `设备状态` 支持手动刷新
|
||||||
|
- `报警历史` 支持分页和刷新
|
||||||
|
- `设备配置 / 通道配置 / 报警设置` 已接通真实后端提交接口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 常用开发命令
|
||||||
|
|
||||||
|
### 10.1 后端测试
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd d:\Trae_space\EMCP\backend
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
python -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 前端构建
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd d:\Trae_space\EMCP\frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 常见问题
|
||||||
|
|
||||||
|
### 11.1 后端启动失败,提示类型注解错误
|
||||||
|
|
||||||
|
如果出现类似报错:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TypeError: 'type' object is not subscriptable
|
||||||
|
```
|
||||||
|
|
||||||
|
原因通常是 Python 版本不兼容。当前项目要求使用:
|
||||||
|
|
||||||
|
- `Python 3.8`
|
||||||
|
|
||||||
|
请确认虚拟环境版本是否正确。
|
||||||
|
|
||||||
|
### 11.2 前端页面打不开
|
||||||
|
|
||||||
|
请检查:
|
||||||
|
|
||||||
|
- 前端服务是否已启动
|
||||||
|
- 地址是否为 `http://127.0.0.1:5173/`
|
||||||
|
- `npm install` 是否已执行成功
|
||||||
|
|
||||||
|
### 11.3 前端有页面但接口不通
|
||||||
|
|
||||||
|
请检查:
|
||||||
|
|
||||||
|
- 后端服务是否已启动
|
||||||
|
- 后端地址是否为 `http://127.0.0.1:8000`
|
||||||
|
- `frontend/src/api/http.ts` 中 `baseURL` 是否正确
|
||||||
|
|
||||||
|
### 11.4 页面能打开但实时数据不更新
|
||||||
|
|
||||||
|
请检查:
|
||||||
|
|
||||||
|
- 后端是否正常运行
|
||||||
|
- 后端轮询任务是否已启动
|
||||||
|
- WebSocket 是否可连
|
||||||
|
- 若 WebSocket 未连通,前端当前会自动回退到 HTTP 轮询读取实时数据
|
||||||
|
|
||||||
|
### 11.5 保存接口返回 401 或 403
|
||||||
|
|
||||||
|
请检查请求头中是否包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-API-Token: admin123
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 关闭服务
|
||||||
|
|
||||||
|
在各自终端中按:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ctrl + C
|
||||||
|
```
|
||||||
|
|
||||||
|
即可停止前端或后端服务。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 建议
|
||||||
|
|
||||||
|
日常开发建议保持两个独立终端:
|
||||||
|
|
||||||
|
- 终端 1:后端 `uvicorn`
|
||||||
|
- 终端 2:前端 `vite`
|
||||||
|
|
||||||
|
如果需要后续交付或部署,可在本说明基础上继续补充:
|
||||||
|
|
||||||
|
- 生产环境启动说明
|
||||||
|
- Windows 开机自启说明
|
||||||
|
- Ubuntu / RK3568 部署说明
|
||||||
|
- Nginx 反向代理说明
|
||||||
579
document/在RK3568设备上系统部署及使用说明.md
Normal file
579
document/在RK3568设备上系统部署及使用说明.md
Normal file
@ -0,0 +1,579 @@
|
|||||||
|
# RK3568 设备上系统部署及使用说明
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
本文档用于说明如何在 `RK3568 + Ubuntu 20.04` 设备上部署 `EMCP` 电气量测控平台的前后端系统,并完成以下目标:
|
||||||
|
|
||||||
|
- 部署后端 `FastAPI + Uvicorn` 服务
|
||||||
|
- 部署前端静态页面
|
||||||
|
- 配置前后端开机自动启动
|
||||||
|
- 登录桌面后自动打开浏览器并访问主页面
|
||||||
|
- 给出现场快速设置和日常使用说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 适用范围
|
||||||
|
|
||||||
|
适用对象:
|
||||||
|
|
||||||
|
- 硬件平台:`RK3568`
|
||||||
|
- 操作系统:`Ubuntu 20.04`
|
||||||
|
- 部署模式:前后端均部署在同一台设备本机
|
||||||
|
|
||||||
|
当前工程实际情况:
|
||||||
|
|
||||||
|
- 后端运行端口:`8000`
|
||||||
|
- 前端访问地址建议使用:`http://127.0.0.1:5173/`
|
||||||
|
- 前端默认请求后端地址:`http://127.0.0.1:8000/api`
|
||||||
|
- 前端默认请求头中携带:`X-API-Token: admin123`
|
||||||
|
- 默认安全校验密码:`admin123`
|
||||||
|
- 后端默认启用模拟采集:`use_mock_device = True`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 当前前端代码默认调用本机 `127.0.0.1:8000` 的后端接口,因此推荐前后端部署在同一台 RK3568 设备上
|
||||||
|
- 如果后续需要将前后端拆分到不同设备,需要同步调整前端接口配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 部署建议
|
||||||
|
|
||||||
|
推荐目录规划如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/emcp/
|
||||||
|
├── backend/
|
||||||
|
├── frontend/
|
||||||
|
└── scripts/
|
||||||
|
```
|
||||||
|
|
||||||
|
建议使用以下方式部署:
|
||||||
|
|
||||||
|
- 后端:`systemd` 托管 `uvicorn`
|
||||||
|
- 前端:前端执行 `npm run build` 后,用轻量静态服务对 `dist/` 目录发布
|
||||||
|
- 浏览器自启动:使用桌面自动启动项 `~/.config/autostart/*.desktop`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 环境准备
|
||||||
|
|
||||||
|
### 4.1 安装系统依赖
|
||||||
|
|
||||||
|
在 RK3568 设备终端执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y python3.8 python3.8-venv python3-pip curl git
|
||||||
|
sudo apt install -y nodejs npm
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- Ubuntu 20.04 默认可直接使用 `Python 3.8`
|
||||||
|
- 如果设备上的 `nodejs` 版本过低,建议升级到 `Node.js 18 LTS` 后再安装前端依赖
|
||||||
|
|
||||||
|
### 4.2 安装浏览器
|
||||||
|
|
||||||
|
如果设备带图形桌面,建议至少安装一个浏览器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y chromium-browser
|
||||||
|
```
|
||||||
|
|
||||||
|
如果系统仓库没有 `chromium-browser`,也可以使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y firefox
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 拷贝项目代码
|
||||||
|
|
||||||
|
将工程目录拷贝到设备,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/emcp
|
||||||
|
sudo chown -R $USER:$USER /opt/emcp
|
||||||
|
```
|
||||||
|
|
||||||
|
然后将项目复制到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/emcp/backend
|
||||||
|
/opt/emcp/frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
如果是通过 `git` 拉取,也可以在 `/opt/emcp` 下执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <你的仓库地址> /opt/emcp
|
||||||
|
```
|
||||||
|
|
||||||
|
如果项目目录本身已经包含 `backend` 和 `frontend`,则以实际目录结构为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 后端部署
|
||||||
|
|
||||||
|
### 5.1 创建虚拟环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/emcp/backend
|
||||||
|
python3.8 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 安装后端依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/emcp/backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
如需测试或调试工具,再安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .[dev]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 手动启动后端验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/emcp/backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
验证方式:
|
||||||
|
|
||||||
|
- 本机健康检查:`http://127.0.0.1:8000/`
|
||||||
|
- 接口文档:`http://127.0.0.1:8000/docs`
|
||||||
|
|
||||||
|
如果能正常返回服务信息,说明后端部署成功。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 前端部署
|
||||||
|
|
||||||
|
### 6.1 安装前端依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/emcp/frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 构建前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/emcp/frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
构建完成后会生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/emcp/frontend/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 手动启动前端验证
|
||||||
|
|
||||||
|
推荐使用 Python 内置静态服务快速发布:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/emcp/frontend
|
||||||
|
python3 -m http.server 5173 --directory dist --bind 0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
验证地址:
|
||||||
|
|
||||||
|
- 前端页面:`http://127.0.0.1:5173/`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 当前前端默认调用本机后端 `http://127.0.0.1:8000/api`
|
||||||
|
- 因此前端页面只要在设备本机打开,即可直接访问后端服务
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 首次联调检查
|
||||||
|
|
||||||
|
建议按以下顺序进行:
|
||||||
|
|
||||||
|
1. 先启动后端
|
||||||
|
2. 再启动前端静态服务
|
||||||
|
3. 浏览器打开 `http://127.0.0.1:5173/`
|
||||||
|
4. 检查实时数据、设备状态、报警历史等页面是否能正常进入
|
||||||
|
|
||||||
|
当前项目已打通页面包括:
|
||||||
|
|
||||||
|
- 实时数据
|
||||||
|
- 设备状态
|
||||||
|
- 设备配置
|
||||||
|
- 通道配置
|
||||||
|
- 报警设置
|
||||||
|
- 报警历史
|
||||||
|
- 控制指令
|
||||||
|
- 系统设置
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- `设备状态` 页面支持刷新
|
||||||
|
- `报警历史` 页面支持分页和刷新
|
||||||
|
- 配置保存前会弹出安全校验密码输入框
|
||||||
|
- 当前默认安全校验密码为 `admin123`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 配置开机自动启动
|
||||||
|
|
||||||
|
推荐使用 `systemd` 管理前后端服务。
|
||||||
|
|
||||||
|
### 8.1 配置后端服务
|
||||||
|
|
||||||
|
创建文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/systemd/system/emcp-backend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
内容如下:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=EMCP Backend Service
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ubuntu
|
||||||
|
WorkingDirectory=/opt/emcp/backend
|
||||||
|
ExecStart=/opt/emcp/backend/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `User=ubuntu` 需要替换为设备实际登录用户名
|
||||||
|
- 如需切换真实采集设备,可结合 `.env` 或配置文件调整后端参数
|
||||||
|
|
||||||
|
### 8.2 配置前端服务
|
||||||
|
|
||||||
|
创建文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/systemd/system/emcp-frontend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
内容如下:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=EMCP Frontend Static Service
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ubuntu
|
||||||
|
WorkingDirectory=/opt/emcp/frontend
|
||||||
|
ExecStart=/usr/bin/python3 -m http.server 5173 --directory /opt/emcp/frontend/dist --bind 0.0.0.0
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 启用开机自启
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable emcp-backend.service
|
||||||
|
sudo systemctl enable emcp-frontend.service
|
||||||
|
sudo systemctl start emcp-backend.service
|
||||||
|
sudo systemctl start emcp-frontend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
查看状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status emcp-backend.service
|
||||||
|
sudo systemctl status emcp-frontend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u emcp-backend.service -f
|
||||||
|
journalctl -u emcp-frontend.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 开机自动打开浏览器访问主页面
|
||||||
|
|
||||||
|
本功能依赖图形桌面环境。若设备只启动命令行、不进入桌面,则无法自动弹出浏览器。
|
||||||
|
|
||||||
|
### 9.1 前置条件
|
||||||
|
|
||||||
|
建议提前确认以下两项:
|
||||||
|
|
||||||
|
- 设备已安装浏览器
|
||||||
|
- 设备已启用桌面自动登录,或现场人员上电后会手动登录桌面
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 只有在用户登录图形桌面后,自动启动项才会执行
|
||||||
|
- 如果希望“上电后无需人工登录就自动打开页面”,需要同时开启系统自动登录
|
||||||
|
|
||||||
|
### 9.2 创建浏览器启动脚本
|
||||||
|
|
||||||
|
创建目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/emcp/scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
创建脚本文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/emcp/scripts/start-emcp-browser.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
内容如下:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
sleep 8
|
||||||
|
|
||||||
|
URL="http://127.0.0.1:5173/"
|
||||||
|
|
||||||
|
if command -v chromium-browser >/dev/null 2>&1; then
|
||||||
|
chromium-browser --start-fullscreen "$URL" >/dev/null 2>&1 &
|
||||||
|
elif command -v firefox >/dev/null 2>&1; then
|
||||||
|
firefox "$URL" >/dev/null 2>&1 &
|
||||||
|
else
|
||||||
|
xdg-open "$URL" >/dev/null 2>&1 &
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
赋予执行权限:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x /opt/emcp/scripts/start-emcp-browser.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `sleep 8` 用于等待前后端服务启动完成
|
||||||
|
- 如果设备性能较低,可改为 `sleep 10` 或 `sleep 15`
|
||||||
|
- 如需全屏显示,优先使用 `chromium-browser --start-fullscreen`
|
||||||
|
|
||||||
|
### 9.3 配置桌面自动启动项
|
||||||
|
|
||||||
|
创建目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/autostart
|
||||||
|
```
|
||||||
|
|
||||||
|
创建文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.config/autostart/emcp-browser.desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
内容如下:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=EMCP Browser Autostart
|
||||||
|
Exec=/opt/emcp/scripts/start-emcp-browser.sh
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
Terminal=false
|
||||||
|
```
|
||||||
|
|
||||||
|
配置完成后,用户每次登录桌面时都会自动打开浏览器并访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:5173/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 快捷设置说明
|
||||||
|
|
||||||
|
这一节适合现场交付时快速操作。
|
||||||
|
|
||||||
|
### 10.1 一次性完成部署
|
||||||
|
|
||||||
|
按以下顺序操作即可:
|
||||||
|
|
||||||
|
1. 拷贝项目到 `/opt/emcp`
|
||||||
|
2. 创建后端虚拟环境并安装依赖
|
||||||
|
3. 前端执行 `npm install` 和 `npm run build`
|
||||||
|
4. 创建两个 `systemd` 服务
|
||||||
|
5. 创建浏览器自动启动脚本
|
||||||
|
6. 创建 `~/.config/autostart/emcp-browser.desktop`
|
||||||
|
7. 执行 `systemctl enable` 完成开机自启
|
||||||
|
|
||||||
|
### 10.2 最常用检查命令
|
||||||
|
|
||||||
|
检查后端状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status emcp-backend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
检查前端状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status emcp-frontend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
重启后端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart emcp-backend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
重启前端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart emcp-frontend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
重新打开浏览器主页:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/emcp/scripts/start-emcp-browser.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 修改主页地址
|
||||||
|
|
||||||
|
如果前端端口改为其他端口,例如 `8080`,需要同步修改以下两处:
|
||||||
|
|
||||||
|
- `/opt/emcp/scripts/start-emcp-browser.sh` 中的 `URL`
|
||||||
|
- 前端静态服务端口或访问地址
|
||||||
|
|
||||||
|
### 10.4 修改登录后自动打开行为
|
||||||
|
|
||||||
|
如果不希望每次登录都自动打开浏览器,可以删除或重命名:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.config/autostart/emcp-browser.desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 日常使用说明
|
||||||
|
|
||||||
|
### 11.1 启动后访问方式
|
||||||
|
|
||||||
|
当设备开机完成并进入桌面后,系统会自动:
|
||||||
|
|
||||||
|
- 启动后端服务
|
||||||
|
- 启动前端静态服务
|
||||||
|
- 打开浏览器访问主页
|
||||||
|
|
||||||
|
默认主页地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:5173/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 页面使用提示
|
||||||
|
|
||||||
|
- 实时数据页:查看模拟或采集到的实时数据
|
||||||
|
- 设备状态页:支持手动刷新状态
|
||||||
|
- 报警历史页:支持分页和刷新
|
||||||
|
- 配置类页面:保存前需要输入安全校验密码
|
||||||
|
|
||||||
|
当前默认安全校验密码:
|
||||||
|
|
||||||
|
```text
|
||||||
|
admin123
|
||||||
|
```
|
||||||
|
|
||||||
|
建议交付后尽快修改为现场正式密码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 常见问题
|
||||||
|
|
||||||
|
### 12.1 浏览器没有自动打开
|
||||||
|
|
||||||
|
请检查:
|
||||||
|
|
||||||
|
- 是否进入了图形桌面
|
||||||
|
- `~/.config/autostart/emcp-browser.desktop` 是否存在
|
||||||
|
- `/opt/emcp/scripts/start-emcp-browser.sh` 是否有执行权限
|
||||||
|
- 浏览器是否已安装
|
||||||
|
|
||||||
|
### 12.2 浏览器打开了,但页面访问失败
|
||||||
|
|
||||||
|
请检查:
|
||||||
|
|
||||||
|
- 前端服务是否启动成功
|
||||||
|
- `5173` 端口是否被其他程序占用
|
||||||
|
- `/opt/emcp/frontend/dist` 是否已经构建生成
|
||||||
|
|
||||||
|
### 12.3 页面能打开,但接口不通
|
||||||
|
|
||||||
|
请检查:
|
||||||
|
|
||||||
|
- 后端服务是否启动成功
|
||||||
|
- `8000` 端口是否正常监听
|
||||||
|
- 前端默认接口地址是否仍为 `http://127.0.0.1:8000/api`
|
||||||
|
|
||||||
|
### 12.4 页面保存时报权限或校验失败
|
||||||
|
|
||||||
|
请检查:
|
||||||
|
|
||||||
|
- 默认请求头 `X-API-Token` 是否被修改
|
||||||
|
- 输入的安全校验密码是否正确
|
||||||
|
- 当前默认密码是否仍为 `admin123`
|
||||||
|
|
||||||
|
### 12.5 修改代码后页面没有变化
|
||||||
|
|
||||||
|
请检查是否重新执行了前端构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/emcp/frontend
|
||||||
|
npm run build
|
||||||
|
sudo systemctl restart emcp-frontend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
后端代码修改后则需要重启后端服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart emcp-backend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 推荐交付方式
|
||||||
|
|
||||||
|
建议现场交付时采用以下方式:
|
||||||
|
|
||||||
|
- 设备开机自动进入桌面
|
||||||
|
- 后端和前端均使用 `systemd` 自启动
|
||||||
|
- 浏览器自动全屏打开主页
|
||||||
|
- 保留一个终端窗口或 SSH 方式用于维护
|
||||||
|
|
||||||
|
如需进一步增强生产部署能力,后续可继续补充:
|
||||||
|
|
||||||
|
- `Nginx` 托管前端静态资源
|
||||||
|
- 反向代理后端接口
|
||||||
|
- HTTPS 证书
|
||||||
|
- 真实硬件采集驱动接入
|
||||||
|
- 自动备份配置与报警数据库
|
||||||
BIN
document/在RK3568设备上系统部署及使用说明.pdf
Normal file
BIN
document/在RK3568设备上系统部署及使用说明.pdf
Normal file
Binary file not shown.
BIN
document/电气量测控平台-显示界面设计和数据接口说.docx
Normal file
BIN
document/电气量测控平台-显示界面设计和数据接口说.docx
Normal file
Binary file not shown.
BIN
document/电气量测控平台-显示界面设计和数据接口说.pdf
Normal file
BIN
document/电气量测控平台-显示界面设计和数据接口说.pdf
Normal file
Binary file not shown.
689
document/电气量测控平台系统需求分析及技术框架设计.md
Normal file
689
document/电气量测控平台系统需求分析及技术框架设计.md
Normal file
@ -0,0 +1,689 @@
|
|||||||
|
# 电气量测控平台系统需求分析与架构设计技术方案
|
||||||
|
**适配平台:RK3568 嵌入式平台(ubuntu 22.04系统)**
|
||||||
|
**技术栈:FastAPI + HTTP + WebSocket + Python + Web前端**
|
||||||
|
|
||||||
|
## 一、文档概述
|
||||||
|
### 1.1 文档目的
|
||||||
|
本文档基于《电气量测控平台显示界面设计和数据接口说明》、`gui.h`头文件及项目功能描述,明确**RK3568嵌入式平台**上Python编写、Web界面展示的电气量测控配置软件的功能、性能、接口、数据存储需求,完成系统架构设计,为软件开发、测试及交付提供技术依据。
|
||||||
|
|
||||||
|
### 1.2 适用范围
|
||||||
|
本文档适用于**RK3568嵌入式硬件**,以Python为开发语言、FastAPI为服务框架、Web为交互界面的电气量测控配置软件,涵盖参数配置、实时数据交互、状态监测、报警管理等全流程功能设计。
|
||||||
|
|
||||||
|
### 1.3 参考资料
|
||||||
|
1. 《电气量测控平台显示界面设计和数据接口说明》
|
||||||
|
2. `gui.h`嵌入式程序头文件
|
||||||
|
3. 项目功能需求描述
|
||||||
|
|
||||||
|
## 二、系统需求分析
|
||||||
|
### 2.1 运行环境需求
|
||||||
|
- **硬件环境**:RK3568嵌入式开发板(支持Linux系统、文件系统、SQLite数据库)
|
||||||
|
- **软件环境**:Linux操作系统、Python 3.8、**FastAPI**、Uvicorn ASGI服务、SQLite数据库、JSON文件存储支持
|
||||||
|
- **交互环境**:Web浏览器(支持WebSocket、动态数据渲染、按钮交互、表格/波形展示)
|
||||||
|
|
||||||
|
### 2.2 功能需求
|
||||||
|
#### 2.2.1 参数配置管理功能
|
||||||
|
实现测控设备全维度参数配置,配置项支持**JSON文件本地存储**,关键参数通过接口下发至C语言嵌入式程序,具体配置项如下:
|
||||||
|
1. **基础设备配置**
|
||||||
|
- 设备软件版本信息展示与配置
|
||||||
|
- 通讯参数设置:串口、网口参数配置
|
||||||
|
- 操作密码设置:支持密码修改、本地JSON文件存储
|
||||||
|
2. **装置通道配置**
|
||||||
|
- AI通道设置:模拟量输入配置、信号类型、所属线路、数据阈值
|
||||||
|
- AO通道设置:模拟量输出配置
|
||||||
|
- 定值设置:电压/电流/功率等超限限值、检测延时、报警输出配置
|
||||||
|
3. **系统管理配置**
|
||||||
|
- 对时设置:参数实时下发嵌入式程序
|
||||||
|
- 灯光设置:屏幕亮度、屏保时间,参数实时下发嵌入式程序
|
||||||
|
|
||||||
|
#### 2.2.2 实时数据动态显示功能
|
||||||
|
1. **数据读取周期**:间隔**0.5秒**从C语言控制系统读取实时数据
|
||||||
|
2. **数据类型**:模拟量、开关量、AI采集数据
|
||||||
|
3. **推送方式**:**WebSocket主动推送**至Web界面,动态刷新展示
|
||||||
|
4. **存储规则**:动态数据仅内存缓存,**不保存到本地**
|
||||||
|
|
||||||
|
#### 2.2.3 设备状态实时监测功能
|
||||||
|
从嵌入式系统动态读取设备状态,Web界面实时展示:
|
||||||
|
- 装置自检状态(正常/异常)
|
||||||
|
- 网络1状态、网络2状态
|
||||||
|
- 串口1状态、串口2状态
|
||||||
|
|
||||||
|
#### 2.2.4 报警事件管理功能
|
||||||
|
1. **报警数据采集**:实时获取嵌入式设备报警事件
|
||||||
|
2. **数据存储**:报警事件保存至**SQLite数据库**
|
||||||
|
3. **展示方式**:**WebSocket实时推送**报警至Web界面,支持弹窗提醒+历史查询
|
||||||
|
|
||||||
|
### 2.3 数据交互需求
|
||||||
|
#### 2.3.1 上行数据(嵌入式系统→Python服务)
|
||||||
|
- 实时数据:0.5秒/次,模拟量、开关量、AI采集数据
|
||||||
|
- 状态数据:装置自检、网络、串口状态
|
||||||
|
- 报警数据:实时报警事件数据
|
||||||
|
|
||||||
|
#### 2.3.2 下行数据(Python服务→嵌入式系统)
|
||||||
|
- 配置数据:设备参数、通道配置、定值、对时、灯光参数
|
||||||
|
- 控制指令:开关量分/合控制信号
|
||||||
|
|
||||||
|
### 2.4 数据存储需求
|
||||||
|
1. **JSON文件存储**:设备版本、通讯参数、密码、AI/AO配置、定值配置等**非实时参数**
|
||||||
|
2. **SQLite数据库**:报警事件数据(支持历史查询)
|
||||||
|
3. **动态数据**:仅内存缓存,不持久化存储
|
||||||
|
|
||||||
|
### 2.5 性能需求
|
||||||
|
1. **数据刷新率**:实时数据读取与WebSocket推送间隔≤0.5秒
|
||||||
|
2. **响应速度**:参数配置下发、开关控制指令响应时间≤1秒
|
||||||
|
3. **稳定性**:7×24小时连续运行
|
||||||
|
4. **兼容性**:适配RK3568硬件,兼容嵌入式C程序接口
|
||||||
|
|
||||||
|
### 2.6 通信服务需求
|
||||||
|
1. **HTTP服务**:基于FastAPI提供**RESTful API**,用于参数配置、状态查询、报警查询、控制指令
|
||||||
|
2. **WebSocket服务**:基于FastAPI提供双工通信,用于**实时数据、报警数据主动推送**至Web界面
|
||||||
|
|
||||||
|
## 三、系统架构设计
|
||||||
|
### 3.1 总体架构
|
||||||
|
系统采用**分层架构 + 前后端分离**设计,从下到上分为:
|
||||||
|
**硬件层 → 嵌入式C程序层 → Python FastAPI服务层 → Web界面层**
|
||||||
|
|
||||||
|
```
|
||||||
|
Web界面层(HTML/CSS/JS)
|
||||||
|
↓↑(HTTP + WebSocket)
|
||||||
|
Python FastAPI服务层(RESTful接口 + WebSocket推送 + 数据处理)
|
||||||
|
↓↑(内部进程/核间通讯)
|
||||||
|
嵌入式C程序层(数据采集 + 控制执行)
|
||||||
|
↓
|
||||||
|
硬件层(RK3568 + 测控硬件 + 通讯模块)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 分层详细设计
|
||||||
|
#### 3.2.1 硬件层
|
||||||
|
- 核心硬件:RK3568嵌入式开发板
|
||||||
|
- 外设模块:AI/AO采集模块、开关量模块、网络/串口模块、显示屏
|
||||||
|
|
||||||
|
#### 3.2.2 嵌入式C程序层
|
||||||
|
- 数据采集模块:20ms周期采集模拟量、开关量、AI数据
|
||||||
|
- 控制执行模块:接收参数与指令,执行配置、对时、灯光、开关控制
|
||||||
|
- 状态监测模块:自检、网口、串口状态实时上报
|
||||||
|
- 报警生成模块:异常事件生成并上传
|
||||||
|
- 通讯模块:与Python服务双向数据交互
|
||||||
|
|
||||||
|
#### 3.2.3 Python FastAPI服务层(核心)
|
||||||
|
采用**FastAPI + Uvicorn**搭建,提供**HTTP接口**和**WebSocket推送**双服务:
|
||||||
|
1. **RESTful API模块**:处理配置提交、状态查询、报警查询、控制指令
|
||||||
|
2. **WebSocket推送模块**:0.5秒推送实时数据、实时报警数据到Web端
|
||||||
|
3. **参数配置模块**:JSON文件读写、配置下发C程序
|
||||||
|
4. **实时数据模块**:定时读取C程序数据,缓存并通过WebSocket推送
|
||||||
|
5. **报警管理模块**:接收报警→存储SQLite→WebSocket推送前端
|
||||||
|
6. **设备状态模块**:采集并缓存设备运行状态
|
||||||
|
|
||||||
|
#### 3.2.4 Web界面层
|
||||||
|
- 菜单区:配置、状态、数据、报警、控制入口
|
||||||
|
- 状态区:自检、网络、串口实时状态
|
||||||
|
- 主显示区:实时数据表格、配置表单、控制按钮、报警列表
|
||||||
|
- 报警区:WebSocket实时弹窗+滚动显示
|
||||||
|
|
||||||
|
### 3.3 数据流向设计
|
||||||
|
1. **实时/报警数据流向**
|
||||||
|
嵌入式C程序 → Python → WebSocket主动推送 → Web界面(0.5秒刷新)
|
||||||
|
2. **参数配置流向**
|
||||||
|
Web界面 → HTTP POST → Python → JSON存储 + 下发C程序
|
||||||
|
3. **状态/查询流向**
|
||||||
|
Web界面 → HTTP GET → Python → 返回数据展示
|
||||||
|
|
||||||
|
## 四、接口详细设计(RESTful + WebSocket)
|
||||||
|
### 4.1 RESTful API 接口详情(完整参数+返回值)
|
||||||
|
所有接口统一返回格式:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200, // 200=成功,400=参数错误,500=服务异常
|
||||||
|
"msg": "操作成功",
|
||||||
|
"data": {} // 业务数据
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 1. GET /api/real-time-data
|
||||||
|
**作用**:获取最新一次实时数据(备用,主用WebSocket)
|
||||||
|
**参数**:无
|
||||||
|
**返回data**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "获取实时数据成功",
|
||||||
|
"data": {
|
||||||
|
"line_list": [
|
||||||
|
{
|
||||||
|
"line_no": 1,
|
||||||
|
"pri_val": {
|
||||||
|
"Ua": 6000.0,
|
||||||
|
"Ub": 6002.5,
|
||||||
|
"Uc": 5998.3,
|
||||||
|
"Ia": 150.2,
|
||||||
|
"Ib": 148.9,
|
||||||
|
"Ic": 151.6,
|
||||||
|
"Pa": 820.5,
|
||||||
|
"Pb": 815.3,
|
||||||
|
"Pc": 825.7,
|
||||||
|
"Pt": 2461.5,
|
||||||
|
"Qa": 120.3,
|
||||||
|
"Qb": 118.6,
|
||||||
|
"Qc": 122.9,
|
||||||
|
"Qt": 361.8,
|
||||||
|
"Sa": 835.2,
|
||||||
|
"Sb": 829.7,
|
||||||
|
"Sc": 840.3,
|
||||||
|
"St": 2505.2,
|
||||||
|
"PFa": 0.98,
|
||||||
|
"PFb": 0.97,
|
||||||
|
"PFc": 0.99,
|
||||||
|
"PFt": 0.98,
|
||||||
|
"Uab": 10400.1,
|
||||||
|
"Ubc": 10395.6,
|
||||||
|
"Uca": 10398.2,
|
||||||
|
"frq": 50.00
|
||||||
|
},
|
||||||
|
"sec_val": {
|
||||||
|
"Ua": 57.6,
|
||||||
|
"Ub": 57.7,
|
||||||
|
"Uc": 57.5,
|
||||||
|
"Ia": 1.2,
|
||||||
|
"Ib": 1.18,
|
||||||
|
"Ic": 1.22,
|
||||||
|
"Pa": 68.2,
|
||||||
|
"Pb": 67.8,
|
||||||
|
"Pc": 68.6,
|
||||||
|
"Pt": 204.6,
|
||||||
|
"Qa": 9.8,
|
||||||
|
"Qb": 9.6,
|
||||||
|
"Qc": 10.1,
|
||||||
|
"Qt": 29.5,
|
||||||
|
"Sa": 69.5,
|
||||||
|
"Sb": 68.9,
|
||||||
|
"Sc": 70.1,
|
||||||
|
"St": 208.5,
|
||||||
|
"PFa": 0.98,
|
||||||
|
"PFb": 0.97,
|
||||||
|
"PFc": 0.99,
|
||||||
|
"PFt": 0.98,
|
||||||
|
"Uab": 100.0,
|
||||||
|
"Ubc": 99.8,
|
||||||
|
"Uca": 99.9,
|
||||||
|
"frq": 50.00
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line_no": 2,
|
||||||
|
"pri_val": {},
|
||||||
|
"sec_val": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line_no": 3,
|
||||||
|
"pri_val": {},
|
||||||
|
"sec_val": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line_no": 4,
|
||||||
|
"pri_val": {},
|
||||||
|
"sec_val": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"switch": {
|
||||||
|
"di1": 1,
|
||||||
|
"di2": 0,
|
||||||
|
"di3": 1,
|
||||||
|
"di4": 0,
|
||||||
|
"di5": 1,
|
||||||
|
"di6": 0,
|
||||||
|
"di7": 1,
|
||||||
|
"di8": 0,
|
||||||
|
"di9": 1,
|
||||||
|
"di10": 0,
|
||||||
|
"di11": 1,
|
||||||
|
"di12": 0,
|
||||||
|
"do1": 1,
|
||||||
|
"do2": 0,
|
||||||
|
"do3": 1,
|
||||||
|
"do4": 0,
|
||||||
|
"do5": 1,
|
||||||
|
"do6": 0,
|
||||||
|
"do7": 1,
|
||||||
|
"do8": 0,
|
||||||
|
"do9": 1,
|
||||||
|
"do10": 0,
|
||||||
|
"do11": 1,
|
||||||
|
"do12": 0
|
||||||
|
},
|
||||||
|
"ai_collect": {
|
||||||
|
"ai1": 4.52,
|
||||||
|
"ai2": 3.86,
|
||||||
|
"ai3": 5.21,
|
||||||
|
"ai4": 2.99,
|
||||||
|
"ai5": 6.10,
|
||||||
|
"ai6": 1.75,
|
||||||
|
"ai7": 3.22,
|
||||||
|
"ai8": 4.91,
|
||||||
|
"ai9": 2.18,
|
||||||
|
"ai10": 5.47,
|
||||||
|
"ai11": 3.06,
|
||||||
|
"ai12": 4.33
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. GET /api/device-status
|
||||||
|
**作用**:获取设备当前运行状态
|
||||||
|
**参数**:无
|
||||||
|
**返回data**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"self_check": "正常",
|
||||||
|
"net1": "正常",
|
||||||
|
"net2": "正常",
|
||||||
|
"uart1": "正常",
|
||||||
|
"uart2": "断开"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. POST /api/config/device
|
||||||
|
**作用**:提交设备基础配置(版本、通讯、密码)
|
||||||
|
**请求body参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"password": "123456",
|
||||||
|
"hardware_version": {
|
||||||
|
"board_version": "B001.001.001",
|
||||||
|
"display_version": "S001.001.001",
|
||||||
|
"other_version": "Y001.001.001"
|
||||||
|
},
|
||||||
|
"software_version": {
|
||||||
|
"display_program": "001.001.001",
|
||||||
|
"communication_program": "001.001.001",
|
||||||
|
"measurement_program": "001.001.001"
|
||||||
|
},
|
||||||
|
"net": [
|
||||||
|
{
|
||||||
|
"nic": "网卡一",
|
||||||
|
"ip": "",
|
||||||
|
"mask": "",
|
||||||
|
"gateway": "",
|
||||||
|
"protocol": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nic": "网卡二",
|
||||||
|
"ip": "192.168.1.56",
|
||||||
|
"mask": "255.255.255.255",
|
||||||
|
"gateway": "192.168.1.56",
|
||||||
|
"protocol": "Modbus TCP"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"uart": [
|
||||||
|
{
|
||||||
|
"port": "COM1",
|
||||||
|
"baud": 0,
|
||||||
|
"parity": "",
|
||||||
|
"data_bits": 0,
|
||||||
|
"stop_bits": 0,
|
||||||
|
"protocol": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": "COM2",
|
||||||
|
"baud": 115200,
|
||||||
|
"parity": "NONE",
|
||||||
|
"data_bits": 8,
|
||||||
|
"stop_bits": 1,
|
||||||
|
"protocol": "Modbus RTU"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**返回data**:`{"save_path":"/config/device.json","send_status":"成功"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. POST /api/config/channel
|
||||||
|
**作用**:提交AI/AO通道配置(AI:12通道,AO:12通道)
|
||||||
|
**请求body参数**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ai_channel": [{"ch":1,"singal_type":"4-20mA","line_no":1,"type":"UA","limit_low":0,"limit_high":20}],
|
||||||
|
"ao_channel": [{"ch":1,"singal_type":"1-5v","line_no":2,"type":"UA","limit_low":0,"limit_high":20}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**返回data**:`{"save_path":"/config/channel.json","send_status":"成功"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. POST /api/config/line_alarm_setting
|
||||||
|
**作用**:提交定值报警阈值配置
|
||||||
|
**请求body参数**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"line_no": 1,
|
||||||
|
"over_limit_alarm": [
|
||||||
|
{
|
||||||
|
"category": "电压",
|
||||||
|
"limit": 180,
|
||||||
|
"delay": 180,
|
||||||
|
"output_node": "开出1",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "电流",
|
||||||
|
"limit": 180,
|
||||||
|
"delay": 180,
|
||||||
|
"output_node": "开出1",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "差流",
|
||||||
|
"limit": 180,
|
||||||
|
"delay": 180,
|
||||||
|
"output_node": "开出1",
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "功率",
|
||||||
|
"limit": 180,
|
||||||
|
"delay": 180,
|
||||||
|
"output_node": "开出1",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "频率",
|
||||||
|
"limit": 180,
|
||||||
|
"delay": 180,
|
||||||
|
"output_node": "开出1",
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fault_alarm": [
|
||||||
|
{
|
||||||
|
"category": "PT断线",
|
||||||
|
"delay": 180,
|
||||||
|
"output_node": "开出1",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "CT断线",
|
||||||
|
"delay": 180,
|
||||||
|
"output_node": "开出1",
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**返回data**:`{"save_path":"/config/setting.json","send_status":"成功"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. POST /api/config/ai_alarm_setting
|
||||||
|
|
||||||
|
**作用**:提交AI报警设置
|
||||||
|
**请求body参数**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"channel_no": 1,
|
||||||
|
"singal_type": "4-20mA",
|
||||||
|
"limit_low":0,
|
||||||
|
"limit_high":20,
|
||||||
|
"delay": 180,
|
||||||
|
"output_node": "开出1",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"channel_no": 2,
|
||||||
|
"singal_type": "1-5v",
|
||||||
|
"limit_low":0,
|
||||||
|
"limit_high":20,
|
||||||
|
"delay": 180,
|
||||||
|
"output_node": "开出1",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回data**:`{"save_path":"/config/setting.json","send_status":"成功"}`
|
||||||
|
|
||||||
|
#### 6. POST /api/config/system
|
||||||
|
|
||||||
|
**作用**:提交系统对时、灯光配置
|
||||||
|
**请求body参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"time_sync": "auto",
|
||||||
|
"brightness": 80,
|
||||||
|
"screen_saver": 60
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**返回data**:`{"send_status":"成功"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 7. GET /api/alarm/list
|
||||||
|
**作用**:分页查询历史报警
|
||||||
|
**参数**:`page=1&size=20`
|
||||||
|
**返回data**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"alarm_type": "operate_alarm",
|
||||||
|
"time": "2025-01-01 12:00:00",
|
||||||
|
"no": "",
|
||||||
|
"type": "",
|
||||||
|
"content": "告警内容",
|
||||||
|
"level": "告警等级"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 8. POST /api/control/switch
|
||||||
|
**作用**:下发开关量控制指令
|
||||||
|
**请求body参数**:
|
||||||
|
```json
|
||||||
|
{"ch":1,"action":1} // 1=合,0=分
|
||||||
|
```
|
||||||
|
**返回data**:`{"control_status":"执行成功"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 WebSocket 接口(主动推送)
|
||||||
|
#### 1. WebSocket 地址:`ws://ip:port/ws/real-time`
|
||||||
|
**推送内容**:实时数据(0.5秒/次)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "real_time",
|
||||||
|
"data": {
|
||||||
|
"line_list": [
|
||||||
|
{
|
||||||
|
"line_no": 1,
|
||||||
|
"pri_val": {
|
||||||
|
"Ua": 6000.0,
|
||||||
|
"Ub": 6002.5,
|
||||||
|
"Uc": 5998.3,
|
||||||
|
"Ia": 150.2,
|
||||||
|
"Ib": 148.9,
|
||||||
|
"Ic": 151.6,
|
||||||
|
"Pa": 820.5,
|
||||||
|
"Pb": 815.3,
|
||||||
|
"Pc": 825.7,
|
||||||
|
"Pt": 2461.5,
|
||||||
|
"Qa": 120.3,
|
||||||
|
"Qb": 118.6,
|
||||||
|
"Qc": 122.9,
|
||||||
|
"Qt": 361.8,
|
||||||
|
"Sa": 835.2,
|
||||||
|
"Sb": 829.7,
|
||||||
|
"Sc": 840.3,
|
||||||
|
"St": 2505.2,
|
||||||
|
"PFa": 0.98,
|
||||||
|
"PFb": 0.97,
|
||||||
|
"PFc": 0.99,
|
||||||
|
"PFt": 0.98,
|
||||||
|
"Uab": 10400.1,
|
||||||
|
"Ubc": 10395.6,
|
||||||
|
"Uca": 10398.2,
|
||||||
|
"frq": 50.00
|
||||||
|
},
|
||||||
|
"sec_val": {
|
||||||
|
"Ua": 57.6,
|
||||||
|
"Ub": 57.7,
|
||||||
|
"Uc": 57.5,
|
||||||
|
"Ia": 1.2,
|
||||||
|
"Ib": 1.18,
|
||||||
|
"Ic": 1.22,
|
||||||
|
"Pa": 68.2,
|
||||||
|
"Pb": 67.8,
|
||||||
|
"Pc": 68.6,
|
||||||
|
"Pt": 204.6,
|
||||||
|
"Qa": 9.8,
|
||||||
|
"Qb": 9.6,
|
||||||
|
"Qc": 10.1,
|
||||||
|
"Qt": 29.5,
|
||||||
|
"Sa": 69.5,
|
||||||
|
"Sb": 68.9,
|
||||||
|
"Sc": 70.1,
|
||||||
|
"St": 208.5,
|
||||||
|
"PFa": 0.98,
|
||||||
|
"PFb": 0.97,
|
||||||
|
"PFc": 0.99,
|
||||||
|
"PFt": 0.98,
|
||||||
|
"Uab": 100.0,
|
||||||
|
"Ubc": 99.8,
|
||||||
|
"Uca": 99.9,
|
||||||
|
"frq": 50.00
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line_no": 2,
|
||||||
|
"pri_val": {},
|
||||||
|
"sec_val": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line_no": 3,
|
||||||
|
"pri_val": {},
|
||||||
|
"sec_val": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line_no": 4,
|
||||||
|
"pri_val": {},
|
||||||
|
"sec_val": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"switch": {
|
||||||
|
"di1": 1,
|
||||||
|
"di2": 0,
|
||||||
|
"di3": 1,
|
||||||
|
"di4": 0,
|
||||||
|
"di5": 1,
|
||||||
|
"di6": 0,
|
||||||
|
"di7": 1,
|
||||||
|
"di8": 0,
|
||||||
|
"di9": 1,
|
||||||
|
"di10": 0,
|
||||||
|
"di11": 1,
|
||||||
|
"di12": 0,
|
||||||
|
"do1": 1,
|
||||||
|
"do2": 0,
|
||||||
|
"do3": 1,
|
||||||
|
"do4": 0,
|
||||||
|
"do5": 1,
|
||||||
|
"do6": 0,
|
||||||
|
"do7": 1,
|
||||||
|
"do8": 0,
|
||||||
|
"do9": 1,
|
||||||
|
"do10": 0,
|
||||||
|
"do11": 1,
|
||||||
|
"do12": 0
|
||||||
|
},
|
||||||
|
"ai_collect": {
|
||||||
|
"ai1": 4.52,
|
||||||
|
"ai2": 3.86,
|
||||||
|
"ai3": 5.21,
|
||||||
|
"ai4": 2.99,
|
||||||
|
"ai5": 6.10,
|
||||||
|
"ai6": 1.75,
|
||||||
|
"ai7": 3.22,
|
||||||
|
"ai8": 4.91,
|
||||||
|
"ai9": 2.18,
|
||||||
|
"ai10": 5.47,
|
||||||
|
"ai11": 3.06,
|
||||||
|
"ai12": 4.33
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. WebSocket 地址:`ws://ip:port/ws/alarm`
|
||||||
|
**推送内容**:实时报警事件(触发即推)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"alarm_type": "line_alarm",
|
||||||
|
"time": "2025-01-01 12:00:00",
|
||||||
|
"no": "1",
|
||||||
|
"type": "",
|
||||||
|
"content": "告警内容",
|
||||||
|
"level": "告警等级"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alarm_type": "ai_alarm",
|
||||||
|
"time": "2025-01-01 12:00:00",
|
||||||
|
"no": "2",
|
||||||
|
"type": "",
|
||||||
|
"content": "告警内容",
|
||||||
|
"level": "告警等级"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alarm_type": "operate_alarm",
|
||||||
|
"time": "2025-01-01 12:00:00",
|
||||||
|
"no": "3",
|
||||||
|
"type": "",
|
||||||
|
"content": "告警内容",
|
||||||
|
"level": "告警等级"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、数据存储设计
|
||||||
|
### 5.1 JSON配置文件
|
||||||
|
- 路径:`/config/`
|
||||||
|
- 文件:`device.json`、`channel.json`、`setting.json`
|
||||||
|
|
||||||
|
### 5.2 SQLite报警数据库
|
||||||
|
表:`alarm_event`
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | INTEGER | 主键 |
|
||||||
|
| alarm_type | TEXT | 告警类型(line_alarm,ai_alarm,operate_alarm) |
|
||||||
|
| time | DATETIME | 事件发生时间 |
|
||||||
|
| no | TEXT | 线路号/通道号 |
|
||||||
|
| type | TEXT | 类型 |
|
||||||
|
| content | TEXT | 告警详情 |
|
||||||
|
| level | TEXT | 告警等级 |
|
||||||
|
|
||||||
|
## 六、安全与可靠性设计
|
||||||
|
1. **密码加密存储**,接口权限校验
|
||||||
|
2. WebSocket断线自动重连
|
||||||
|
3. 配置文件修改自动备份
|
||||||
|
4. 服务异常自动重启
|
||||||
|
|
||||||
|
## 七、总结
|
||||||
|
本方案基于**RK3568平台**,采用**FastAPI + HTTP + WebSocket**架构,满足电气量测控平台全部需求:
|
||||||
|
- RESTful API 负责配置、查询、控制
|
||||||
|
- WebSocket 负责实时数据、报警数据主动推送
|
||||||
|
- JSON 存储配置,SQLite 存储报警
|
||||||
|
- 0.5秒实时刷新,界面无卡顿、数据不丢失
|
||||||
|
|
||||||
|
系统架构轻量化、高性能,完全适配嵌入式平台运行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
BIN
document/电气量测控平台系统需求分析及技术框架设计.pdf
Normal file
BIN
document/电气量测控平台系统需求分析及技术框架设计.pdf
Normal file
Binary file not shown.
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>电气量测控平台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1832
frontend/package-lock.json
generated
Normal file
1832
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "emcp-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.5",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
54
frontend/src/App.vue
Normal file
54
frontend/src/App.vue
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { usePlatformStore } from './stores/platform'
|
||||||
|
import RealtimeView from './views/RealtimeView.vue'
|
||||||
|
import StatusView from './views/StatusView.vue'
|
||||||
|
import DeviceConfigView from './views/DeviceConfigView.vue'
|
||||||
|
import ChannelConfigView from './views/ChannelConfigView.vue'
|
||||||
|
import AlarmSettingView from './views/AlarmSettingView.vue'
|
||||||
|
import AlarmHistoryView from './views/AlarmHistoryView.vue'
|
||||||
|
import ControlView from './views/ControlView.vue'
|
||||||
|
import SystemConfigView from './views/SystemConfigView.vue'
|
||||||
|
|
||||||
|
const store = usePlatformStore()
|
||||||
|
const activeTab = ref('realtime')
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'realtime', label: '实时数据', component: RealtimeView },
|
||||||
|
{ key: 'status', label: '设备状态', component: StatusView },
|
||||||
|
{ key: 'device', label: '设备配置', component: DeviceConfigView },
|
||||||
|
{ key: 'channel', label: '通道配置', component: ChannelConfigView },
|
||||||
|
{ key: 'alarm-setting', label: '报警设置', component: AlarmSettingView },
|
||||||
|
{ key: 'alarms', label: '报警历史', component: AlarmHistoryView },
|
||||||
|
{ key: 'control', label: '控制指令', component: ControlView },
|
||||||
|
{ key: 'system', label: '系统设置', component: SystemConfigView },
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentComponent = computed(() => tabs.find((item) => item.key === activeTab.value)?.component ?? RealtimeView)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void store.bootstrap()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="app-shell">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>电气量测控平台</h1>
|
||||||
|
<p>RK3568 + FastAPI + WebSocket + Vue 的前后端开发框架骨架。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
:class="{ active: tab.key === activeTab }"
|
||||||
|
@click="activeTab = tab.key"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<component :is="currentComponent" :store="store.state" :actions="store" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
10
frontend/src/api/http.ts
Normal file
10
frontend/src/api/http.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const http = axios.create({
|
||||||
|
baseURL: 'http://127.0.0.1:8000/api',
|
||||||
|
timeout: 5000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Token': 'admin123',
|
||||||
|
},
|
||||||
|
})
|
||||||
71
frontend/src/api/platform.ts
Normal file
71
frontend/src/api/platform.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import type {
|
||||||
|
AiAlarmSettingItem,
|
||||||
|
AlarmEvent,
|
||||||
|
ApiResponse,
|
||||||
|
ChannelConfigPayload,
|
||||||
|
DeviceConfigPayload,
|
||||||
|
DeviceStatus,
|
||||||
|
LineAlarmSettingPayload,
|
||||||
|
RealtimeData,
|
||||||
|
SystemConfigPayload,
|
||||||
|
} from '../types/platform'
|
||||||
|
|
||||||
|
export async function fetchRealtimeData(): Promise<RealtimeData> {
|
||||||
|
const response = await http.get<ApiResponse<RealtimeData>>('/real-time-data')
|
||||||
|
return response.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDeviceStatus(): Promise<DeviceStatus> {
|
||||||
|
const response = await http.get<ApiResponse<DeviceStatus>>('/device-status')
|
||||||
|
return response.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAlarmHistory(page = 1, size = 20): Promise<AlarmEvent[]> {
|
||||||
|
const response = await http.get<ApiResponse<AlarmEvent[]>>(`/alarm/list?page=${page}&size=${size}`)
|
||||||
|
return response.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAccessPassword(password: string): Promise<boolean> {
|
||||||
|
const response = await http.post<ApiResponse<boolean>>('/auth/verify-password', { password })
|
||||||
|
return response.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDeviceConfig(payload: DeviceConfigPayload): Promise<ApiResponse<{ save_path: string; send_status: string }>> {
|
||||||
|
const response = await http.post<ApiResponse<{ save_path: string; send_status: string }>>('/config/device', payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveChannelConfig(
|
||||||
|
payload: ChannelConfigPayload,
|
||||||
|
): Promise<ApiResponse<{ save_path: string; send_status: string }>> {
|
||||||
|
const response = await http.post<ApiResponse<{ save_path: string; send_status: string }>>('/config/channel', payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLineAlarmSetting(
|
||||||
|
payload: LineAlarmSettingPayload,
|
||||||
|
): Promise<ApiResponse<{ save_path: string; send_status: string }>> {
|
||||||
|
const response = await http.post<ApiResponse<{ save_path: string; send_status: string }>>('/config/line_alarm_setting', payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAiAlarmSetting(
|
||||||
|
payload: AiAlarmSettingItem[],
|
||||||
|
): Promise<ApiResponse<{ save_path: string; send_status: string; target: string; items: number }>> {
|
||||||
|
const response = await http.post<ApiResponse<{ save_path: string; send_status: string; target: string; items: number }>>(
|
||||||
|
'/config/ai_alarm_setting',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function controlSwitch(ch: number, action: number): Promise<ApiResponse<{ control_status: string }>> {
|
||||||
|
const response = await http.post<ApiResponse<{ control_status: string }>>('/control/switch', { ch, action })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSystemConfig(payload: SystemConfigPayload): Promise<ApiResponse<{ send_status: string }>> {
|
||||||
|
const response = await http.post<ApiResponse<{ send_status: string }>>('/config/system', payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
5
frontend/src/main.ts
Normal file
5
frontend/src/main.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
142
frontend/src/stores/platform.ts
Normal file
142
frontend/src/stores/platform.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
import { fetchAlarmHistory, fetchDeviceStatus, fetchRealtimeData } from '../api/platform'
|
||||||
|
import { connectAlarm, connectRealtimeWithLifecycle } from '../websocket/client'
|
||||||
|
import type { AlarmEvent, DeviceStatus, RealtimeData } from '../types/platform'
|
||||||
|
|
||||||
|
interface PlatformState {
|
||||||
|
realtime: RealtimeData | null
|
||||||
|
status: DeviceStatus | null
|
||||||
|
alarms: AlarmEvent[]
|
||||||
|
alarmPage: number
|
||||||
|
alarmPageSize: number
|
||||||
|
alarmHasNext: boolean
|
||||||
|
alarmLoading: boolean
|
||||||
|
realtimeConnected: boolean
|
||||||
|
realtimeSource: string
|
||||||
|
realtimeUpdatedAt: string
|
||||||
|
statusLoading: boolean
|
||||||
|
statusUpdatedAt: string
|
||||||
|
alarmUpdatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = reactive<PlatformState>({
|
||||||
|
realtime: null,
|
||||||
|
status: null,
|
||||||
|
alarms: [],
|
||||||
|
alarmPage: 1,
|
||||||
|
alarmPageSize: 20,
|
||||||
|
alarmHasNext: false,
|
||||||
|
alarmLoading: false,
|
||||||
|
realtimeConnected: false,
|
||||||
|
realtimeSource: 'init',
|
||||||
|
realtimeUpdatedAt: '',
|
||||||
|
statusLoading: false,
|
||||||
|
statusUpdatedAt: '',
|
||||||
|
alarmUpdatedAt: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
let realtimeSocket: WebSocket | null = null
|
||||||
|
let alarmSocket: WebSocket | null = null
|
||||||
|
let realtimePollTimer: number | null = null
|
||||||
|
|
||||||
|
function markRealtime(data: RealtimeData, source: string) {
|
||||||
|
state.realtime = data
|
||||||
|
state.realtimeSource = source
|
||||||
|
state.realtimeUpdatedAt = new Date().toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRealtimeByHttp(source = 'http-poll') {
|
||||||
|
const realtime = await fetchRealtimeData()
|
||||||
|
markRealtime(realtime, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStatus() {
|
||||||
|
state.statusLoading = true
|
||||||
|
try {
|
||||||
|
state.status = await fetchDeviceStatus()
|
||||||
|
state.statusUpdatedAt = new Date().toLocaleTimeString()
|
||||||
|
} finally {
|
||||||
|
state.statusLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAlarms(page = state.alarmPage) {
|
||||||
|
state.alarmLoading = true
|
||||||
|
try {
|
||||||
|
const alarms = await fetchAlarmHistory(page, state.alarmPageSize)
|
||||||
|
state.alarms = alarms
|
||||||
|
state.alarmPage = page
|
||||||
|
state.alarmHasNext = alarms.length === state.alarmPageSize
|
||||||
|
state.alarmUpdatedAt = new Date().toLocaleTimeString()
|
||||||
|
} finally {
|
||||||
|
state.alarmLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextAlarmPage() {
|
||||||
|
if (!state.alarmHasNext) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await refreshAlarms(state.alarmPage + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prevAlarmPage() {
|
||||||
|
if (state.alarmPage <= 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await refreshAlarms(state.alarmPage - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
await syncRealtimeByHttp('http-init')
|
||||||
|
await refreshStatus()
|
||||||
|
await refreshAlarms(1)
|
||||||
|
|
||||||
|
if (!realtimeSocket) {
|
||||||
|
realtimeSocket = connectRealtimeWithLifecycle(
|
||||||
|
(data) => {
|
||||||
|
state.realtimeConnected = true
|
||||||
|
markRealtime(data, 'websocket')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onOpen: () => {
|
||||||
|
state.realtimeConnected = true
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
state.realtimeConnected = false
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
state.realtimeConnected = false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!alarmSocket) {
|
||||||
|
alarmSocket = connectAlarm((alarm) => {
|
||||||
|
if (state.alarmPage === 1) {
|
||||||
|
state.alarms = [alarm, ...state.alarms].slice(0, state.alarmPageSize)
|
||||||
|
state.alarmUpdatedAt = new Date().toLocaleTimeString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!realtimePollTimer) {
|
||||||
|
realtimePollTimer = window.setInterval(() => {
|
||||||
|
if (!state.realtimeConnected) {
|
||||||
|
void syncRealtimeByHttp('http-fallback')
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlatformStore() {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
bootstrap,
|
||||||
|
refreshStatus,
|
||||||
|
refreshAlarms,
|
||||||
|
nextAlarmPage,
|
||||||
|
prevAlarmPage,
|
||||||
|
}
|
||||||
|
}
|
||||||
173
frontend/src/style.css
Normal file
173
frontend/src/style.css
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #dbe3ef;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric .label {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric .value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warn {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid input,
|
||||||
|
.form-grid select {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
padding: 8px 4px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table input,
|
||||||
|
.table select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
border: none;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
127
frontend/src/types/platform.ts
Normal file
127
frontend/src/types/platform.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
export interface ValueGroup {
|
||||||
|
Ua: number
|
||||||
|
Ub: number
|
||||||
|
Uc: number
|
||||||
|
Ia: number
|
||||||
|
Ib: number
|
||||||
|
Ic: number
|
||||||
|
Pt: number
|
||||||
|
frq: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineData {
|
||||||
|
line_no: number
|
||||||
|
pri_val: ValueGroup
|
||||||
|
sec_val: ValueGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RealtimeData {
|
||||||
|
line_list: LineData[]
|
||||||
|
switch: Record<string, number>
|
||||||
|
ai_collect: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceStatus {
|
||||||
|
self_check: string
|
||||||
|
net1: string
|
||||||
|
net2: string
|
||||||
|
uart1: string
|
||||||
|
uart2: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlarmEvent {
|
||||||
|
id?: number
|
||||||
|
alarm_type: string
|
||||||
|
time: string
|
||||||
|
no: string
|
||||||
|
type: string
|
||||||
|
content: string
|
||||||
|
level: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardwareVersion {
|
||||||
|
board_version: string
|
||||||
|
display_version: string
|
||||||
|
other_version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoftwareVersion {
|
||||||
|
display_program: string
|
||||||
|
communication_program: string
|
||||||
|
measurement_program: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetConfigItem {
|
||||||
|
nic: string
|
||||||
|
ip: string
|
||||||
|
mask: string
|
||||||
|
gateway: string
|
||||||
|
protocol: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UartConfigItem {
|
||||||
|
port: string
|
||||||
|
baud: number
|
||||||
|
parity: string
|
||||||
|
data_bits: number
|
||||||
|
stop_bits: number
|
||||||
|
protocol: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceConfigPayload {
|
||||||
|
password: string
|
||||||
|
hardware_version: HardwareVersion
|
||||||
|
software_version: SoftwareVersion
|
||||||
|
net: NetConfigItem[]
|
||||||
|
uart: UartConfigItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelItem {
|
||||||
|
ch: number
|
||||||
|
singal_type: string
|
||||||
|
line_no: number
|
||||||
|
type: string
|
||||||
|
limit_low: number
|
||||||
|
limit_high: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelConfigPayload {
|
||||||
|
ai_channel: ChannelItem[]
|
||||||
|
ao_channel: ChannelItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlarmRule {
|
||||||
|
category: string
|
||||||
|
limit?: number
|
||||||
|
delay: number
|
||||||
|
output_node: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineAlarmSettingPayload {
|
||||||
|
line_no: number
|
||||||
|
over_limit_alarm: AlarmRule[]
|
||||||
|
fault_alarm: AlarmRule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiAlarmSettingItem {
|
||||||
|
channel_no: number
|
||||||
|
singal_type: string
|
||||||
|
limit_low: number
|
||||||
|
limit_high: number
|
||||||
|
delay: number
|
||||||
|
output_node: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemConfigPayload {
|
||||||
|
time_sync: string
|
||||||
|
brightness: number
|
||||||
|
screen_saver: number
|
||||||
|
}
|
||||||
20
frontend/src/utils/saveGuard.ts
Normal file
20
frontend/src/utils/saveGuard.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { verifyAccessPassword } from '../api/platform'
|
||||||
|
|
||||||
|
export async function ensureSaveAuthorized(): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const password = window.prompt('请输入安全校验密码')
|
||||||
|
|
||||||
|
if (password === null) {
|
||||||
|
return { ok: false, message: '已取消安全校验' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password.trim()) {
|
||||||
|
return { ok: false, message: '安全校验密码不能为空' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = await verifyAccessPassword(password)
|
||||||
|
if (!verified) {
|
||||||
|
return { ok: false, message: '安全校验失败,密码错误' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, message: '安全校验通过' }
|
||||||
|
}
|
||||||
33
frontend/src/views/AlarmHistoryView.vue
Normal file
33
frontend/src/views/AlarmHistoryView.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ store: any; actions: any }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>报警历史</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" :disabled="store.alarmLoading" @click="actions.refreshAlarms()">
|
||||||
|
{{ store.alarmLoading ? '刷新中...' : '刷新列表' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>类型</th><th>时间</th><th>编号</th><th>内容</th><th>级别</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="alarm in store.alarms" :key="`${alarm.time}-${alarm.no}-${alarm.content}`">
|
||||||
|
<td>{{ alarm.alarm_type }}</td>
|
||||||
|
<td>{{ alarm.time }}</td>
|
||||||
|
<td>{{ alarm.no }}</td>
|
||||||
|
<td>{{ alarm.content }}</td>
|
||||||
|
<td>{{ alarm.level }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" :disabled="store.alarmLoading || store.alarmPage <= 1" @click="actions.prevAlarmPage()">上一页</button>
|
||||||
|
<button class="primary" :disabled="store.alarmLoading || !store.alarmHasNext" @click="actions.nextAlarmPage()">下一页</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">当前页:{{ store.alarmPage }},最后刷新:{{ store.alarmUpdatedAt || '--' }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
76
frontend/src/views/AlarmSettingView.vue
Normal file
76
frontend/src/views/AlarmSettingView.vue
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { saveAiAlarmSetting, saveLineAlarmSetting } from '../api/platform'
|
||||||
|
import type { AiAlarmSettingItem, LineAlarmSettingPayload } from '../types/platform'
|
||||||
|
import { ensureSaveAuthorized } from '../utils/saveGuard'
|
||||||
|
|
||||||
|
defineProps<{ store: any; actions: any }>()
|
||||||
|
|
||||||
|
const lineAlarm = reactive<LineAlarmSettingPayload>({
|
||||||
|
line_no: 1,
|
||||||
|
over_limit_alarm: [
|
||||||
|
{ category: '电压', limit: 180, delay: 180, output_node: '开出1', enabled: true },
|
||||||
|
{ category: '电流', limit: 180, delay: 180, output_node: '开出1', enabled: true },
|
||||||
|
{ category: '差流', limit: 180, delay: 180, output_node: '开出1', enabled: false },
|
||||||
|
{ category: '频率', limit: 180, delay: 180, output_node: '开出1', enabled: false },
|
||||||
|
],
|
||||||
|
fault_alarm: [{ category: 'PT断线', delay: 180, output_node: '开出1', enabled: true }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const aiAlarm = reactive<AiAlarmSettingItem[]>([
|
||||||
|
{
|
||||||
|
channel_no: 1,
|
||||||
|
singal_type: '4-20mA',
|
||||||
|
limit_low: 0,
|
||||||
|
limit_high: 20,
|
||||||
|
delay: 180,
|
||||||
|
output_node: '开出1',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = ref('未保存')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const guard = await ensureSaveAuthorized()
|
||||||
|
if (!guard.ok) {
|
||||||
|
result.value = guard.message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [lineResponse, aiResponse] = await Promise.all([saveLineAlarmSetting(lineAlarm), saveAiAlarmSetting(aiAlarm)])
|
||||||
|
result.value = `${lineResponse.msg} / ${aiResponse.msg}`
|
||||||
|
} catch (error) {
|
||||||
|
result.value = error instanceof Error ? error.message : '保存报警设置失败'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>报警设置</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>线路号<input v-model.number="lineAlarm.line_no" type="number" min="1" max="4" /></label>
|
||||||
|
<label>电压越限<input v-model.number="lineAlarm.over_limit_alarm[0].limit" type="number" /></label>
|
||||||
|
<label>电流越限<input v-model.number="lineAlarm.over_limit_alarm[1].limit" type="number" /></label>
|
||||||
|
<label>差流越限<input v-model.number="lineAlarm.over_limit_alarm[2].limit" type="number" /></label>
|
||||||
|
<label>频率越限<input v-model.number="lineAlarm.over_limit_alarm[3].limit" type="number" /></label>
|
||||||
|
<label>动作延时<input v-model.number="lineAlarm.over_limit_alarm[0].delay" type="number" /></label>
|
||||||
|
<label>输出节点<select v-model="lineAlarm.over_limit_alarm[0].output_node"><option>开出1</option><option>开出2</option></select></label>
|
||||||
|
<label>PT断线延时<input v-model.number="lineAlarm.fault_alarm[0].delay" type="number" /></label>
|
||||||
|
<label>AI通道号<input v-model.number="aiAlarm[0].channel_no" type="number" min="1" max="12" /></label>
|
||||||
|
<label>AI信号类型<input v-model="aiAlarm[0].singal_type" /></label>
|
||||||
|
<label>AI下限<input v-model.number="aiAlarm[0].limit_low" type="number" /></label>
|
||||||
|
<label>AI上限<input v-model.number="aiAlarm[0].limit_high" type="number" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存报警设置' }}</button>
|
||||||
|
</div>
|
||||||
|
<p>保存结果:{{ result }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
67
frontend/src/views/ChannelConfigView.vue
Normal file
67
frontend/src/views/ChannelConfigView.vue
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { saveChannelConfig } from '../api/platform'
|
||||||
|
import type { ChannelConfigPayload } from '../types/platform'
|
||||||
|
import { ensureSaveAuthorized } from '../utils/saveGuard'
|
||||||
|
|
||||||
|
defineProps<{ store: any; actions: any }>()
|
||||||
|
|
||||||
|
const form = reactive<ChannelConfigPayload>({
|
||||||
|
ai_channel: [{ ch: 1, singal_type: '4-20mA', line_no: 1, type: 'UA', limit_low: 0, limit_high: 20 }],
|
||||||
|
ao_channel: [{ ch: 1, singal_type: '1-5v', line_no: 2, type: 'UA', limit_low: 0, limit_high: 20 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = ref('未保存')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const guard = await ensureSaveAuthorized()
|
||||||
|
if (!guard.ok) {
|
||||||
|
result.value = guard.message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await saveChannelConfig(form)
|
||||||
|
result.value = `${response.msg},${response.data.send_status}`
|
||||||
|
} catch (error) {
|
||||||
|
result.value = error instanceof Error ? error.message : '保存通道配置失败'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>通道配置</h2>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>通道</th><th>信号类型</th><th>线路</th><th>测量类型</th><th>下限</th><th>上限</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>AI{{ form.ai_channel[0].ch }}</td>
|
||||||
|
<td><input v-model="form.ai_channel[0].singal_type" /></td>
|
||||||
|
<td><input v-model.number="form.ai_channel[0].line_no" type="number" min="1" max="4" /></td>
|
||||||
|
<td><input v-model="form.ai_channel[0].type" /></td>
|
||||||
|
<td><input v-model.number="form.ai_channel[0].limit_low" type="number" /></td>
|
||||||
|
<td><input v-model.number="form.ai_channel[0].limit_high" type="number" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>AO{{ form.ao_channel[0].ch }}</td>
|
||||||
|
<td><input v-model="form.ao_channel[0].singal_type" /></td>
|
||||||
|
<td><input v-model.number="form.ao_channel[0].line_no" type="number" min="1" max="4" /></td>
|
||||||
|
<td><input v-model="form.ao_channel[0].type" /></td>
|
||||||
|
<td><input v-model.number="form.ao_channel[0].limit_low" type="number" /></td>
|
||||||
|
<td><input v-model.number="form.ao_channel[0].limit_high" type="number" /></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存通道配置' }}</button>
|
||||||
|
</div>
|
||||||
|
<p>保存结果:{{ result }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
28
frontend/src/views/ControlView.vue
Normal file
28
frontend/src/views/ControlView.vue
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { controlSwitch } from "../api/platform"
|
||||||
|
|
||||||
|
defineProps<{ store: any; actions: any }>()
|
||||||
|
|
||||||
|
const channel = ref(1)
|
||||||
|
const result = ref("等待下发")
|
||||||
|
|
||||||
|
async function sendControl(action: number) {
|
||||||
|
const response = await controlSwitch(channel.value, action)
|
||||||
|
result.value = response.data.control_status
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>控制指令</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>开关通道<input v-model="channel" type="number" min="1" max="12" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" @click="sendControl(1)">合闸</button>
|
||||||
|
<button class="primary" @click="sendControl(0)">分闸</button>
|
||||||
|
</div>
|
||||||
|
<p>执行结果:{{ result }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
78
frontend/src/views/DeviceConfigView.vue
Normal file
78
frontend/src/views/DeviceConfigView.vue
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { saveDeviceConfig } from '../api/platform'
|
||||||
|
import type { DeviceConfigPayload } from '../types/platform'
|
||||||
|
import { ensureSaveAuthorized } from '../utils/saveGuard'
|
||||||
|
|
||||||
|
defineProps<{ store: any; actions: any }>()
|
||||||
|
|
||||||
|
const form = reactive<DeviceConfigPayload>({
|
||||||
|
password: '123456',
|
||||||
|
hardware_version: {
|
||||||
|
board_version: 'B001.001.001',
|
||||||
|
display_version: 'S001.001.001',
|
||||||
|
other_version: 'Y001.001.001',
|
||||||
|
},
|
||||||
|
software_version: {
|
||||||
|
display_program: '001.001.001',
|
||||||
|
communication_program: '001.001.001',
|
||||||
|
measurement_program: '001.001.001',
|
||||||
|
},
|
||||||
|
net: [
|
||||||
|
{ nic: '网卡一', ip: '192.168.1.10', mask: '255.255.255.0', gateway: '192.168.1.1', protocol: 'Modbus TCP' },
|
||||||
|
{ nic: '网卡二', ip: '192.168.1.56', mask: '255.255.255.255', gateway: '192.168.1.56', protocol: 'Modbus TCP' },
|
||||||
|
],
|
||||||
|
uart: [
|
||||||
|
{ port: 'COM1', baud: 9600, parity: 'NONE', data_bits: 8, stop_bits: 1, protocol: '' },
|
||||||
|
{ port: 'COM2', baud: 115200, parity: 'NONE', data_bits: 8, stop_bits: 1, protocol: 'Modbus RTU' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = ref('未保存')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const guard = await ensureSaveAuthorized()
|
||||||
|
if (!guard.ok) {
|
||||||
|
result.value = guard.message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await saveDeviceConfig(form)
|
||||||
|
result.value = `${response.msg},${response.data.send_status}`
|
||||||
|
} catch (error) {
|
||||||
|
result.value = error instanceof Error ? error.message : '保存设备配置失败'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>设备基础配置</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>操作密码<input v-model="form.password" type="password" /></label>
|
||||||
|
<label>板卡版本<input v-model="form.hardware_version.board_version" /></label>
|
||||||
|
<label>显示版本<input v-model="form.hardware_version.display_version" /></label>
|
||||||
|
<label>其他版本<input v-model="form.hardware_version.other_version" /></label>
|
||||||
|
<label>显示程序版本<input v-model="form.software_version.display_program" /></label>
|
||||||
|
<label>通信程序版本<input v-model="form.software_version.communication_program" /></label>
|
||||||
|
<label>测量程序版本<input v-model="form.software_version.measurement_program" /></label>
|
||||||
|
<label>网卡一 IP<input v-model="form.net[0].ip" /></label>
|
||||||
|
<label>网卡一 掩码<input v-model="form.net[0].mask" /></label>
|
||||||
|
<label>网卡一 网关<input v-model="form.net[0].gateway" /></label>
|
||||||
|
<label>网卡二 IP<input v-model="form.net[1].ip" /></label>
|
||||||
|
<label>网卡二 协议<input v-model="form.net[1].protocol" /></label>
|
||||||
|
<label>串口一波特率<input v-model.number="form.uart[0].baud" type="number" /></label>
|
||||||
|
<label>串口二波特率<input v-model.number="form.uart[1].baud" type="number" /></label>
|
||||||
|
<label>串口二协议<input v-model="form.uart[1].protocol" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存设备配置' }}</button>
|
||||||
|
</div>
|
||||||
|
<p>保存结果:{{ result }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
35
frontend/src/views/RealtimeView.vue
Normal file
35
frontend/src/views/RealtimeView.vue
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ store: any; actions: any }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>实时数据</h2>
|
||||||
|
<div class="metrics" style="margin-bottom: 12px">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">数据源</div>
|
||||||
|
<div class="value">{{ store.realtimeSource }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">连接状态</div>
|
||||||
|
<div class="value">{{ store.realtimeConnected ? 'WebSocket 已连接' : 'HTTP 轮询回退' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">最后更新时间</div>
|
||||||
|
<div class="value">{{ store.realtimeUpdatedAt || '--' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="store.realtime" class="grid">
|
||||||
|
<div v-for="line in store.realtime.line_list" :key="line.line_no" class="panel">
|
||||||
|
<h3>线路 {{ line.line_no }}</h3>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="metric"><div class="label">一次电压 Ua</div><div class="value">{{ line.pri_val.Ua }}</div></div>
|
||||||
|
<div class="metric"><div class="label">一次电流 Ia</div><div class="value">{{ line.pri_val.Ia }}</div></div>
|
||||||
|
<div class="metric"><div class="label">总有功 Pt</div><div class="value">{{ line.pri_val.Pt }}</div></div>
|
||||||
|
<div class="metric"><div class="label">频率</div><div class="value">{{ line.pri_val.frq }}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else>正在等待实时数据...</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
27
frontend/src/views/StatusView.vue
Normal file
27
frontend/src/views/StatusView.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ store: any; actions: any }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>设备状态</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" :disabled="store.statusLoading" @click="actions.refreshStatus()">
|
||||||
|
{{ store.statusLoading ? '刷新中...' : '刷新状态' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-if="store.status">
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="metric"><div class="label">自检</div><div class="value">{{ store.status.self_check }}</div></div>
|
||||||
|
<div class="metric"><div class="label">网口1</div><div class="value">{{ store.status.net1 }}</div></div>
|
||||||
|
<div class="metric"><div class="label">网口2</div><div class="value">{{ store.status.net2 }}</div></div>
|
||||||
|
<div class="metric"><div class="label">串口1</div><div class="value">{{ store.status.uart1 }}</div></div>
|
||||||
|
<div class="metric"><div class="label">串口2</div><div class="value">{{ store.status.uart2 }}</div></div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">最后刷新:{{ store.statusUpdatedAt || '--' }}</p>
|
||||||
|
</template>
|
||||||
|
<p v-else>正在加载状态...</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
38
frontend/src/views/SystemConfigView.vue
Normal file
38
frontend/src/views/SystemConfigView.vue
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from "vue"
|
||||||
|
import { saveSystemConfig } from "../api/platform"
|
||||||
|
import { ensureSaveAuthorized } from "../utils/saveGuard"
|
||||||
|
|
||||||
|
defineProps<{ store: any; actions: any }>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
time_sync: "auto",
|
||||||
|
brightness: 80,
|
||||||
|
screen_saver: 60,
|
||||||
|
})
|
||||||
|
const result = ref("未保存")
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const guard = await ensureSaveAuthorized()
|
||||||
|
if (!guard.ok) {
|
||||||
|
result.value = guard.message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await saveSystemConfig(form)
|
||||||
|
result.value = response.msg
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>系统设置</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>对时模式<select v-model="form.time_sync"><option value="auto">自动</option><option value="manual">手动</option></select></label>
|
||||||
|
<label>亮度<input v-model="form.brightness" type="number" min="0" max="100" /></label>
|
||||||
|
<label>屏保时间<input v-model="form.screen_saver" type="number" min="0" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="actions"><button class="primary" @click="save">保存系统设置</button></div>
|
||||||
|
<p>保存结果:{{ result }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
37
frontend/src/websocket/client.ts
Normal file
37
frontend/src/websocket/client.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { AlarmEvent, RealtimeData } from '../types/platform'
|
||||||
|
|
||||||
|
function buildWsUrl(path: string): string {
|
||||||
|
return `ws://127.0.0.1:8000${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectRealtime(onMessage: (data: RealtimeData) => void): WebSocket {
|
||||||
|
const socket = new WebSocket(buildWsUrl('/ws/real-time'))
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const payload = JSON.parse(event.data)
|
||||||
|
onMessage(payload.data)
|
||||||
|
}
|
||||||
|
return socket
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectRealtimeWithLifecycle(
|
||||||
|
onMessage: (data: RealtimeData) => void,
|
||||||
|
handlers: {
|
||||||
|
onOpen?: () => void
|
||||||
|
onClose?: () => void
|
||||||
|
onError?: () => void
|
||||||
|
},
|
||||||
|
): WebSocket {
|
||||||
|
const socket = connectRealtime(onMessage)
|
||||||
|
socket.onopen = () => handlers.onOpen?.()
|
||||||
|
socket.onclose = () => handlers.onClose?.()
|
||||||
|
socket.onerror = () => handlers.onError?.()
|
||||||
|
return socket
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectAlarm(onMessage: (data: AlarmEvent) => void): WebSocket {
|
||||||
|
const socket = new WebSocket(buildWsUrl('/ws/alarm'))
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
onMessage(JSON.parse(event.data))
|
||||||
|
}
|
||||||
|
return socket
|
||||||
|
}
|
||||||
15
frontend/tsconfig.json
Normal file
15
frontend/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
10
frontend/vite.config.ts
Normal file
10
frontend/vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user