diff --git a/docs/superpowers/plans/2026-03-24-control-engine.md b/docs/superpowers/plans/2026-03-24-control-engine.md index 1fcb975..4a3d229 100644 --- a/docs/superpowers/plans/2026-03-24-control-engine.md +++ b/docs/superpowers/plans/2026-03-24-control-engine.md @@ -1,38 +1,38 @@ -# Control Engine Implementation Plan +# 控制引擎实现计划 -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **适用于代理执行:** 必须使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务执行。步骤使用复选框(`- [ ]`)语法跟踪进度。 -**Goal:** Implement the automated control engine for coal feeder / distributor units, including state machine, fault/comm protection, runtime API and frontend control panels. +**目标:** 实现投煤器 / 布料机单元的自动控制引擎,包括状态机、故障/通信保护、运行时 API 及前端控制面板。 -**Architecture:** The engine spawns one async task per enabled unit (supervised by a 10s scanner). Each task drives the unit's state machine using `tokio::time::sleep_until` for phase timing and `tokio::sync::Notify` for instant wake-up when external state changes (auto enable/disable, fault ack). A 500ms fault-poll ticker runs inside each task's `wait_phase` helper so fault/comm status is still checked promptly during long phases. State is kept in `ControlRuntimeStore` (in-memory, never persisted). Frontend receives real-time updates via `WsMessage::UnitRuntimeChanged` — pushed **only on state transitions**, not every tick. +**架构:** 引擎为每个已启用的单元各启动一个异步任务(由10秒扫描器监督)。每个任务通过 `tokio::time::sleep_until` 控制阶段计时,通过 `tokio::sync::Notify` 在外部状态变化时(自动启停、故障确认)立即唤醒。状态保存在 `ControlRuntimeStore`(内存中,不持久化)。前端通过 `WsMessage::UnitRuntimeChanged` 实时接收更新——**仅在状态转换时推送**,不做周期性推送。 -**Tech Stack:** Rust/Axum backend, sqlx/PostgreSQL, tokio async, vanilla JS ES modules frontend. +**技术栈:** Rust/Axum 后端、sqlx/PostgreSQL、tokio 异步、Vanilla JS ES 模块前端。 --- -## File Map +## 文件清单 -| File | Action | Responsibility | -|------|--------|---------------| -| `src/control/runtime.rs` | ✅ Done | `UnitRuntime` struct + `ControlRuntimeStore` with `Notify` per unit | -| `src/control/command.rs` | ✅ Done | Shared `send_pulse_command()` helper | -| `src/control/engine.rs` | ✅ Done | Supervisor + per-unit async tasks + `wait_phase` | -| `src/control/validator.rs` | ✅ Done | Block manual commands when unit is fault/comm locked | -| `src/control/mod.rs` | ✅ Done | Exposes `command`, `engine`, `runtime`, `validator` | -| `src/event.rs` | ✅ Done | 7 `AppEvent` variants; `UnitStateChanged` fires but is **not** persisted to DB | -| `src/websocket.rs` | ✅ Done | `WsMessage::UnitRuntimeChanged` | -| `src/service/control.rs` | ✅ Done | `get_all_enabled_units`, `get_equipment_by_unit_id` | -| `src/handler/control.rs` | ✅ Done | `start_auto`, `stop_auto`, `batch_start_auto`, `batch_stop_auto`, `ack_fault`, `get_unit_runtime`; calls `notify_unit` after every state change | -| `src/main.rs` | ✅ Done | Routes for above endpoints | -| `web/js/state.js` | ✅ Done | `runtimes: new Map()` | -| `web/js/units.js` | ✅ Done | Runtime state badge, Auto Start/Stop, Ack Fault; shows `display_acc_sec` | -| `web/js/ops.js` | ✅ Done | Ops panel unit cards show runtime badge + `display_acc_sec` | -| `web/js/app.js` | ✅ Done | Handles `UnitRuntimeChanged` WS message | -| `web/styles.css` | ✅ Done | `.event-card { flex-shrink: 0 }` prevents text overlap under flex column | +| 文件 | 操作 | 职责 | +|------|------|------| +| `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 列表中文字重叠 | --- -## Current UnitRuntime Shape +## UnitRuntime 结构体(当前) ```rust // src/control/runtime.rs @@ -41,102 +41,102 @@ pub struct UnitRuntime { pub unit_id: Uuid, pub state: UnitRuntimeState, pub auto_enabled: bool, - pub accumulated_run_sec: i64, // internal accumulator (ms); do NOT display directly - pub display_acc_sec: i64, // snapshot at state-transition; use this for display + 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, } -// NOTE: elapsed-time fields (current_run_elapsed_sec, current_stop_elapsed_sec, -// distributor_run_elapsed_sec, last_tick_at) were removed in the event-driven -// refactor. Timing is now managed entirely by tokio::time::sleep_until inside -// the per-unit task. Do not re-add them. +// 注意:elapsed 字段(current_run_elapsed_sec、current_stop_elapsed_sec、 +// distributor_run_elapsed_sec、last_tick_at)已在事件驱动重构中移除。 +// 计时完全由单元任务内部的 tokio::time::sleep_until 管理,请勿重新添加。 ``` -`ControlRuntimeStore` adds: +`ControlRuntimeStore` 额外包含: ```rust notifiers: Arc>>>, -// Methods: +// 方法: pub async fn get_or_create_notify(&self, unit_id: Uuid) -> Arc -pub async fn notify_unit(&self, unit_id: Uuid) // call from handlers after state changes +pub async fn notify_unit(&self, unit_id: Uuid) // 每次状态变更后调用 ``` --- -## Engine Architecture (event-driven, 2026-03-26) +## 引擎架构(事件驱动,2026-03-26) ``` start() - └─ supervise() — interval 10s, spawns unit_task per enabled unit + └─ supervise() —— 10秒间隔,为每个启用单元启动 unit_task unit_task(unit_id) - ├─ load_equipment_maps — once at task start (cached for task lifetime) - ├─ fault_tick — interval 500ms, used inside wait_phase + ├─ load_equipment_maps —— 任务启动时加载一次(缓存至任务生命周期结束) + ├─ fault_tick —— 500ms 间隔,在 wait_phase 内部使用 └─ loop: - ├─ reload unit config (check still enabled) - ├─ check_fault_comm → push WS if changed - ├─ if !auto || fault || comm → select!(fault_tick | notify), continue - └─ match state: - Stopped → wait_phase(stop_time_sec) → start feeder → state=Running → push WS - Running → wait_phase(run_time_sec) → stop feeder → acc += run_time_sec - → if acc >= acc_time_sec: start distributor, state=DistributorRunning - → else: state=Stopped → push WS - DistributorRunning → wait_phase(bl_time_sec) → stop distributor → acc=0 → state=Stopped → push WS + ├─ 重新加载单元配置(检查是否仍启用) + ├─ 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) wait_phase(secs): deadline = now + secs loop: - select! { sleep_until(deadline) => return true - fault_tick.tick() => re-check fault/comm; if interrupted return false - notify.notified() => re-check fault/comm; if interrupted return false } + select! { sleep_until(deadline) => 返回 true(阶段正常完成) + fault_tick.tick() => 重新检查故障/通信;若中断返回 false + notify.notified() => 重新检查故障/通信;若中断返回 false } ``` -**Key invariants:** -- `accumulated_run_sec` is updated by **exactly** `run_time_sec * 1000` per completed cycle (no delta drift). -- `display_acc_sec` is a snapshot copied from `accumulated_run_sec` only at Running→Stopped or Running→DistributorRunning transitions. Frontend always reads `display_acc_sec`. -- WS is pushed **only** when something changes. No periodic push. -- `unit.state_changed` events are fired (for logging) but **not** written to the DB event table (too frequent). +**关键不变量:** +- `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` 事件仅用于日志记录,**不写入**数据库事件表(频率过高)。 --- -## Task 1: Extend UnitRuntime — ✅ DONE +## 任务一:扩展 UnitRuntime ✅ 已完成 -**Files:** `src/control/runtime.rs` +**文件:** `src/control/runtime.rs` -Fields as shown in "Current UnitRuntime Shape" above. `ControlRuntimeStore` includes the `notifiers` map with `get_or_create_notify` and `notify_unit` methods. +字段如上方"UnitRuntime 结构体"所示。`ControlRuntimeStore` 包含 `notifiers` 映射,提供 `get_or_create_notify` 和 `notify_unit` 方法。 --- -## Task 2: Create shared pulse-command helper — ✅ DONE +## 任务二:创建共享脉冲指令辅助函数 ✅ 已完成 -**Files:** `src/control/command.rs`, `src/control/mod.rs`, `src/handler/control.rs` +**文件:** `src/control/command.rs`、`src/control/mod.rs`、`src/handler/control.rs` -`send_pulse_command(connection_manager, point_id, value_type, pulse_ms)` writes high→delay→low. -`simulate_run_feedback(state, eq_id, running)` writes a fake run-feedback value in simulate mode. +`send_pulse_command(connection_manager, point_id, value_type, pulse_ms)` 写入高→延迟→低电平序列。 + +`simulate_run_feedback(state, eq_id, running)` 在模拟模式下写入虚拟运行反馈值(用于无真实 OPC UA 设备时的调试)。 --- -## Task 3: Add runtime-state checks to validator.rs — ✅ DONE +## 任务三:在 validator.rs 添加运行时状态检查 ✅ 已完成 -**Files:** `src/control/validator.rs` +**文件:** `src/control/validator.rs` -After existing REM/FLT/quality checks in `validate_manual_control`: +在 `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("Auto control is active; disable auto first", ...)); + return Err(ApiErr::Forbidden("自动控制已激活,请先停止自动控制", ...)); } if runtime.comm_locked { - return Err(ApiErr::Forbidden("Unit communication is locked", ...)); + return Err(ApiErr::Forbidden("单元通信已锁定", ...)); } if runtime.fault_locked { - return Err(ApiErr::Forbidden("Unit is fault locked", ...)); + return Err(ApiErr::Forbidden("单元处于故障锁定状态", ...)); } } } @@ -144,11 +144,11 @@ if let Some(unit_id) = equipment.unit_id { --- -## Task 4: Extend AppEvent with business events — ✅ DONE +## 任务四:扩展 AppEvent 业务事件 ✅ 已完成 -**Files:** `src/event.rs` +**文件:** `src/event.rs` -7 variants added: +新增 7 个变体: ```rust AutoControlStarted { unit_id: Uuid }, @@ -160,23 +160,23 @@ CommRecovered { unit_id: Uuid }, UnitStateChanged { unit_id: Uuid, from_state: String, to_state: String }, ``` -**`persist_event_if_needed` mapping:** +**`persist_event_if_needed` 映射:** -| Variant | DB? | event_type | -|---------|-----|-----------| +| 变体 | 写库? | event_type | +|------|--------|-----------| | `AutoControlStarted` | ✅ | `unit.auto_control_started` | | `AutoControlStopped` | ✅ | `unit.auto_control_stopped` | -| `FaultLocked` | ✅ | `unit.fault_locked` (level: error) | +| `FaultLocked` | ✅ | `unit.fault_locked`(level: error)| | `FaultAcked` | ✅ | `unit.fault_acked` | -| `CommLocked` | ✅ | `unit.comm_locked` (level: warn) | +| `CommLocked` | ✅ | `unit.comm_locked`(level: warn)| | `CommRecovered` | ✅ | `unit.comm_recovered` | -| `UnitStateChanged` | ❌ | — (too frequent; fires every cycle) | +| `UnitStateChanged` | ❌ | —(频率过高,每周期触发)| --- -## Task 5: Add WsMessage::UnitRuntimeChanged — ✅ DONE +## 任务五:添加 WsMessage::UnitRuntimeChanged ✅ 已完成 -**Files:** `src/websocket.rs` +**文件:** `src/websocket.rs` ```rust UnitRuntimeChanged(crate::control::runtime::UnitRuntime), @@ -184,9 +184,9 @@ UnitRuntimeChanged(crate::control::runtime::UnitRuntime), --- -## Task 6: Add service helpers — ✅ DONE +## 任务六:添加 service 辅助函数 ✅ 已完成 -**Files:** `src/service/control.rs` +**文件:** `src/service/control.rs` ```rust pub async fn get_all_enabled_units(pool: &PgPool) -> Result, sqlx::Error> @@ -195,26 +195,26 @@ pub async fn get_equipment_by_unit_id(pool: &PgPool, unit_id: Uuid) -> Result **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **适用于代理执行:** 必须使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务执行。步骤使用复选框(`- [ ]`)语法跟踪进度。 -**Goal:** Add a top-level Tab switch between an **运维视图** (operational, equipment-first) and the existing **配置视图** (configuration, point-first), with the ops view showing realtime signal values on equipment cards and a system-events panel at the bottom, while the config view replaces the events panel with a realtime SSE log stream. +**目标:** 在顶部添加 **运维视图** 和 **配置视图** 两个标签页切换。运维视图以设备为核心,展示实时信号点状态(彩色信号点)及底部系统事件面板;配置视图在原有布局基础上,将底部中间面板替换为实时 SSE 日志流。 -**Architecture:** Two CSS grid classes (`grid-ops` / `grid-config`) on `
` control which panels are visible. A new `ops.js` module drives the ops view: it calls `GET /api/unit/{id}/detail` on unit selection, renders equipment cards with per-role signal cells, and registers point DOM elements in `state.opsPointEls` so the existing WebSocket handler can push live updates. The SSE log stream (`/api/logs/stream`) is revived as a separate panel shown only in config view, started/stopped on tab switch. +**架构:** `
` 元素通过 CSS 类名(`grid-ops` / `grid-config`)控制面板显示。新建 `ops.js` 模块负责运维视图:加载所有单元的设备详情并渲染设备卡片,每张卡片包含 REM/RUN/FLT 三个信号点(彩色小圆点),卡片中的 DOM 元素注册到 `state.opsPointEls`(`Map`),WebSocket 处理器通过 `sigDotClass()` 实时更新信号点颜色。SSE 日志流(`/api/logs/stream`)仅在配置视图中启动,切换标签时启停。 -**Tech Stack:** Vanilla JS ES modules, CSS Grid, SSE (`EventSource`), existing WebSocket infrastructure, existing `/api/unit/{id}/detail` endpoint. +**技术栈:** Vanilla JS ES 模块、CSS Grid、SSE(`EventSource`)、现有 WebSocket 基础设施、`/api/unit/{id}/detail` 端点。 --- -## Current layout (reference) +## 当前布局(参考) ``` -grid (3 cols × 2 rows): - top-left → equipment-panel.html (col 1, row 1) - top-right → points-panel.html (col 2-3, row 1) - bottom-left → source-panel.html (col 1, row 2) — units + sources stacked - bottom-mid → logs-panel.html (col 2, row 2) — system events - bottom-right→ chart-panel.html (col 3, row 2) +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行) ``` -## Target layouts +## 目标布局 ``` -grid-config (same as current): - top-left → equipment-panel (col 1, row 1) - top-right → points-panel (col 2-3, row 1) - bottom-left → source-panel (col 1, row 2) - bottom-mid → log-stream-panel (NEW) (col 2, row 2) — SSE logs - bottom-right→ chart-panel (col 3, row 2) +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 (new): - top → ops-panel (NEW) (col 1-3, row 1) — unit sidebar + equipment cards - bottom → logs-panel (col 1-3, row 2) — system events (full width) +grid-ops(新布局): + 上方 → ops-panel【新建】 (第1-2列,第1行)—— 单元侧栏 + 设备卡片 + 下方 → logs-panel (第1-2列,第2行)—— 系统事件(全宽) ``` -## File Map +## 文件清单 -| File | Action | Purpose | +| 文件 | 操作 | 用途 | |---|---|---| -| `web/html/topbar.html` | Modify | Add `#tabOps` / `#tabConfig` tab buttons | -| `web/html/ops-panel.html` | **Create** | Ops view: `#opsUnitList` sidebar + `#opsEquipmentArea` card grid | -| `web/html/log-stream-panel.html` | **Create** | Config view bottom-mid: SSE log stream (`#logView`) | -| `web/index.html` | Modify | Add new partials, version bump | -| `web/js/ops.js` | **Create** | Load unit detail, render equipment cards, expose `updateOpsPoint()` | -| `web/js/state.js` | Modify | Add `activeView`, `opsPointEls`, `logSource` | -| `web/js/dom.js` | Modify | Add refs: `tabOps`, `tabConfig`, `opsUnitList`, `opsEquipmentArea`, `logView` | -| `web/js/logs.js` | Modify | Restore `startLogs` / `stopLogs`; call `updateOpsPoint` in WS handler | -| `web/js/app.js` | Modify | Tab switch logic, bind ops unit-click, start/stop log stream on switch | -| `web/styles.css` | Modify | Tab styles, `grid-ops`, `grid-config`, ops card + signal row styles | +| `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`、设备卡片与信号点样式 | --- -## Task 1: Tab scaffold + CSS layout switching +## 任务一:标签脚手架 + CSS 布局切换 ✅ 已完成 -**Files:** -- Modify: `web/html/topbar.html` -- Modify: `web/index.html` -- Modify: `web/js/state.js` -- Modify: `web/js/dom.js` -- Modify: `web/js/app.js` -- Modify: `web/styles.css` +**涉及文件:** +- 修改:`web/html/topbar.html` +- 修改:`web/index.html` +- 修改:`web/js/state.js` +- 修改:`web/js/dom.js` +- 修改:`web/js/app.js` +- 修改:`web/styles.css` -- [ ] **Step 1: Add tab buttons to topbar** +- [x] **步骤 1:在顶栏添加标签按钮** -Replace `web/html/topbar.html` with: +`web/html/topbar.html` 中添加 `.tab-bar`(含 `#tabOps` / `#tabConfig`)及批量自动控制按钮(`#batchStartAutoBtn` / `#batchStopAutoBtn`)。 -```html -
-
PLC Control
-
- - -
-
- - -
Ready
-
-
-``` +- [x] **步骤 2:向 `web/styles.css` 添加标签与网格 CSS** -- [ ] **Step 2: Add tab + grid CSS to `web/styles.css`** +添加 `.tab-bar`、`.tab-btn`、`.tab-btn.active` 样式;将原有 `.grid` 替换为 `.grid-ops` 和 `.grid-config`,分别定义列、行及面板 `grid-column/row` 赋值。 -After the existing `.topbar-actions` block, add: - -```css -/* ── Tabs ───────────────────────────────────────── */ - -.tab-bar { - display: flex; - gap: 2px; -} - -.tab-btn { - padding: 0 16px; - height: 28px; - font-size: 13px; - font-weight: 500; - background: transparent; - border: 1px solid var(--border); - color: var(--text-2); - cursor: pointer; -} - -.tab-btn.active { - background: var(--accent); - border-color: var(--accent); - color: #fff; -} -``` - -Replace the existing `.grid` block (lines 82–94) with: - -```css -.grid-ops, -.grid-config { - display: grid; - gap: 1px; - height: calc(100vh - var(--topbar-h)); -} - -.grid-config { - grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr); - grid-template-rows: 1fr 380px; -} - -.grid-ops { - grid-template-columns: 260px minmax(0, 1fr); - grid-template-rows: 1fr 260px; -} - -/* config view slot assignments */ -.grid-config .panel.top-left { grid-column: 1; grid-row: 1; } -.grid-config .panel.top-right { grid-column: 2 / 4; grid-row: 1; } -.grid-config .panel.bottom-left { grid-column: 1; grid-row: 2; } -.grid-config .panel.bottom-mid { grid-column: 2; grid-row: 2; } -.grid-config .panel.bottom-right{ grid-column: 3; grid-row: 2; } - -/* ops view slot assignments */ -.grid-ops .panel.ops-main { grid-column: 1 / 3; grid-row: 1; } -.grid-ops .panel.ops-bottom { grid-column: 1 / 3; grid-row: 2; } -``` - -- [ ] **Step 3: Add `activeView` and `logSource` to `web/js/state.js`** +- [x] **步骤 3:向 `web/js/state.js` 添加新字段** ```js -export const state = { - // ... existing fields ... - activeView: "ops", // "ops" | "config" - opsPointEls: new Map(), // point_id -> { valueEl, qualityEl } - logSource: null, -}; +activeView: "ops", // "ops" | "config" +opsPointEls: new Map(), // point_id -> { dotEl } +logSource: null, +selectedOpsUnitId: null, ``` -- [ ] **Step 4: Add DOM refs in `web/js/dom.js`** +- [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"), ``` -- [ ] **Step 5: Add `switchView` function + wiring in `web/js/app.js`** +- [x] **步骤 5:在 `web/js/app.js` 中添加 `switchView` 函数并绑定事件** -Add at top of `app.js`: -```js -import { startOps, handleOpsUnitClick } from "./ops.js"; -import { startLogs, stopLogs } from "./logs.js"; -``` - -Add `switchView` function before `bindEvents`: ```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"); - - // config-only panels - ["top-left", "top-right", "bottom-left", "bottom-right"].forEach((cls) => { - const el = main.querySelector(`.panel.${cls}`); - if (el) el.classList.toggle("hidden", view === "ops"); - }); - // bottom-mid is log-stream in config, hidden in ops - const logStreamPanel = main.querySelector(".panel.bottom-mid"); - if (logStreamPanel) logStreamPanel.classList.toggle("hidden", view === "ops"); - - // ops-only panels - const opsMain = main.querySelector(".panel.ops-main"); - const opsBottom = main.querySelector(".panel.ops-bottom"); - if (opsMain) opsMain.classList.toggle("hidden", view === "config"); - if (opsBottom) opsBottom.classList.toggle("hidden", view === "config"); - - if (view === "config") { - startLogs(); - } else { - stopLogs(); - } + // 显示/隐藏配置视图专属面板(top-left/top-right/bottom-left/bottom-right/bottom-mid) + // 显示/隐藏运维视图专属面板(ops-main/ops-bottom) + if (view === "config") startLogs(); else stopLogs(); } ``` -In `bindEvents`, add: +`bindEvents` 中添加: ```js dom.tabOps.addEventListener("click", () => switchView("ops")); dom.tabConfig.addEventListener("click", () => switchView("config")); ``` -In `bootstrap`, call after `bindEvents()`: +`bootstrap` 中调用: ```js -switchView("ops"); // default to ops view +switchView("ops"); // 默认进入运维视图 ``` -- [ ] **Step 6: Update `web/index.html` — add new partials, default grid class, version bump** +- [x] **步骤 6:更新 `web/index.html`** -```html -
-
-
-
-
-
-
-
-
-``` - -Bump version: `?v=20260325a` on both CSS and JS. - -- [ ] **Step 7: Verify panels show/hide correctly** - -Open browser, click 运维 / 配置 tabs — panels should swap. Layout may be unstyled; that's fine for now. - -- [ ] **Step 8: Commit** - -```bash -git add web/html/topbar.html web/index.html web/js/state.js web/js/dom.js web/js/app.js web/styles.css -git commit -m "feat(web): add tab scaffold for ops/config dual-view layout" -``` +`
` 中引入所有 partial(含新建的 ops-panel.html、log-stream-panel.html),并更新 CSS/JS 版本号。 --- -## Task 2: Ops panel HTML + CSS skeleton +## 任务二:运维面板 HTML + CSS 骨架 ✅ 已完成 -**Files:** -- Create: `web/html/ops-panel.html` -- Modify: `web/styles.css` +**涉及文件:** +- 新建:`web/html/ops-panel.html` +- 修改:`web/styles.css` -- [ ] **Step 1: Create `web/html/ops-panel.html`** +- [x] **步骤 1:新建 `web/html/ops-panel.html`** ```html
@@ -270,479 +146,178 @@ git commit -m "feat(web): add tab scaffold for ops/config dual-view layout"
``` -Note: `logs-panel.html` already has `id="eventList"` and class structure. Add `ops-bottom` class to it in HTML: +`web/html/logs-panel.html` 增加 `ops-bottom` class,使其在运维视图中作为底部全宽面板。 -In `web/html/logs-panel.html`, change: -```html -
-``` +- [x] **步骤 2:向 `web/styles.css` 添加运维视图 CSS** -- [ ] **Step 2: Add ops layout CSS to `web/styles.css`** +`.ops-layout`(flex 横向)、`.ops-unit-sidebar`(固定宽度)、`.ops-unit-list`(可滚动)、`.ops-equipment-area`(flex-wrap 卡片区)。 -```css -/* ── Ops View ───────────────────────────────────── */ +设备卡片相关类:`.ops-eq-card`、`.ops-eq-card-head`、`.ops-signal-rows`、`.ops-signal-row`、`.ops-signal-label`、`.ops-eq-card-actions`。 -.ops-layout { - display: flex; - min-height: 0; - flex: 1 1 auto; - overflow: hidden; -} +单元列表项相关类:`.ops-unit-item`、`.ops-unit-item-name`、`.ops-unit-item-meta`、`.ops-unit-item-actions`。 -.ops-unit-sidebar { - width: 260px; - flex-shrink: 0; - border-right: 1px solid var(--border); - display: flex; - flex-direction: column; - min-height: 0; - overflow: hidden; -} - -.ops-unit-list { - flex: 1 1 auto; - overflow-y: auto; -} - -.ops-equipment-area { - flex: 1 1 auto; - overflow: auto; - padding: 12px; - display: flex; - flex-wrap: wrap; - align-content: flex-start; - gap: 12px; -} - -.ops-placeholder { - padding: 20px; -} - -/* Equipment ops card */ -.ops-eq-card { - width: 220px; - border: 1px solid var(--border); - background: var(--surface); - display: flex; - flex-direction: column; - gap: 0; -} - -.ops-eq-card-head { - padding: 8px 10px 6px; - border-bottom: 1px solid var(--border-light); - display: flex; - align-items: center; - gap: 6px; -} - -.ops-eq-card-head strong { - flex: 1; - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ops-signal-rows { - padding: 6px 10px; - display: flex; - flex-direction: column; - gap: 3px; -} - -.ops-signal-row { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; -} - -.ops-signal-label { - width: 36px; - color: var(--text-3); - font-size: 11px; - text-transform: uppercase; - flex-shrink: 0; -} - -.ops-signal-value { - flex: 1; - font-weight: 500; -} - -.ops-eq-card-actions { - padding: 6px 10px 8px; - display: flex; - gap: 6px; - border-top: 1px solid var(--border-light); -} - -.ops-eq-card-actions button { - flex: 1; - padding: 3px 0; - font-size: 12px; -} - -/* ops unit list item */ -.ops-unit-item { - padding: 8px 10px; - cursor: pointer; - border-bottom: 1px solid var(--border-light); - display: flex; - flex-direction: column; - gap: 3px; -} - -.ops-unit-item:hover { background: var(--accent-bg); } -.ops-unit-item.selected { - background: var(--accent-bg); - border-left: 3px solid var(--accent); -} - -.ops-unit-item-name { - font-size: 13px; - font-weight: 600; -} - -.ops-unit-item-meta { - font-size: 11px; - color: var(--text-3); - display: flex; - gap: 6px; -} -``` - -- [ ] **Step 3: Verify layout renders correctly (empty, no JS yet)** - -Refresh browser in ops tab — sidebar and card area should be visible with placeholder text. - -- [ ] **Step 4: Commit** - -```bash -git add web/html/ops-panel.html web/html/logs-panel.html web/styles.css -git commit -m "feat(web): add ops panel HTML skeleton and layout CSS" -``` +信号点相关类:`.sig-dot`(灰色默认)、`.sig-dot.sig-on`(绿色)、`.sig-dot.sig-fault`(红色)、`.sig-dot.sig-warn`(黄色)。 --- -## Task 3: ops.js — unit list + equipment card rendering +## 任务三:ops.js —— 单元列表 + 设备卡片渲染 ✅ 已完成 -**Files:** -- Create: `web/js/ops.js` -- Modify: `web/js/app.js` +**涉及文件:** +- 新建:`web/js/ops.js` +- 修改:`web/js/app.js`、`web/js/units.js` -The ops view unit list is separate from the config view's `#unitList`. When a unit is clicked, `GET /api/unit/{id}/detail` returns the nested structure and we render equipment cards. +### 实际实现说明 -Equipment card signal roles to display (in order): `rem`, `run`, `flt`. Show label + quality dot + value. Start/Stop buttons only for `coal_feeder` / `distributor` kind. +运维视图在**初始加载时一次性加载所有单元的所有设备卡片**(`loadAllEquipmentCards`),而非等待点击单元后再加载。点击某个单元会过滤只展示该单元的设备;再次点击同一单元则取消过滤并恢复全部展示。 -- [ ] **Step 1: Create `web/js/ops.js`** +信号点使用**彩色小圆点**(`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 -import { apiFetch } from "./api.js"; -import { dom } from "./dom.js"; -import { state } from "./state.js"; - -const SIGNAL_ROLES = ["rem", "run", "flt"]; -const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" }; - -export function renderOpsUnits() { - if (!dom.opsUnitList) return; - dom.opsUnitList.innerHTML = ""; - - if (!state.units.length) { - dom.opsUnitList.innerHTML = '
暂无控制单元
'; - return; - } - - state.units.forEach((unit) => { - const runtime = state.runtimes.get(unit.id); - const item = document.createElement("div"); - item.className = `ops-unit-item${state.selectedOpsUnitId === unit.id ? " selected" : ""}`; - item.innerHTML = ` -
${unit.code} / ${unit.name}
-
- ${unit.enabled ? "EN" : "DIS"} - ${runtime ? `Acc ${Math.floor(runtime.accumulated_run_sec / 1000)}s` : ""} -
- `; - item.addEventListener("click", () => selectOpsUnit(unit.id)); - dom.opsUnitList.appendChild(item); - }); -} - -async function selectOpsUnit(unitId) { - state.selectedOpsUnitId = unitId === state.selectedOpsUnitId ? null : unitId; - renderOpsUnits(); - - if (!state.selectedOpsUnitId) { - dom.opsEquipmentArea.innerHTML = '
← 选择控制单元
'; - state.opsPointEls.clear(); - return; - } - - dom.opsEquipmentArea.innerHTML = '
加载中...
'; - state.opsPointEls.clear(); - - const detail = await apiFetch(`/api/unit/${state.selectedOpsUnitId}/detail`); - renderOpsEquipments(detail.equipments || []); -} - -function renderOpsEquipments(equipments) { - dom.opsEquipmentArea.innerHTML = ""; - if (!equipments.length) { - dom.opsEquipmentArea.innerHTML = '
该单元下暂无设备
'; - return; - } - - equipments.forEach((eq) => { - const runtime = state.runtimes.get(state.selectedOpsUnitId); - const card = document.createElement("div"); - card.className = "ops-eq-card"; - - // Build role → point_id map - const roleMap = {}; - (eq.points || []).forEach((p) => { - if (p.signal_role) roleMap[p.signal_role] = p; - }); - - // Signal rows HTML (placeholders; WS will fill values) - const signalRowsHtml = SIGNAL_ROLES.map((role) => { - const point = roleMap[role]; - if (!point) return ""; - return ` -
- ${ROLE_LABELS[role] || role} - ? - -- -
`; - }).join(""); - - const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor"; - - card.innerHTML = ` -
- ${eq.code} - ${eq.kind || "--"} -
-
${signalRowsHtml || '无绑定信号'}
- ${canControl ? '
' : ""} - `; - - if (canControl) { - const actions = card.querySelector(".ops-eq-card-actions"); - const startBtn = document.createElement("button"); - startBtn.className = "secondary"; - startBtn.textContent = "Start"; - startBtn.addEventListener("click", () => - apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {}) - ); - const stopBtn = document.createElement("button"); - stopBtn.className = "danger"; - stopBtn.textContent = "Stop"; - stopBtn.addEventListener("click", () => - apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {}) - ); - actions.append(startBtn, stopBtn); - } - - dom.opsEquipmentArea.appendChild(card); - - // Register DOM elements for WS updates - SIGNAL_ROLES.forEach((role) => { - const point = roleMap[role]; - if (!point) return; - const valueEl = card.querySelector(`[data-ops-value="${point.id}"]`); - const qualityEl = card.querySelector(`[data-ops-quality="${point.id}"]`); - if (valueEl && qualityEl) { - state.opsPointEls.set(point.id, { valueEl, qualityEl }); - } - }); - }); -} - 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(() => {}); + }); } ``` -- [ ] **Step 2: Add `selectedOpsUnitId` to `web/js/state.js`** +**`syncEquipmentButtonsForUnit(unitId, autoEnabled)`**(导出) + +WS 收到 `UnitRuntimeChanged` 时调用,同步设备卡片中 Start/Stop 按钮的 `disabled` 状态(避免重新渲染整个卡片区): ```js -selectedOpsUnitId: null, +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 ? "自动控制运行中,请先停止自动" : ""; + }); + }); +} ``` -- [ ] **Step 3: Wire ops into `web/js/app.js`** +#### app.js 接入 -Add import: ```js -import { startOps, renderOpsUnits } from "./ops.js"; -``` +import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js"; -In `bootstrap`, add after `loadUnits`: -```js -await withStatus(loadUnits()); // already exists -startOps(); // initialise ops unit list -``` +// bootstrap 中: +await withStatus(loadUnits()); +startOps(); -Also update the `equipments-updated` listener to also call `renderOpsUnits`: -```js +// 事件监听: document.addEventListener("equipments-updated", () => { renderUnits(); renderOpsUnits(); }); -``` - -After `loadUnits()` is called anywhere (e.g., `refreshUnitBtn`), `renderOpsUnits()` should also be triggered. Simplest: call `renderOpsUnits()` inside `loadUnits()` in `units.js` — add at end of that function: - -In `web/js/units.js`, at end of `loadUnits()`: -```js -// notify ops view -document.dispatchEvent(new Event("units-loaded")); -``` - -In `web/js/app.js`: -```js -document.addEventListener("units-loaded", renderOpsUnits); -``` - -- [ ] **Step 4: Verify unit list renders and card area populates on click** - -Click 运维 tab → unit list shows → click a unit → equipment cards appear with signal row placeholders. - -- [ ] **Step 5: Commit** - -```bash -git add web/js/ops.js web/js/state.js web/js/app.js web/js/units.js -git commit -m "feat(web): ops view unit list and equipment card rendering" +document.addEventListener("units-loaded", () => { + renderOpsUnits(); + if (!state.selectedOpsUnitId) loadAllEquipmentCards(); +}); ``` --- -## Task 4: Realtime signal values in ops cards +## 任务四:运维卡片信号点实时更新 ✅ 已完成 -**Files:** -- Modify: `web/js/logs.js` +**涉及文件:** +- 修改:`web/js/logs.js` -The WebSocket `PointNewValue` handler already updates `state.pointEls`. Add a second lookup for `state.opsPointEls`. - -- [ ] **Step 1: Update WebSocket handler in `web/js/logs.js`** - -In the `PointNewValue` branch, after the existing `state.pointEls` block: +在 `startPointSocket` 的 WebSocket `PointNewValue` 分支中,添加运维视图信号点更新逻辑: ```js -if (payload.type === "PointNewValue" || payload.type === "point_new_value") { - const data = payload.data; - - // config view point table - const entry = state.pointEls.get(data.point_id); - if (entry) { - entry.value.textContent = formatValue(data); - entry.quality.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`; - entry.quality.textContent = (data.quality || "unknown").toUpperCase(); - entry.time.textContent = data.timestamp || "--"; - } - - // ops view signal cell - const opsEntry = state.opsPointEls.get(data.point_id); - if (opsEntry) { - opsEntry.valueEl.textContent = formatValue(data); - opsEntry.qualityEl.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`; - opsEntry.qualityEl.textContent = (data.quality || "unknown").toUpperCase(); - } - - if (state.chartPointId === data.point_id) { - appendChartPoint(data); - } - return; +// 运维视图信号点 +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); + }); } ``` -Also update `UnitRuntimeChanged` to re-render ops unit list: +`UnitRuntimeChanged` 分支同步更新运维单元列表和设备按钮状态: ```js if (payload.type === "UnitRuntimeChanged") { const runtime = payload.data; state.runtimes.set(runtime.unit_id, runtime); renderUnits(); - // lazy import to avoid circular dep - import("./ops.js").then(({ renderOpsUnits }) => renderOpsUnits()); + import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => { + renderOpsUnits(); + syncEquipmentButtonsForUnit(runtime.unit_id, runtime.auto_enabled); + }); return; } ``` -- [ ] **Step 2: Verify realtime updates** - -With a live OPC UA source connected, open ops view, select a unit — signal cells should show live quality badges and values updating in real time. - -- [ ] **Step 3: Commit** - -```bash -git add web/js/logs.js -git commit -m "feat(web): ops card signal cells update from WebSocket PointNewValue" -``` +> 注意:使用动态 `import("./ops.js")` 避免循环依赖(`ops.js` → `logs.js` → `ops.js`)。 --- -## Task 5: Log stream panel for config view +## 任务五:配置视图的日志流面板 ✅ 已完成 -**Files:** -- Create: `web/html/log-stream-panel.html` -- Modify: `web/js/logs.js` -- Modify: `web/js/dom.js` -- Modify: `web/js/app.js` +**涉及文件:** +- 新建:`web/html/log-stream-panel.html` +- 修改:`web/js/logs.js` +- 修改:`web/js/dom.js` -Restore the SSE `EventSource` log stream, but only active when in config view. The `startLogs` / `stopLogs` functions are called by `switchView` in `app.js` (already wired in Task 1 Step 5). - -- [ ] **Step 1: Create `web/html/log-stream-panel.html`** +- [x] **步骤 1:新建 `web/html/log-stream-panel.html`** ```html
-
-

实时日志

-
+

实时日志

``` -- [ ] **Step 2: Restore `startLogs` / `stopLogs` in `web/js/logs.js`** - -Add before `startPointSocket`: +- [x] **步骤 2:在 `web/js/logs.js` 中实现 `startLogs` / `stopLogs`** ```js -function escapeHtml(text) { - return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); -} - -function parseLogLine(line) { - const trimmed = line.trim(); - if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null; - try { return JSON.parse(trimmed); } catch { return null; } -} - -export function appendLog(line) { - if (!dom.logView) return; - const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10; - const div = document.createElement("div"); - const parsed = parseLogLine(line); - if (!parsed) { - div.className = "log-line"; - div.textContent = line; - } else { - const levelRaw = (parsed.level || "").toString(); - const level = levelRaw.toLowerCase(); - div.className = `log-line${level ? ` level-${level}` : ""}`; - div.innerHTML = [ - `${escapeHtml(levelRaw || "LOG")}`, - parsed.timestamp ? ` ${escapeHtml(parsed.timestamp)}` : "", - parsed.target ? ` ${escapeHtml(parsed.target)}` : "", - `${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}`, - ].join(""); - } - dom.logView.appendChild(div); - if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight; -} - export function startLogs() { if (state.logSource) return; state.logSource = new EventSource("/api/logs/stream"); @@ -761,77 +336,23 @@ export function stopLogs() { } ``` -- [ ] **Step 3: Add `logView` to `web/js/dom.js`** (already added in Task 1 Step 4 — verify it's present) - -- [ ] **Step 4: Verify config view shows SSE log stream** - -Click 配置 tab → bottom-middle panel should show "实时日志" with live log lines streaming. Click 运维 tab → SSE connection closes. - -- [ ] **Step 5: Commit** - -```bash -git add web/html/log-stream-panel.html web/js/logs.js web/js/dom.js -git commit -m "feat(web): restore SSE log stream panel in config view" -``` +`startLogs()` 是幂等的(有 `if (state.logSource) return` 守卫),可安全重复调用。 --- -## Task 6: Final wiring, cleanup and polish +## 任务六:收尾、清理与样式完善 ✅ 已完成 -**Files:** -- Modify: `web/styles.css` (log panel, minor tweaks) -- Modify: `web/js/units.js` (dispatch units-loaded) -- Modify: `web/index.html` (version bump) - -- [ ] **Step 1: Add log panel CSS** (if not already in styles.css from previous work) - -Verify `.log`, `.log-line`, `.level-info`, `.level-warn`, `.level-error` styles exist. If not, add: - -```css -.log { - flex: 1 1 auto; - overflow-y: auto; - font-family: monospace; - font-size: 12px; - padding: 4px 8px; -} - -.log-line { padding: 1px 0; border-bottom: 1px solid var(--border-light); } -.log-line .level { font-weight: 700; margin-right: 6px; } -.log-line.level-error { color: var(--danger); } -.log-line.level-warn { color: var(--warning); } -.log-line.level-info { color: var(--text-2); } -.log-line .message { color: var(--text); } -``` - -- [ ] **Step 2: Bump version in `web/index.html`** - -Change `?v=20260325a` → `?v=20260325b` on both CSS and JS links. - -- [ ] **Step 3: Final verification checklist** - -- [ ] 运维 tab: unit list renders, click unit → equipment cards appear -- [ ] Equipment cards show REM / RUN / FLT rows with live values -- [ ] Start/Stop buttons work (coal_feeder / distributor only) -- [ ] `UnitRuntimeChanged` WS message updates ops unit list badges -- [ ] 配置 tab: all existing panels visible (equipment, points, sources, chart) -- [ ] 配置 tab bottom-mid shows SSE log stream, lines append in real time -- [ ] Switching tabs starts/stops SSE correctly (no duplicate connections) -- [ ] 配置 tab events/chart/points work as before - -- [ ] **Step 4: Final commit** - -```bash -git add web/styles.css web/js/units.js web/index.html -git commit -m "feat(web): dual-view UI complete — ops cards + config log stream" -``` +- [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 日志流 --- -## Notes for implementer +## 实现者注意事项 -- `state.opsPointEls` is cleared and rebuilt every time a different unit is selected in ops view — no stale references. -- The lazy `import("./ops.js")` in `logs.js` for `UnitRuntimeChanged` avoids a circular dependency (`ops.js` → `logs.js` → `ops.js`). Alternatively, expose a `document.dispatchEvent(new Event("unit-runtime-changed"))` and listen in `ops.js`. -- The ops view does **not** reload `state.equipments` separately — it uses the `/api/unit/{id}/detail` response which is self-contained. -- `startLogs()` is idempotent (guards with `if (state.logSource) return`), so double-calling is safe. -- Backend log CSS classes: the existing styles from before the log removal commit should still be in `styles.css`. If they were removed, add them back per Task 6 Step 1. +- `state.opsPointEls` 在每次重新渲染设备卡片时清空重建,不存在陈旧引用。 +- `syncEquipmentButtonsForUnit` 仅更新按钮的 `disabled` 状态,避免每次运行时更新都重渲染整个卡片区。 +- 运维视图默认展示**所有单元的所有设备**,点击单元后过滤;取消选择后恢复全部展示。 +- 设备卡片头部 `data-unit-id` 属性供 `syncEquipmentButtonsForUnit` 精确定位按钮。 +- 后端 `/api/unit/{id}/detail` 响应中 `point.point_monitor` 字段包含最新缓存值,可用于初始渲染信号点颜色,无需等待 WebSocket 推送。 diff --git a/web/js/logs.js b/web/js/logs.js index fdf55ad..83ae7c9 100644 --- a/web/js/logs.js +++ b/web/js/logs.js @@ -16,7 +16,7 @@ function parseLogLine(line) { try { return JSON.parse(trimmed); } catch { return null; } } -export function appendLog(line) { +function appendLog(line) { if (!dom.logView) return; const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10; const div = document.createElement("div"); diff --git a/web/js/ops.js b/web/js/ops.js index 2627ee9..2456a02 100644 --- a/web/js/ops.js +++ b/web/js/ops.js @@ -1,6 +1,5 @@ import { apiFetch } from "./api.js"; import { dom } from "./dom.js"; -import { formatValue } from "./points.js"; import { state } from "./state.js"; import { loadUnits } from "./units.js"; diff --git a/web/js/state.js b/web/js/state.js index a36d6c3..26d79cf 100644 --- a/web/js/state.js +++ b/web/js/state.js @@ -22,7 +22,7 @@ export const state = { apiDocLoaded: false, runtimes: new Map(), // unit_id -> UnitRuntime activeView: "ops", // "ops" | "config" - opsPointEls: new Map(), // point_id -> { valueEl, qualityEl } + opsPointEls: new Map(), // point_id -> { dotEl } logSource: null, selectedOpsUnitId: null, };