plc_control/docs/superpowers/plans/2026-03-25-dual-view-web.md

359 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 双视图 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` 端点。
---
## 当前布局(参考)
```
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行
```
## 目标布局
```
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行—— 系统事件(全宽)
```
## 文件清单
| 文件 | 操作 | 用途 |
|---|---|---|
| `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 布局切换 ✅ 已完成
**涉及文件:**
- 修改:`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`)。
- [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 推送。