docs: 一些文档修改

This commit is contained in:
caoqianming 2026-04-01 08:22:34 +08:00
parent 3f517c5f48
commit 43bbc36106
4 changed files with 343 additions and 575 deletions

5
.gitignore vendored
View File

@ -26,3 +26,8 @@
# OS files
.DS_Store
Thumbs.db
.claude/
.VSCodeCounter/
cl.bat
col.bat

150
README.md Normal file
View File

@ -0,0 +1,150 @@
# PLC Control
PLC Control 是一个面向 PLC / OPC UA 场景的本地控制与监控系统,提供设备建模、点位管理、控制单元自动控制、实时信号展示、日志追踪和文档查看能力。
## 核心功能
### 1. 数据源与点位管理
- 支持 OPC UA 数据源接入、重连和节点浏览
- 支持从节点批量创建点位
- 支持点位与设备、标签、信号角色的绑定
- 支持批量写点和实时值查看
### 2. 设备与控制单元建模
- 设备可按 `unit_id` 归属到控制单元
- 点位可按 `signal_role` 标记为 `start_cmd`、`stop_cmd`、`run`、`flt`、`rem` 等控制信号
- 控制单元通过时长参数描述启停节奏与累计运行逻辑
### 3. 自动控制引擎
- 后端为每个启用中的控制单元维护独立异步任务
- 支持 `Stopped -> Running -> DistributorRunning` 状态流转
- 支持通信锁定、故障锁定、人工确认
- 支持单个和批量启动/停止自动控制
### 4. 手动控制与联锁
- 设备手动启停前会检查 REM、FLT、信号质量和单元运行时状态
- 自动控制启用时会阻止冲突的手动命令
- `fault_locked`、`comm_locked`、`manual_ack_required` 都会阻止自动启动
### 5. 前端双视图
- `Ops` 视图:
- 展示控制单元状态
- 展示设备卡片和 REM/RUN/FLT 实时信号
- 提供设备启停和批量自动控制入口
- `Config` 视图:
- 展示数据源、设备、点位、事件、图表
- 展示实时日志流
### 6. 实时通信
- WebSocket 推送点位实时值
- WebSocket 推送控制单元运行时变化
- SSE 推送日志增量
- 日志流默认跟随最新 `app.log*` 文件,支持轮转切换
## 系统设计
## 后端结构
- `src/main.rs`
- 启动 Axum 服务
- 注册 HTTP 路由、WebSocket 路由和静态页面
- `src/handler`
- HTTP 接口层
- `src/service`
- 数据查询与写入封装
- `src/control`
- 自动控制引擎、运行时存储、手动控制校验、模拟反馈
- `src/connection.rs`
- OPC UA 连接管理、订阅、轮询、批量写点
- `src/event.rs`
- 控制事件、实时点位事件和事件持久化
- `src/websocket.rs`
- WebSocket 房间与实时消息广播
## 控制引擎设计
控制引擎的几个关键点:
- 每个启用单元有一个独立任务
- 每轮循环都会重读单元配置和设备角色映射
- 设备/点位控制相关配置变更后,会主动唤醒对应单元任务
- `wait_phase` 使用 `sleep_until(deadline)`,保证阶段计时不会被中途轮询漂移
当前时长约束:
- `run_time_sec > 0`
- `stop_time_sec > 0`
- `acc_time_sec > 0`
- `bl_time_sec > 0`
- `acc_time_sec > run_time_sec`
## 前端 Web 设计
前端采用原生 ES Module 和分片 HTML 结构。
关键模块:
- `web/js/app.js`: 页面启动、视图切换、事件绑定
- `web/js/ops.js`: 运维视图渲染
- `web/js/logs.js`: WebSocket 与日志 SSE 处理
- `web/js/docs.js`: Markdown 文档抽屉
- `web/js/units.js` / `equipment.js` / `points.js`: 配置视图业务逻辑
文档查看入口:
- 可在前端页面中查看 `API.md`
- 可在前端页面中查看 `README.md`
两者都通过统一的文档抽屉组件展示。
## 实时日志设计
日志能力包含两部分:
- `GET /api/logs`
- 拉取日志内容
- `GET /api/logs/stream`
- SSE 增量推送
日志轮转行为:
- 如果未指定 `file`,后端总是跟随最新 `app.log*`
- 一旦轮转到新文件SSE 流自动切换
- 前端会插入分隔提示,标识当前切换到了哪个日志文件
## 运行方式
## 主要依赖
- Rust
- Axum
- Tokio
- SQLx
- PostgreSQL
- async-opcua
## 关键环境变量
- `DATABASE_URL`
- `HOST`
- `PORT`
- `WRITE_API_KEY`
- `SIMULATE_PLC`
## 文档索引
- API 接口说明: `API.md`
- 控制引擎计划: `docs/superpowers/plans/2026-03-24-control-engine.md`
- 双视图前端计划: `docs/superpowers/plans/2026-03-25-dual-view-web.md`
## 当前实现特点
- 后端偏本地部署和现场控制使用
- 控制状态保存在内存中,重启后重置
- 前端和后端都围绕“实时控制 + 快速排障”设计

View File

@ -1,272 +1,128 @@
# 控制引擎实现计划
> **适用于代理执行:** 必须使用 superpowers:subagent-driven-development推荐或 superpowers:executing-plans 逐任务执行。步骤使用复选框(`- [ ]`)语法跟踪进度。
## 当前状态
**目标:** 实现投煤器 / 布料机单元的自动控制引擎,包括状态机、故障/通信保护、运行时 API 及前端控制面板
该计划对应的核心功能已经落地,且在后续修复中做了几次重要收敛。当前代码状态以仓库实现为准,下面记录的是最新有效设计
**架构:** 引擎为每个已启用的单元各启动一个异步任务由10秒扫描器监督。每个任务通过 `tokio::time::sleep_until` 控制阶段计时,通过 `tokio::sync::Notify` 在外部状态变化时(自动启停、故障确认)立即唤醒。状态保存在 `ControlRuntimeStore`(内存中,不持久化)。前端通过 `WsMessage::UnitRuntimeChanged` 实时接收更新——**仅在状态转换时推送**,不做周期性推送。
## 已完成范围
**技术栈:** Rust/Axum 后端、sqlx/PostgreSQL、tokio 异步、Vanilla JS ES 模块前端。
- 已实现 `ControlRuntimeStore``UnitRuntime`
- 已实现单元自动控制状态机
- 已实现故障锁定、通信锁定、人工确认流程
- 已实现自动控制相关 API
- 已实现 `UnitRuntimeChanged` WebSocket 推送
- 已实现前端单元运行时展示
- 已实现手动控制前的运行时阻断校验
---
## 关键实现更新
## 文件清单
### 1. 时长配置约束
| 文件 | 操作 | 职责 |
|------|------|------|
| `src/control/runtime.rs` | ✅ 已完成 | `UnitRuntime` 结构体 + `ControlRuntimeStore`(含 `Notify` |
| `src/control/command.rs` | ✅ 已完成 | 共享 `send_pulse_command()``simulate_run_feedback()` |
| `src/control/engine.rs` | ✅ 已完成 | 监督器 + 单元异步任务 + `wait_phase` |
| `src/control/validator.rs` | ✅ 已完成 | 故障/通信锁定时阻断手动指令 |
| `src/control/mod.rs` | ✅ 已完成 | 导出 `command`、`engine`、`runtime`、`validator` |
| `src/event.rs` | ✅ 已完成 | 7个 `AppEvent` 变体;`UnitStateChanged` 触发但**不持久化到数据库** |
| `src/websocket.rs` | ✅ 已完成 | `WsMessage::UnitRuntimeChanged` |
| `src/service/control.rs` | ✅ 已完成 | `get_all_enabled_units`、`get_equipment_by_unit_id` |
| `src/handler/control.rs` | ✅ 已完成 | `start_auto`、`stop_auto`、`batch_start_auto`、`batch_stop_auto`、`ack_fault`、`get_unit_runtime`;每次状态变更后调用 `notify_unit` |
| `src/main.rs` | ✅ 已完成 | 上述端点的路由注册 |
| `web/js/state.js` | ✅ 已完成 | `runtimes: new Map()` |
| `web/js/units.js` | ✅ 已完成 | 运行时状态徽章、Auto Start/Stop、Ack Fault显示 `display_acc_sec` |
| `web/js/ops.js` | ✅ 已完成 | 运维面板单元卡片显示运行时徽章与 `display_acc_sec` |
| `web/js/app.js` | ✅ 已完成 | 处理 `UnitRuntimeChanged` WS 消息 |
| `web/styles.css` | ✅ 已完成 | `.event-card { flex-shrink: 0 }` 防止 flex 列表中文字重叠 |
控制单元时长字段现在统一要求:
---
- `run_time_sec > 0`
- `stop_time_sec > 0`
- `acc_time_sec > 0`
- `bl_time_sec > 0`
- `acc_time_sec > run_time_sec`
## UnitRuntime 结构体(当前)
这些约束已在创建和更新接口中统一校验。
### 2. 自动控制启动条件统一
单个启动和批量启动现在使用同一套阻断规则。以下任一条件成立时都不能启动自动控制:
- `fault_locked = true`
- `comm_locked = true`
- `manual_ack_required = true`
### 3. 设备映射刷新策略已调整
最初计划里是“单元任务启动时加载一次设备映射”。这个方案已经被修正。
当前实现:
- 控制引擎在每轮单元循环中重新读取设备和角色映射
- 设备或点位的控制相关配置发生变化后,会主动唤醒对应单元任务
- 因此不再依赖“等下一个 supervisor 周期重启任务”来刷新映射
这解决了运行中的单元长期使用旧 `equipment.kind`、`signal_role`、`equipment_id` 绑定的问题。
## 当前引擎行为
### supervisor
- 周期性扫描已启用单元
- 确保每个启用单元都有对应任务在运行
- 单元被禁用或删除后,其任务会自行退出
### unit_task
每轮循环都会执行:
1. 重读单元配置
2. 重读设备和角色映射
3. 检查故障与通信状态
4. 根据 `auto_enabled`、锁定状态和当前状态机状态推进流程
### wait_phase
- 使用 `sleep_until(deadline)` 控制阶段结束时间
- 中途通过 `fault_tick``notify` 打断
- 打断后重新检查故障、通信和自动控制开关
## 运行时结构
当前 `UnitRuntime` 字段:
```rust
// src/control/runtime.rs
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UnitRuntime {
pub unit_id: Uuid,
pub state: UnitRuntimeState,
pub auto_enabled: bool,
pub accumulated_run_sec: i64, // 内部累加器(毫秒),不直接用于显示
pub display_acc_sec: i64, // 状态转换时的快照,前端展示用此字段
pub accumulated_run_sec: i64,
pub display_acc_sec: i64,
pub fault_locked: bool,
pub flt_active: bool,
pub comm_locked: bool,
pub manual_ack_required: bool,
}
// 注意elapsed 字段current_run_elapsed_sec、current_stop_elapsed_sec、
// distributor_run_elapsed_sec、last_tick_at已在事件驱动重构中移除。
// 计时完全由单元任务内部的 tokio::time::sleep_until 管理,请勿重新添加。
```
`ControlRuntimeStore` 额外包含:
说明:
```rust
notifiers: Arc<RwLock<HashMap<Uuid, Arc<Notify>>>>,
- `accumulated_run_sec``display_acc_sec` 单位都是毫秒
- 运行时状态保存在内存中,不持久化
// 方法:
pub async fn get_or_create_notify(&self, unit_id: Uuid) -> Arc<Notify>
pub async fn notify_unit(&self, unit_id: Uuid) // 每次状态变更后调用
```
## 配置变更后的唤醒链路
---
以下操作现在都会通知相关控制单元尽快刷新配置:
## 引擎架构事件驱动2026-03-26
- 更新设备
- 批量调整设备所属单元
- 删除设备
- 更新点位的 `equipment_id` / `signal_role`
- 批量调整点位设备绑定
- 删除点位
```
start()
└─ supervise() —— 10秒间隔为每个启用单元启动 unit_task
## 相关文件
unit_task(unit_id)
├─ load_equipment_maps —— 任务启动时加载一次(缓存至任务生命周期结束)
├─ fault_tick —— 500ms 间隔,在 wait_phase 内部使用
└─ loop:
├─ 重新加载单元配置(检查是否仍启用)
├─ check_fault_comm → 有变化则推送 WS
├─ 若 !auto || fault || comm → select!(fault_tick | notify)continue
└─ 按状态分支:
Stopped → wait_phase(stop_time_sec) → 启动给煤机 → 状态=Running → 推送 WS
Running → wait_phase(run_time_sec) → 停止给煤机 → acc += run_time_sec
→ 若 acc >= acc_time_sec启动布料机状态=DistributorRunning
→ 否则:状态=Stopped → 推送 WS
DistributorRunning → wait_phase(bl_time_sec) → 停止布料机 → acc=0 → 状态=Stopped → 推送 WS
FaultLocked|CommLocked → select!(fault_tick | notify)
- `src/control/runtime.rs`
- `src/control/engine.rs`
- `src/control/validator.rs`
- `src/handler/control.rs`
- `src/handler/equipment.rs`
- `src/handler/point.rs`
- `src/service/control.rs`
- `src/event.rs`
- `src/websocket.rs`
wait_phase(secs):
deadline = now + secs
loop:
select! { sleep_until(deadline) => 返回 true阶段正常完成
fault_tick.tick() => 重新检查故障/通信;若中断返回 false
notify.notified() => 重新检查故障/通信;若中断返回 false }
```
## 验证状态
**关键不变量:**
- `accumulated_run_sec` 每个完成周期**精确**增加 `run_time_sec * 1000`(无 delta 漂移)。
- `display_acc_sec` 仅在 Running→Stopped 或 Running→DistributorRunning 转换时从 `accumulated_run_sec` 复制快照,前端始终读取 `display_acc_sec`
- WS **仅在状态变化时**推送,无周期性推送。
- `unit.state_changed` 事件仅用于日志记录,**不写入**数据库事件表(频率过高)。
当前仓库内已补的相关测试包括:
---
- 时长字段校验测试
- `comm_locked` 阻止自动启动测试
- 设备映射重载反映最新角色绑定测试
## 任务一:扩展 UnitRuntime ✅ 已完成
**文件:** `src/control/runtime.rs`
字段如上方"UnitRuntime 结构体"所示。`ControlRuntimeStore` 包含 `notifiers` 映射,提供 `get_or_create_notify``notify_unit` 方法。
---
## 任务二:创建共享脉冲指令辅助函数 ✅ 已完成
**文件:** `src/control/command.rs`、`src/control/mod.rs`、`src/handler/control.rs`
`send_pulse_command(connection_manager, point_id, value_type, pulse_ms)` 写入高→延迟→低电平序列。
`simulate_run_feedback(state, eq_id, running)` 在模拟模式下写入虚拟运行反馈值(用于无真实 OPC UA 设备时的调试)。
---
## 任务三:在 validator.rs 添加运行时状态检查 ✅ 已完成
**文件:** `src/control/validator.rs`
`validate_manual_control` 的现有 REM/FLT/quality 检查之后添加:
```rust
if let Some(unit_id) = equipment.unit_id {
if let Some(runtime) = state.control_runtime.get(unit_id).await {
if runtime.auto_enabled {
return Err(ApiErr::Forbidden("自动控制已激活,请先停止自动控制", ...));
}
if runtime.comm_locked {
return Err(ApiErr::Forbidden("单元通信已锁定", ...));
}
if runtime.fault_locked {
return Err(ApiErr::Forbidden("单元处于故障锁定状态", ...));
}
}
}
```
---
## 任务四:扩展 AppEvent 业务事件 ✅ 已完成
**文件:** `src/event.rs`
新增 7 个变体:
```rust
AutoControlStarted { unit_id: Uuid },
AutoControlStopped { unit_id: Uuid },
FaultLocked { unit_id: Uuid, equipment_id: Uuid },
FaultAcked { unit_id: Uuid },
CommLocked { unit_id: Uuid },
CommRecovered { unit_id: Uuid },
UnitStateChanged { unit_id: Uuid, from_state: String, to_state: String },
```
**`persist_event_if_needed` 映射:**
| 变体 | 写库? | event_type |
|------|--------|-----------|
| `AutoControlStarted` | ✅ | `unit.auto_control_started` |
| `AutoControlStopped` | ✅ | `unit.auto_control_stopped` |
| `FaultLocked` | ✅ | `unit.fault_locked`level: error|
| `FaultAcked` | ✅ | `unit.fault_acked` |
| `CommLocked` | ✅ | `unit.comm_locked`level: warn|
| `CommRecovered` | ✅ | `unit.comm_recovered` |
| `UnitStateChanged` | ❌ | —(频率过高,每周期触发)|
---
## 任务五:添加 WsMessage::UnitRuntimeChanged ✅ 已完成
**文件:** `src/websocket.rs`
```rust
UnitRuntimeChanged(crate::control::runtime::UnitRuntime),
```
---
## 任务六:添加 service 辅助函数 ✅ 已完成
**文件:** `src/service/control.rs`
```rust
pub async fn get_all_enabled_units(pool: &PgPool) -> Result<Vec<ControlUnit>, sqlx::Error>
pub async fn get_equipment_by_unit_id(pool: &PgPool, unit_id: Uuid) -> Result<Vec<Equipment>, sqlx::Error>
```
---
## 任务七:实现 control/engine.rs ✅ 已完成事件驱动2026-03-26
**文件:** `src/control/engine.rs`
完整设计参见上方"引擎架构"章节。
**后续修改的关键规则:**
- 除状态转换或故障/通信变化外,不得推送 `WsMessage::UnitRuntimeChanged`
- `wait_phase` 必须使用 `sleep_until(deadline)` 而非 `sleep(duration)`——deadline 在阶段开始时固定,故障 tick 重检不会重置计时器。
- 在 `wait_phase` 内处理 `notify.notified()` 时,必须从 store 重新读取运行时handler 可能已修改 `auto_enabled`)。
- 设备映射在每次任务调用时加载一次若设备配置变更监督器将在下次扫描≤10秒时重启该任务。
---
## 任务八:新增 API 端点 ✅ 已完成
**文件:** `src/handler/control.rs`、`src/main.rs`
| 方法 | 路径 | Handler |
|------|------|---------|
| POST | `/api/unit/:id/start-auto` | `start_auto_unit` |
| POST | `/api/unit/:id/stop-auto` | `stop_auto_unit` |
| POST | `/api/unit/:id/ack-fault` | `ack_fault_unit` |
| POST | `/api/unit/batch-start-auto` | `batch_start_auto` |
| POST | `/api/unit/batch-stop-auto` | `batch_stop_auto` |
| GET | `/api/unit/:id/runtime` | `get_unit_runtime` |
**Notify 规约:** 每个修改 `auto_enabled``fault_locked` 的 handler在 upsert 运行时后**必须**调用 `state.control_runtime.notify_unit(unit_id).await`,以立即唤醒休眠中的单元任务。
```rust
// 每个 auto/fault handler 必须遵循此模式:
state.control_runtime.upsert(runtime).await;
state.control_runtime.notify_unit(unit_id).await; // ← 不可省略
let _ = state.event_manager.send(AppEvent::...);
```
---
## 任务九:前端运行时集成 ✅ 已完成
**文件:** `web/js/state.js`、`web/js/units.js`、`web/js/ops.js`、`web/js/app.js`
**app.js 中的 WS 处理器:**
```js
case "UnitRuntimeChanged":
state.runtimes.set(payload.data.unit_id, payload.data);
renderUnits(); // 重新渲染单元卡片(更新徽章和按钮)
renderOpsUnits(); // 更新运维视图单元列表
syncEquipmentButtonsForUnit(runtime.unit_id, runtime.auto_enabled);
break;
```
**显示规则:** 前端始终使用 `runtime.display_acc_sec` 显示累计时间,不使用 `runtime.accumulated_run_sec`
```js
// ✅ 正确
`Acc ${Math.floor(runtime.display_acc_sec / 1000)}s`
// ❌ 错误——会显示周期中途的抖动值
`Acc ${Math.floor(runtime.accumulated_run_sec / 1000)}s`
```
**事件列表 CSS** `.event-card` 必须设置 `flex-shrink: 0`(在 `web/styles.css` 中),防止 flex 列高度压缩导致文字重叠。
---
## 任务十:将引擎接入 AppState ✅ 已完成
**文件:** `src/main.rs`
```rust
let control_runtime = Arc::new(control::runtime::ControlRuntimeStore::new());
// ... 构建 AppState ...
control::engine::start(state.clone(), control_runtime);
```
最近一次相关修改完成后,`cargo test` 与 `cargo check` 均已通过。

View File

@ -1,358 +1,115 @@
# 双视图 Web UI 实现计划
> **适用于代理执行:** 必须使用 superpowers:subagent-driven-development推荐或 superpowers:executing-plans 逐任务执行。步骤使用复选框(`- [ ]`)语法跟踪进度。
## 当前状态
**目标:** 在顶部添加 **运维视图****配置视图** 两个标签页切换。运维视图以设备为核心,展示实时信号点状态(彩色信号点)及底部系统事件面板;配置视图在原有布局基础上,将底部中间面板替换为实时 SSE 日志流
该计划对应的双视图页面已经完成,本文档同步到当前实现,并补充最近的日志流轮转修复
**架构:** `<main>` 元素通过 CSS 类名(`grid-ops` / `grid-config`)控制面板显示。新建 `ops.js` 模块负责运维视图:加载所有单元的设备详情并渲染设备卡片,每张卡片包含 REM/RUN/FLT 三个信号点(彩色小圆点),卡片中的 DOM 元素注册到 `state.opsPointEls``Map<point_id, { dotEl }>`WebSocket 处理器通过 `sigDotClass()` 实时更新信号点颜色。SSE 日志流(`/api/logs/stream`)仅在配置视图中启动,切换标签时启停。
## 已完成范围
**技术栈:** Vanilla JS ES 模块、CSS Grid、SSE`EventSource`)、现有 WebSocket 基础设施、`/api/unit/{id}/detail` 端点。
- 已实现顶部 `Ops / Config` 视图切换
- 已实现运维视图单元侧栏和设备卡片区
- 已实现配置视图日志流面板
- 已实现基于 WebSocket 的信号灯和运行时状态更新
- 已实现批量启动 / 批量停止自动控制按钮
- 已实现 SSE 日志流接入
---
## 当前页面结构
## 当前布局(参考)
### 运维视图
```
grid3列 × 2行
左上 → equipment-panel.html 第1列第1行
右上 → points-panel.html 第2-3列第1行
左下 → source-panel.html 第1列第2行—— 单元 + 数据源
中下 → logs-panel.html 第2列第2行—— 系统事件
右下 → chart-panel.html 第3列第2行
- 左侧: 控制单元列表
- 右侧: 设备卡片区
- 底部: 系统事件面板
### 配置视图
- 保留原有配置面板布局
- 底部中间区域显示实时日志流
## 关键实现说明
### 1. 单元与设备视图
前端在 `ops` 视图中:
- 展示单元运行状态、启用状态、累计时长
- 展示设备的 `REM / RUN / FLT` 信号状态
- 为 `coal_feeder``distributor` 提供手动 `Start / Stop`
- 自动控制启用时,禁用对应设备卡片上的手动按钮
相关文件:
- `web/js/ops.js`
- `web/js/units.js`
- `web/js/app.js`
- `web/js/state.js`
- `web/styles.css`
### 2. 运行时更新
`UnitRuntimeChanged` WebSocket 消息会驱动:
- 单元列表状态刷新
- 运维视图单元卡片刷新
- 设备手动按钮禁用状态同步
点位实时值更新会驱动:
- 配置视图点位列表刷新
- 运维视图信号灯颜色刷新
- 图表数据追加
### 3. 日志流面板
配置视图使用 SSE 订阅 `/api/logs/stream`
当前行为:
- 默认跟随最新的 `app.log*`
- 后端每 800ms 检查一次增量内容
- 如果日志轮转到了新文件SSE 流会自动切换到新文件
- 前端会插入一条分隔提示,例如:
```text
[log switched to app.log.1]
```
## 目标布局
然后继续追加新文件内容,不清空旧内容。
```
grid-config与原布局一致
左上 → equipment-panel 第1列第1行
右上 → points-panel 第2-3列第1行
左下 → source-panel 第1列第2行
中下 → log-stream-panel【新建】 第2列第2行—— SSE 日志
右下 → chart-panel 第3列第2行
相关文件:
grid-ops新布局
上方 → ops-panel【新建】 第1-2列第1行—— 单元侧栏 + 设备卡片
下方 → logs-panel 第1-2列第2行—— 系统事件(全宽)
```
- `src/handler/log.rs`
- `web/js/logs.js`
## 文件清单
## 最近补充修复
| 文件 | 操作 | 用途 |
|---|---|---|
| `web/html/topbar.html` | 修改 | 添加 `#tabOps` / `#tabConfig` 标签按钮及批量自动按钮 |
| `web/html/ops-panel.html` | **新建** | 运维视图:`#opsUnitList` 侧栏 + `#opsEquipmentArea` 设备卡片区 |
| `web/html/log-stream-panel.html` | **新建** | 配置视图底部中间SSE 日志流(`#logView`|
| `web/index.html` | 修改 | 引入新 partial、更新版本号 |
| `web/js/ops.js` | **新建** | 加载设备详情、渲染设备卡片、`sigDotClass()`、`syncEquipmentButtonsForUnit()` |
| `web/js/state.js` | 修改 | 添加 `activeView`、`opsPointEls`、`logSource`、`selectedOpsUnitId` |
| `web/js/dom.js` | 修改 | 添加引用:`tabOps`、`tabConfig`、`batchStartAutoBtn`、`batchStopAutoBtn`、`opsUnitList`、`opsEquipmentArea`、`logView` |
| `web/js/logs.js` | 修改 | 添加 `startLogs` / `stopLogs`;在 WS 处理器中更新 `opsPointEls` 信号点 |
| `web/js/app.js` | 修改 | 标签切换逻辑、监听 `units-loaded` 事件、启动 ops 视图 |
| `web/styles.css` | 修改 | 标签样式、`grid-ops`、`grid-config`、设备卡片与信号点样式 |
### 日志轮转跟随
---
新增修复后:
## 任务一:标签脚手架 + CSS 布局切换 ✅ 已完成
- `GET /api/logs/stream` 在未指定 `file` 时,会持续跟随最新日志文件
- 一旦文件切换,后端返回 `reset = true` 和新的 `file`
- 前端用分隔提示标识轮转点
**涉及文件:**
- 修改:`web/html/topbar.html`
- 修改:`web/index.html`
- 修改:`web/js/state.js`
- 修改:`web/js/dom.js`
- 修改:`web/js/app.js`
- 修改:`web/styles.css`
这解决了旧实现只盯住启动时选中文件、轮转后不再显示新日志的问题。
- [x] **步骤 1在顶栏添加标签按钮**
## 相关文件
`web/html/topbar.html` 中添加 `.tab-bar`(含 `#tabOps` / `#tabConfig`)及批量自动控制按钮(`#batchStartAutoBtn` / `#batchStopAutoBtn`)。
- `web/index.html`
- `web/html/topbar.html`
- `web/html/ops-panel.html`
- `web/html/log-stream-panel.html`
- `web/js/app.js`
- `web/js/ops.js`
- `web/js/logs.js`
- `web/js/state.js`
- `web/js/dom.js`
- `web/styles.css`
- [x] **步骤 2`web/styles.css` 添加标签与网格 CSS**
## 验证状态
添加 `.tab-bar`、`.tab-btn`、`.tab-btn.active` 样式;将原有 `.grid` 替换为 `.grid-ops``.grid-config`,分别定义列、行及面板 `grid-column/row` 赋值。
最近一次日志流相关修复完成后,已确认:
- [x] **步骤 3`web/js/state.js` 添加新字段**
```js
activeView: "ops", // "ops" | "config"
opsPointEls: new Map(), // point_id -> { dotEl }
logSource: null,
selectedOpsUnitId: null,
```
- [x] **步骤 4`web/js/dom.js` 添加 DOM 引用**
```js
tabOps: byId("tabOps"),
tabConfig: byId("tabConfig"),
batchStartAutoBtn: byId("batchStartAutoBtn"),
batchStopAutoBtn: byId("batchStopAutoBtn"),
opsUnitList: byId("opsUnitList"),
opsEquipmentArea: byId("opsEquipmentArea"),
logView: byId("logView"),
```
- [x] **步骤 5`web/js/app.js` 中添加 `switchView` 函数并绑定事件**
```js
function switchView(view) {
state.activeView = view;
const main = document.querySelector("main");
main.className = view === "ops" ? "grid-ops" : "grid-config";
dom.tabOps.classList.toggle("active", view === "ops");
dom.tabConfig.classList.toggle("active", view === "config");
// 显示/隐藏配置视图专属面板top-left/top-right/bottom-left/bottom-right/bottom-mid
// 显示/隐藏运维视图专属面板ops-main/ops-bottom
if (view === "config") startLogs(); else stopLogs();
}
```
`bindEvents` 中添加:
```js
dom.tabOps.addEventListener("click", () => switchView("ops"));
dom.tabConfig.addEventListener("click", () => switchView("config"));
```
`bootstrap` 中调用:
```js
switchView("ops"); // 默认进入运维视图
```
- [x] **步骤 6更新 `web/index.html`**
`<main class="grid-ops">` 中引入所有 partial含新建的 ops-panel.html、log-stream-panel.html并更新 CSS/JS 版本号。
---
## 任务二:运维面板 HTML + CSS 骨架 ✅ 已完成
**涉及文件:**
- 新建:`web/html/ops-panel.html`
- 修改:`web/styles.css`
- [x] **步骤 1新建 `web/html/ops-panel.html`**
```html
<section class="panel ops-main">
<div class="ops-layout">
<aside class="ops-unit-sidebar">
<div class="panel-head"><h2>控制单元</h2></div>
<div class="list ops-unit-list" id="opsUnitList"></div>
</aside>
<div class="ops-equipment-area" id="opsEquipmentArea">
<div class="muted ops-placeholder">← 选择控制单元</div>
</div>
</div>
</section>
```
`web/html/logs-panel.html` 增加 `ops-bottom` class使其在运维视图中作为底部全宽面板。
- [x] **步骤 2`web/styles.css` 添加运维视图 CSS**
`.ops-layout`flex 横向)、`.ops-unit-sidebar`(固定宽度)、`.ops-unit-list`(可滚动)、`.ops-equipment-area`flex-wrap 卡片区)。
设备卡片相关类:`.ops-eq-card`、`.ops-eq-card-head`、`.ops-signal-rows`、`.ops-signal-row`、`.ops-signal-label`、`.ops-eq-card-actions`。
单元列表项相关类:`.ops-unit-item`、`.ops-unit-item-name`、`.ops-unit-item-meta`、`.ops-unit-item-actions`。
信号点相关类:`.sig-dot`(灰色默认)、`.sig-dot.sig-on`(绿色)、`.sig-dot.sig-fault`(红色)、`.sig-dot.sig-warn`(黄色)。
---
## 任务三ops.js —— 单元列表 + 设备卡片渲染 ✅ 已完成
**涉及文件:**
- 新建:`web/js/ops.js`
- 修改:`web/js/app.js`、`web/js/units.js`
### 实际实现说明
运维视图在**初始加载时一次性加载所有单元的所有设备卡片**`loadAllEquipmentCards`),而非等待点击单元后再加载。点击某个单元会过滤只展示该单元的设备;再次点击同一单元则取消过滤并恢复全部展示。
信号点使用**彩色小圆点**`sig-dot` 类)而非文字值+质量徽章。
#### 核心函数
**`sigDotClass(role, quality, valueText) → string`**(导出)
根据信号质量与值计算 CSS 类名:
- `quality !== "good"``"sig-dot sig-warn"`(黄色)
- 值为 `"1"` / `"true"` / `"on"`
- `role === "flt"``"sig-dot sig-fault"`(红色)
- 其他 → `"sig-dot sig-on"`(绿色)
- 其他 → `"sig-dot"`(灰色)
**`renderOpsUnits()`**(导出)
遍历 `state.units`,为每个单元渲染列表项,包含:
- 运行状态徽章(`runtimeBadge`)、启用/禁用徽章、累计时间
- "Start Auto" / "Stop Auto" 按钮(调用 `/api/control/unit/:id/start-auto``stop-auto`
- 若 `runtime.manual_ack_required` 为真,显示 "Ack Fault" 按钮
**`loadAllEquipmentCards()`**(导出)
并发请求所有单元的 `/api/unit/{id}/detail`,将全部设备合并后调用 `renderOpsEquipments()`
**`selectOpsUnit(unitId)`**(私有)
切换 `state.selectedOpsUnitId`。若取消选择,调用 `loadAllEquipmentCards()` 恢复全部展示;若选中某单元,加载该单元详情并渲染其设备。
**`renderOpsEquipments(equipments)`**(私有)
为每台设备渲染一张卡片,包含:
- 卡片头:设备编码 + 类型徽章
- 信号行REM / RUN / FLT 三个角色,每行一个 `<span class="sig-dot ...">` 元素(`data-ops-dot` + `data-ops-role` 属性)
- 控制按钮(仅 `coal_feeder` / `distributor`Start / Stop`auto_enabled` 时禁用
- 注册 DOM 元素:`state.opsPointEls.set(pointId, { dotEl })`
- 若缓存中有 `point.point_monitor`,立即根据缓存值初始化信号点颜色
**`startOps()`**(导出)
```js
export function startOps() {
renderOpsUnits();
loadAllEquipmentCards();
dom.batchStartAutoBtn?.addEventListener("click", () => {
apiFetch("/api/control/unit/batch-start-auto", { method: "POST" }).then(() => loadUnits()).catch(() => {});
});
dom.batchStopAutoBtn?.addEventListener("click", () => {
apiFetch("/api/control/unit/batch-stop-auto", { method: "POST" }).then(() => loadUnits()).catch(() => {});
});
}
```
**`syncEquipmentButtonsForUnit(unitId, autoEnabled)`**(导出)
WS 收到 `UnitRuntimeChanged` 时调用,同步设备卡片中 Start/Stop 按钮的 `disabled` 状态(避免重新渲染整个卡片区):
```js
export function syncEquipmentButtonsForUnit(unitId, autoEnabled) {
dom.opsEquipmentArea
.querySelectorAll(`.ops-eq-card-actions[data-unit-id="${unitId}"]`)
.forEach((actions) => {
actions.querySelectorAll("button").forEach((btn) => {
btn.disabled = autoEnabled;
btn.title = autoEnabled ? "自动控制运行中,请先停止自动" : "";
});
});
}
```
#### app.js 接入
```js
import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js";
// bootstrap 中:
await withStatus(loadUnits());
startOps();
// 事件监听:
document.addEventListener("equipments-updated", () => {
renderUnits();
renderOpsUnits();
});
document.addEventListener("units-loaded", () => {
renderOpsUnits();
if (!state.selectedOpsUnitId) loadAllEquipmentCards();
});
```
---
## 任务四:运维卡片信号点实时更新 ✅ 已完成
**涉及文件:**
- 修改:`web/js/logs.js`
`startPointSocket` 的 WebSocket `PointNewValue` 分支中,添加运维视图信号点更新逻辑:
```js
// 运维视图信号点
const opsEntry = state.opsPointEls.get(data.point_id);
if (opsEntry) {
const { dotEl } = opsEntry;
const role = dotEl.dataset.opsRole;
import("./ops.js").then(({ sigDotClass }) => {
dotEl.className = sigDotClass(role, data.quality, data.value_text);
});
}
```
`UnitRuntimeChanged` 分支同步更新运维单元列表和设备按钮状态:
```js
if (payload.type === "UnitRuntimeChanged") {
const runtime = payload.data;
state.runtimes.set(runtime.unit_id, runtime);
renderUnits();
import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => {
renderOpsUnits();
syncEquipmentButtonsForUnit(runtime.unit_id, runtime.auto_enabled);
});
return;
}
```
> 注意:使用动态 `import("./ops.js")` 避免循环依赖(`ops.js` → `logs.js``ops.js`)。
---
## 任务五:配置视图的日志流面板 ✅ 已完成
**涉及文件:**
- 新建:`web/html/log-stream-panel.html`
- 修改:`web/js/logs.js`
- 修改:`web/js/dom.js`
- [x] **步骤 1新建 `web/html/log-stream-panel.html`**
```html
<section class="panel bottom-mid">
<div class="panel-head"><h2>实时日志</h2></div>
<div class="log" id="logView"></div>
</section>
```
- [x] **步骤 2`web/js/logs.js` 中实现 `startLogs` / `stopLogs`**
```js
export function startLogs() {
if (state.logSource) return;
state.logSource = new EventSource("/api/logs/stream");
state.logSource.addEventListener("log", (event) => {
const data = JSON.parse(event.data);
(data.lines || []).forEach(appendLog);
});
state.logSource.addEventListener("error", () => appendLog("[log stream error]"));
}
export function stopLogs() {
if (state.logSource) {
state.logSource.close();
state.logSource = null;
}
}
```
`startLogs()` 是幂等的(有 `if (state.logSource) return` 守卫),可安全重复调用。
---
## 任务六:收尾、清理与样式完善 ✅ 已完成
- [x] 补充日志面板 CSS`.log`、`.log-line`、`.level-info/warn/error`
- [x] `web/js/units.js``loadUnits()` 末尾派发 `units-loaded` 事件
- [x] 更新 `web/index.html` 版本号
- [x] 最终验证标签切换、信号点实时更新、Start/Stop 控制按钮、SSE 日志流
---
## 实现者注意事项
- `state.opsPointEls` 在每次重新渲染设备卡片时清空重建,不存在陈旧引用。
- `syncEquipmentButtonsForUnit` 仅更新按钮的 `disabled` 状态,避免每次运行时更新都重渲染整个卡片区。
- 运维视图默认展示**所有单元的所有设备**,点击单元后过滤;取消选择后恢复全部展示。
- 设备卡片头部 `data-unit-id` 属性供 `syncEquipmentButtonsForUnit` 精确定位按钮。
- 后端 `/api/unit/{id}/detail` 响应中 `point.point_monitor` 字段包含最新缓存值,可用于初始渲染信号点颜色,无需等待 WebSocket 推送。
- `cargo check` 通过
- 日志轮转切换逻辑的后端测试通过
- `node --check web/js/logs.js` 通过