14 KiB
双视图 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 端点。
当前布局(参考)
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 -
步骤 1:在顶栏添加标签按钮
web/html/topbar.html 中添加 .tab-bar(含 #tabOps / #tabConfig)及批量自动控制按钮(#batchStartAutoBtn / #batchStopAutoBtn)。
- 步骤 2:向
web/styles.css添加标签与网格 CSS
添加 .tab-bar、.tab-btn、.tab-btn.active 样式;将原有 .grid 替换为 .grid-ops 和 .grid-config,分别定义列、行及面板 grid-column/row 赋值。
- 步骤 3:向
web/js/state.js添加新字段
activeView: "ops", // "ops" | "config"
opsPointEls: new Map(), // point_id -> { dotEl }
logSource: null,
selectedOpsUnitId: null,
- 步骤 4:向
web/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"),
- 步骤 5:在
web/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,使其在运维视图中作为底部全宽面板。
- 步骤 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()(导出)
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.js→logs.js→ops.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>
- 步骤 2:在
web/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.js在loadUnits()末尾派发units-loaded事件- 更新
web/index.html版本号 - 最终验证:标签切换、信号点实时更新、Start/Stop 控制按钮、SSE 日志流
实现者注意事项
state.opsPointEls在每次重新渲染设备卡片时清空重建,不存在陈旧引用。syncEquipmentButtonsForUnit仅更新按钮的disabled状态,避免每次运行时更新都重渲染整个卡片区。- 运维视图默认展示所有单元的所有设备,点击单元后过滤;取消选择后恢复全部展示。
- 设备卡片头部
data-unit-id属性供syncEquipmentButtonsForUnit精确定位按钮。 - 后端
/api/unit/{id}/detail响应中point.point_monitor字段包含最新缓存值,可用于初始渲染信号点颜色,无需等待 WebSocket 推送。