359 lines
14 KiB
Markdown
359 lines
14 KiB
Markdown
# 双视图 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`
|
||
|
||
- [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 推送。
|