# 双视图 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` 端点。 --- ## 当前布局(参考) ``` 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行) ``` ## 目标布局 ``` 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`** `
` 中引入所有 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 推送。