diff --git a/.gitignore b/.gitignore index 00eecb3..ff52cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,8 @@ # OS files .DS_Store Thumbs.db + +.claude/ +.VSCodeCounter/ +cl.bat +col.bat diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e2e838 --- /dev/null +++ b/README.md @@ -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` + +## 当前实现特点 + +- 后端偏本地部署和现场控制使用 +- 控制状态保存在内存中,重启后重置 +- 前端和后端都围绕“实时控制 + 快速排障”设计 diff --git a/docs/superpowers/plans/2026-03-24-control-engine.md b/docs/superpowers/plans/2026-03-24-control-engine.md index 4a3d229..c9c7aee 100644 --- a/docs/superpowers/plans/2026-03-24-control-engine.md +++ b/docs/superpowers/plans/2026-03-24-control-engine.md @@ -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>>>, +- `accumulated_run_sec` 和 `display_acc_sec` 单位都是毫秒 +- 运行时状态保存在内存中,不持久化 -// 方法: -pub async fn get_or_create_notify(&self, unit_id: Uuid) -> Arc -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, sqlx::Error> -pub async fn get_equipment_by_unit_id(pool: &PgPool, unit_id: Uuid) -> Result, 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` 均已通过。 diff --git a/docs/superpowers/plans/2026-03-25-dual-view-web.md b/docs/superpowers/plans/2026-03-25-dual-view-web.md index 96147e1..710da73 100644 --- a/docs/superpowers/plans/2026-03-25-dual-view-web.md +++ b/docs/superpowers/plans/2026-03-25-dual-view-web.md @@ -1,358 +1,115 @@ # 双视图 Web UI 实现计划 -> **适用于代理执行:** 必须使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务执行。步骤使用复选框(`- [ ]`)语法跟踪进度。 +## 当前状态 -**目标:** 在顶部添加 **运维视图** 和 **配置视图** 两个标签页切换。运维视图以设备为核心,展示实时信号点状态(彩色信号点)及底部系统事件面板;配置视图在原有布局基础上,将底部中间面板替换为实时 SSE 日志流。 +该计划对应的双视图页面已经完成,本文档同步到当前实现,并补充最近的日志流轮转修复。 -**架构:** `
` 元素通过 CSS 类名(`grid-ops` / `grid-config`)控制面板显示。新建 `ops.js` 模块负责运维视图:加载所有单元的设备详情并渲染设备卡片,每张卡片包含 REM/RUN/FLT 三个信号点(彩色小圆点),卡片中的 DOM 元素注册到 `state.opsPointEls`(`Map`),WebSocket 处理器通过 `sigDotClass()` 实时更新信号点颜色。SSE 日志流(`/api/logs/stream`)仅在配置视图中启动,切换标签时启停。 +## 已完成范围 -**技术栈:** Vanilla JS ES 模块、CSS Grid、SSE(`EventSource`)、现有 WebSocket 基础设施、`/api/unit/{id}/detail` 端点。 +- 已实现顶部 `Ops / Config` 视图切换 +- 已实现运维视图单元侧栏和设备卡片区 +- 已实现配置视图日志流面板 +- 已实现基于 WebSocket 的信号灯和运行时状态更新 +- 已实现批量启动 / 批量停止自动控制按钮 +- 已实现 SSE 日志流接入 ---- +## 当前页面结构 -## 当前布局(参考) +### 运维视图 -``` -grid(3列 × 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`** - -`
` 中引入所有 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 -
-
- -
-
← 选择控制单元
-
-
-
-``` - -`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 三个角色,每行一个 `` 元素(`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 -
-

实时日志

-
-
-``` - -- [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` 通过