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

14 KiB
Raw Blame History

双视图 Web UI 实现计划

适用于代理执行: 必须使用 superpowers:subagent-driven-development推荐或 superpowers:executing-plans 逐任务执行。步骤使用复选框(- [ ])语法跟踪进度。

目标: 在顶部添加 运维视图配置视图 两个标签页切换。运维视图以设备为核心,展示实时信号点状态(彩色信号点)及底部系统事件面板;配置视图在原有布局基础上,将底部中间面板替换为实时 SSE 日志流。

架构: <main> 元素通过 CSS 类名(grid-ops / grid-config)控制面板显示。新建 ops.js 模块负责运维视图:加载所有单元的设备详情并渲染设备卡片,每张卡片包含 REM/RUN/FLT 三个信号点(彩色小圆点),卡片中的 DOM 元素注册到 state.opsPointElsMap<point_id, { dotEl }>WebSocket 处理器通过 sigDotClass() 实时更新信号点颜色。SSE 日志流(/api/logs/stream)仅在配置视图中启动,切换标签时启停。

技术栈: Vanilla JS ES 模块、CSS Grid、SSEEventSource)、现有 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 修改 添加 activeViewopsPointElslogSourceselectedOpsUnitId
web/js/dom.js 修改 添加引用:tabOpstabConfigbatchStartAutoBtnbatchStopAutoBtnopsUnitListopsEquipmentArealogView
web/js/logs.js 修改 添加 startLogs / stopLogs;在 WS 处理器中更新 opsPointEls 信号点
web/js/app.js 修改 标签切换逻辑、监听 units-loaded 事件、启动 ops 视图
web/styles.css 修改 标签样式、grid-opsgrid-config、设备卡片与信号点样式

任务一:标签脚手架 + CSS 布局切换 已完成

涉及文件:

  • 修改:web/html/topbar.html

  • 修改:web/index.html

  • 修改:web/js/state.js

  • 修改:web/js/dom.js

  • 修改:web/js/app.js

  • 修改:web/styles.css

  • 步骤 1在顶栏添加标签按钮

web/html/topbar.html 中添加 .tab-bar(含 #tabOps / #tabConfig)及批量自动控制按钮(#batchStartAutoBtn / #batchStopAutoBtn)。

  • 步骤 2web/styles.css 添加标签与网格 CSS

添加 .tab-bar.tab-btn.tab-btn.active 样式;将原有 .grid 替换为 .grid-ops.grid-config,分别定义列、行及面板 grid-column/row 赋值。

  • 步骤 3web/js/state.js 添加新字段
activeView: "ops",           // "ops" | "config"
opsPointEls: new Map(),      // point_id -> { dotEl }
logSource: null,
selectedOpsUnitId: null,
  • 步骤 4web/js/dom.js 添加 DOM 引用
tabOps: byId("tabOps"),
tabConfig: byId("tabConfig"),
batchStartAutoBtn: byId("batchStartAutoBtn"),
batchStopAutoBtn: byId("batchStopAutoBtn"),
opsUnitList: byId("opsUnitList"),
opsEquipmentArea: byId("opsEquipmentArea"),
logView: byId("logView"),
  • 步骤 5web/js/app.js 中添加 switchView 函数并绑定事件
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 中添加:

dom.tabOps.addEventListener("click", () => switchView("ops"));
dom.tabConfig.addEventListener("click", () => switchView("config"));

bootstrap 中调用:

switchView("ops");   // 默认进入运维视图
  • 步骤 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

  • 步骤 1新建 web/html/ops-panel.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使其在运维视图中作为底部全宽面板。

  • 步骤 2web/styles.css 添加运维视图 CSS

.ops-layoutflex 横向)、.ops-unit-sidebar(固定宽度)、.ops-unit-list(可滚动)、.ops-equipment-areaflex-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.jsweb/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-autostop-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 / distributorStart / Stopauto_enabled 时禁用
  • 注册 DOM 元素:state.opsPointEls.set(pointId, { dotEl })
  • 若缓存中有 point.point_monitor,立即根据缓存值初始化信号点颜色

startOps()(导出)

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 状态(避免重新渲染整个卡片区):

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 接入

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 分支中,添加运维视图信号点更新逻辑:

// 运维视图信号点
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 分支同步更新运维单元列表和设备按钮状态:

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.jslogs.jsops.js)。


任务五:配置视图的日志流面板 已完成

涉及文件:

  • 新建:web/html/log-stream-panel.html

  • 修改:web/js/logs.js

  • 修改:web/js/dom.js

  • 步骤 1新建 web/html/log-stream-panel.html

<section class="panel bottom-mid">
  <div class="panel-head"><h2>实时日志</h2></div>
  <div class="log" id="logView"></div>
</section>
  • 步骤 2web/js/logs.js 中实现 startLogs / stopLogs
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 守卫),可安全重复调用。


任务六:收尾、清理与样式完善 已完成

  • 补充日志面板 CSS.log.log-line.level-info/warn/error
  • web/js/units.jsloadUnits() 末尾派发 units-loaded 事件
  • 更新 web/index.html 版本号
  • 最终验证标签切换、信号点实时更新、Start/Stop 控制按钮、SSE 日志流

实现者注意事项

  • state.opsPointEls 在每次重新渲染设备卡片时清空重建,不存在陈旧引用。
  • syncEquipmentButtonsForUnit 仅更新按钮的 disabled 状态,避免每次运行时更新都重渲染整个卡片区。
  • 运维视图默认展示所有单元的所有设备,点击单元后过滤;取消选择后恢复全部展示。
  • 设备卡片头部 data-unit-id 属性供 syncEquipmentButtonsForUnit 精确定位按钮。
  • 后端 /api/unit/{id}/detail 响应中 point.point_monitor 字段包含最新缓存值,可用于初始渲染信号点颜色,无需等待 WebSocket 推送。