838 lines
25 KiB
Markdown
838 lines
25 KiB
Markdown
# Dual-View Web UI Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add a top-level Tab switch between an **运维视图** (operational, equipment-first) and the existing **配置视图** (configuration, point-first), with the ops view showing realtime signal values on equipment cards and a system-events panel at the bottom, while the config view replaces the events panel with a realtime SSE log stream.
|
||
|
||
**Architecture:** Two CSS grid classes (`grid-ops` / `grid-config`) on `<main>` control which panels are visible. A new `ops.js` module drives the ops view: it calls `GET /api/unit/{id}/detail` on unit selection, renders equipment cards with per-role signal cells, and registers point DOM elements in `state.opsPointEls` so the existing WebSocket handler can push live updates. The SSE log stream (`/api/logs/stream`) is revived as a separate panel shown only in config view, started/stopped on tab switch.
|
||
|
||
**Tech Stack:** Vanilla JS ES modules, CSS Grid, SSE (`EventSource`), existing WebSocket infrastructure, existing `/api/unit/{id}/detail` endpoint.
|
||
|
||
---
|
||
|
||
## Current layout (reference)
|
||
|
||
```
|
||
grid (3 cols × 2 rows):
|
||
top-left → equipment-panel.html (col 1, row 1)
|
||
top-right → points-panel.html (col 2-3, row 1)
|
||
bottom-left → source-panel.html (col 1, row 2) — units + sources stacked
|
||
bottom-mid → logs-panel.html (col 2, row 2) — system events
|
||
bottom-right→ chart-panel.html (col 3, row 2)
|
||
```
|
||
|
||
## Target layouts
|
||
|
||
```
|
||
grid-config (same as current):
|
||
top-left → equipment-panel (col 1, row 1)
|
||
top-right → points-panel (col 2-3, row 1)
|
||
bottom-left → source-panel (col 1, row 2)
|
||
bottom-mid → log-stream-panel (NEW) (col 2, row 2) — SSE logs
|
||
bottom-right→ chart-panel (col 3, row 2)
|
||
|
||
grid-ops (new):
|
||
top → ops-panel (NEW) (col 1-3, row 1) — unit sidebar + equipment cards
|
||
bottom → logs-panel (col 1-3, row 2) — system events (full width)
|
||
```
|
||
|
||
## File Map
|
||
|
||
| File | Action | Purpose |
|
||
|---|---|---|
|
||
| `web/html/topbar.html` | Modify | Add `#tabOps` / `#tabConfig` tab buttons |
|
||
| `web/html/ops-panel.html` | **Create** | Ops view: `#opsUnitList` sidebar + `#opsEquipmentArea` card grid |
|
||
| `web/html/log-stream-panel.html` | **Create** | Config view bottom-mid: SSE log stream (`#logView`) |
|
||
| `web/index.html` | Modify | Add new partials, version bump |
|
||
| `web/js/ops.js` | **Create** | Load unit detail, render equipment cards, expose `updateOpsPoint()` |
|
||
| `web/js/state.js` | Modify | Add `activeView`, `opsPointEls`, `logSource` |
|
||
| `web/js/dom.js` | Modify | Add refs: `tabOps`, `tabConfig`, `opsUnitList`, `opsEquipmentArea`, `logView` |
|
||
| `web/js/logs.js` | Modify | Restore `startLogs` / `stopLogs`; call `updateOpsPoint` in WS handler |
|
||
| `web/js/app.js` | Modify | Tab switch logic, bind ops unit-click, start/stop log stream on switch |
|
||
| `web/styles.css` | Modify | Tab styles, `grid-ops`, `grid-config`, ops card + signal row styles |
|
||
|
||
---
|
||
|
||
## Task 1: Tab scaffold + CSS layout switching
|
||
|
||
**Files:**
|
||
- Modify: `web/html/topbar.html`
|
||
- Modify: `web/index.html`
|
||
- Modify: `web/js/state.js`
|
||
- Modify: `web/js/dom.js`
|
||
- Modify: `web/js/app.js`
|
||
- Modify: `web/styles.css`
|
||
|
||
- [ ] **Step 1: Add tab buttons to topbar**
|
||
|
||
Replace `web/html/topbar.html` with:
|
||
|
||
```html
|
||
<header class="topbar">
|
||
<div class="title">PLC Control</div>
|
||
<div class="tab-bar">
|
||
<button type="button" class="tab-btn active" id="tabOps">运维</button>
|
||
<button type="button" class="tab-btn" id="tabConfig">配置</button>
|
||
</div>
|
||
<div class="topbar-actions">
|
||
<button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button>
|
||
<button type="button" class="secondary" id="openApiDoc">API.md</button>
|
||
<div class="status" id="statusText">Ready</div>
|
||
</div>
|
||
</header>
|
||
```
|
||
|
||
- [ ] **Step 2: Add tab + grid CSS to `web/styles.css`**
|
||
|
||
After the existing `.topbar-actions` block, add:
|
||
|
||
```css
|
||
/* ── Tabs ───────────────────────────────────────── */
|
||
|
||
.tab-bar {
|
||
display: flex;
|
||
gap: 2px;
|
||
}
|
||
|
||
.tab-btn {
|
||
padding: 0 16px;
|
||
height: 28px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
color: var(--text-2);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.tab-btn.active {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #fff;
|
||
}
|
||
```
|
||
|
||
Replace the existing `.grid` block (lines 82–94) with:
|
||
|
||
```css
|
||
.grid-ops,
|
||
.grid-config {
|
||
display: grid;
|
||
gap: 1px;
|
||
height: calc(100vh - var(--topbar-h));
|
||
}
|
||
|
||
.grid-config {
|
||
grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
|
||
grid-template-rows: 1fr 380px;
|
||
}
|
||
|
||
.grid-ops {
|
||
grid-template-columns: 260px minmax(0, 1fr);
|
||
grid-template-rows: 1fr 260px;
|
||
}
|
||
|
||
/* config view slot assignments */
|
||
.grid-config .panel.top-left { grid-column: 1; grid-row: 1; }
|
||
.grid-config .panel.top-right { grid-column: 2 / 4; grid-row: 1; }
|
||
.grid-config .panel.bottom-left { grid-column: 1; grid-row: 2; }
|
||
.grid-config .panel.bottom-mid { grid-column: 2; grid-row: 2; }
|
||
.grid-config .panel.bottom-right{ grid-column: 3; grid-row: 2; }
|
||
|
||
/* ops view slot assignments */
|
||
.grid-ops .panel.ops-main { grid-column: 1 / 3; grid-row: 1; }
|
||
.grid-ops .panel.ops-bottom { grid-column: 1 / 3; grid-row: 2; }
|
||
```
|
||
|
||
- [ ] **Step 3: Add `activeView` and `logSource` to `web/js/state.js`**
|
||
|
||
```js
|
||
export const state = {
|
||
// ... existing fields ...
|
||
activeView: "ops", // "ops" | "config"
|
||
opsPointEls: new Map(), // point_id -> { valueEl, qualityEl }
|
||
logSource: null,
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Add DOM refs in `web/js/dom.js`**
|
||
|
||
```js
|
||
tabOps: byId("tabOps"),
|
||
tabConfig: byId("tabConfig"),
|
||
opsUnitList: byId("opsUnitList"),
|
||
opsEquipmentArea: byId("opsEquipmentArea"),
|
||
logView: byId("logView"),
|
||
```
|
||
|
||
- [ ] **Step 5: Add `switchView` function + wiring in `web/js/app.js`**
|
||
|
||
Add at top of `app.js`:
|
||
```js
|
||
import { startOps, handleOpsUnitClick } from "./ops.js";
|
||
import { startLogs, stopLogs } from "./logs.js";
|
||
```
|
||
|
||
Add `switchView` function before `bindEvents`:
|
||
```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");
|
||
|
||
// config-only panels
|
||
["top-left", "top-right", "bottom-left", "bottom-right"].forEach((cls) => {
|
||
const el = main.querySelector(`.panel.${cls}`);
|
||
if (el) el.classList.toggle("hidden", view === "ops");
|
||
});
|
||
// bottom-mid is log-stream in config, hidden in ops
|
||
const logStreamPanel = main.querySelector(".panel.bottom-mid");
|
||
if (logStreamPanel) logStreamPanel.classList.toggle("hidden", view === "ops");
|
||
|
||
// ops-only panels
|
||
const opsMain = main.querySelector(".panel.ops-main");
|
||
const opsBottom = main.querySelector(".panel.ops-bottom");
|
||
if (opsMain) opsMain.classList.toggle("hidden", view === "config");
|
||
if (opsBottom) opsBottom.classList.toggle("hidden", view === "config");
|
||
|
||
if (view === "config") {
|
||
startLogs();
|
||
} else {
|
||
stopLogs();
|
||
}
|
||
}
|
||
```
|
||
|
||
In `bindEvents`, add:
|
||
```js
|
||
dom.tabOps.addEventListener("click", () => switchView("ops"));
|
||
dom.tabConfig.addEventListener("click", () => switchView("config"));
|
||
```
|
||
|
||
In `bootstrap`, call after `bindEvents()`:
|
||
```js
|
||
switchView("ops"); // default to ops view
|
||
```
|
||
|
||
- [ ] **Step 6: Update `web/index.html` — add new partials, default grid class, version bump**
|
||
|
||
```html
|
||
<main class="grid-ops">
|
||
<div data-partial="/ui/html/ops-panel.html"></div>
|
||
<div data-partial="/ui/html/equipment-panel.html"></div>
|
||
<div data-partial="/ui/html/points-panel.html"></div>
|
||
<div data-partial="/ui/html/source-panel.html"></div>
|
||
<div data-partial="/ui/html/log-stream-panel.html"></div>
|
||
<div data-partial="/ui/html/logs-panel.html"></div>
|
||
<div data-partial="/ui/html/chart-panel.html"></div>
|
||
</main>
|
||
```
|
||
|
||
Bump version: `?v=20260325a` on both CSS and JS.
|
||
|
||
- [ ] **Step 7: Verify panels show/hide correctly**
|
||
|
||
Open browser, click 运维 / 配置 tabs — panels should swap. Layout may be unstyled; that's fine for now.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add web/html/topbar.html web/index.html web/js/state.js web/js/dom.js web/js/app.js web/styles.css
|
||
git commit -m "feat(web): add tab scaffold for ops/config dual-view layout"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Ops panel HTML + CSS skeleton
|
||
|
||
**Files:**
|
||
- Create: `web/html/ops-panel.html`
|
||
- Modify: `web/styles.css`
|
||
|
||
- [ ] **Step 1: Create `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>
|
||
```
|
||
|
||
Note: `logs-panel.html` already has `id="eventList"` and class structure. Add `ops-bottom` class to it in HTML:
|
||
|
||
In `web/html/logs-panel.html`, change:
|
||
```html
|
||
<section class="panel ops-bottom">
|
||
```
|
||
|
||
- [ ] **Step 2: Add ops layout CSS to `web/styles.css`**
|
||
|
||
```css
|
||
/* ── Ops View ───────────────────────────────────── */
|
||
|
||
.ops-layout {
|
||
display: flex;
|
||
min-height: 0;
|
||
flex: 1 1 auto;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.ops-unit-sidebar {
|
||
width: 260px;
|
||
flex-shrink: 0;
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.ops-unit-list {
|
||
flex: 1 1 auto;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.ops-equipment-area {
|
||
flex: 1 1 auto;
|
||
overflow: auto;
|
||
padding: 12px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-content: flex-start;
|
||
gap: 12px;
|
||
}
|
||
|
||
.ops-placeholder {
|
||
padding: 20px;
|
||
}
|
||
|
||
/* Equipment ops card */
|
||
.ops-eq-card {
|
||
width: 220px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
|
||
.ops-eq-card-head {
|
||
padding: 8px 10px 6px;
|
||
border-bottom: 1px solid var(--border-light);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.ops-eq-card-head strong {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.ops-signal-rows {
|
||
padding: 6px 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
|
||
.ops-signal-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.ops-signal-label {
|
||
width: 36px;
|
||
color: var(--text-3);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.ops-signal-value {
|
||
flex: 1;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.ops-eq-card-actions {
|
||
padding: 6px 10px 8px;
|
||
display: flex;
|
||
gap: 6px;
|
||
border-top: 1px solid var(--border-light);
|
||
}
|
||
|
||
.ops-eq-card-actions button {
|
||
flex: 1;
|
||
padding: 3px 0;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* ops unit list item */
|
||
.ops-unit-item {
|
||
padding: 8px 10px;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid var(--border-light);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
|
||
.ops-unit-item:hover { background: var(--accent-bg); }
|
||
.ops-unit-item.selected {
|
||
background: var(--accent-bg);
|
||
border-left: 3px solid var(--accent);
|
||
}
|
||
|
||
.ops-unit-item-name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.ops-unit-item-meta {
|
||
font-size: 11px;
|
||
color: var(--text-3);
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify layout renders correctly (empty, no JS yet)**
|
||
|
||
Refresh browser in ops tab — sidebar and card area should be visible with placeholder text.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add web/html/ops-panel.html web/html/logs-panel.html web/styles.css
|
||
git commit -m "feat(web): add ops panel HTML skeleton and layout CSS"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: ops.js — unit list + equipment card rendering
|
||
|
||
**Files:**
|
||
- Create: `web/js/ops.js`
|
||
- Modify: `web/js/app.js`
|
||
|
||
The ops view unit list is separate from the config view's `#unitList`. When a unit is clicked, `GET /api/unit/{id}/detail` returns the nested structure and we render equipment cards.
|
||
|
||
Equipment card signal roles to display (in order): `rem`, `run`, `flt`. Show label + quality dot + value. Start/Stop buttons only for `coal_feeder` / `distributor` kind.
|
||
|
||
- [ ] **Step 1: Create `web/js/ops.js`**
|
||
|
||
```js
|
||
import { apiFetch } from "./api.js";
|
||
import { dom } from "./dom.js";
|
||
import { state } from "./state.js";
|
||
|
||
const SIGNAL_ROLES = ["rem", "run", "flt"];
|
||
const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" };
|
||
|
||
export function renderOpsUnits() {
|
||
if (!dom.opsUnitList) return;
|
||
dom.opsUnitList.innerHTML = "";
|
||
|
||
if (!state.units.length) {
|
||
dom.opsUnitList.innerHTML = '<div class="muted" style="padding:12px">暂无控制单元</div>';
|
||
return;
|
||
}
|
||
|
||
state.units.forEach((unit) => {
|
||
const runtime = state.runtimes.get(unit.id);
|
||
const item = document.createElement("div");
|
||
item.className = `ops-unit-item${state.selectedOpsUnitId === unit.id ? " selected" : ""}`;
|
||
item.innerHTML = `
|
||
<div class="ops-unit-item-name">${unit.code} / ${unit.name}</div>
|
||
<div class="ops-unit-item-meta">
|
||
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
|
||
${runtime ? `<span>Acc ${Math.floor(runtime.accumulated_run_sec / 1000)}s</span>` : ""}
|
||
</div>
|
||
`;
|
||
item.addEventListener("click", () => selectOpsUnit(unit.id));
|
||
dom.opsUnitList.appendChild(item);
|
||
});
|
||
}
|
||
|
||
async function selectOpsUnit(unitId) {
|
||
state.selectedOpsUnitId = unitId === state.selectedOpsUnitId ? null : unitId;
|
||
renderOpsUnits();
|
||
|
||
if (!state.selectedOpsUnitId) {
|
||
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">← 选择控制单元</div>';
|
||
state.opsPointEls.clear();
|
||
return;
|
||
}
|
||
|
||
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">加载中...</div>';
|
||
state.opsPointEls.clear();
|
||
|
||
const detail = await apiFetch(`/api/unit/${state.selectedOpsUnitId}/detail`);
|
||
renderOpsEquipments(detail.equipments || []);
|
||
}
|
||
|
||
function renderOpsEquipments(equipments) {
|
||
dom.opsEquipmentArea.innerHTML = "";
|
||
if (!equipments.length) {
|
||
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">该单元下暂无设备</div>';
|
||
return;
|
||
}
|
||
|
||
equipments.forEach((eq) => {
|
||
const runtime = state.runtimes.get(state.selectedOpsUnitId);
|
||
const card = document.createElement("div");
|
||
card.className = "ops-eq-card";
|
||
|
||
// Build role → point_id map
|
||
const roleMap = {};
|
||
(eq.points || []).forEach((p) => {
|
||
if (p.signal_role) roleMap[p.signal_role] = p;
|
||
});
|
||
|
||
// Signal rows HTML (placeholders; WS will fill values)
|
||
const signalRowsHtml = SIGNAL_ROLES.map((role) => {
|
||
const point = roleMap[role];
|
||
if (!point) return "";
|
||
return `
|
||
<div class="ops-signal-row">
|
||
<span class="ops-signal-label">${ROLE_LABELS[role] || role}</span>
|
||
<span class="badge quality-unknown" data-ops-quality="${point.id}">?</span>
|
||
<span class="ops-signal-value" data-ops-value="${point.id}">--</span>
|
||
</div>`;
|
||
}).join("");
|
||
|
||
const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor";
|
||
|
||
card.innerHTML = `
|
||
<div class="ops-eq-card-head">
|
||
<strong title="${eq.name}">${eq.code}</strong>
|
||
<span class="badge">${eq.kind || "--"}</span>
|
||
</div>
|
||
<div class="ops-signal-rows">${signalRowsHtml || '<span class="muted" style="font-size:11px;padding:2px 0">无绑定信号</span>'}</div>
|
||
${canControl ? '<div class="ops-eq-card-actions"></div>' : ""}
|
||
`;
|
||
|
||
if (canControl) {
|
||
const actions = card.querySelector(".ops-eq-card-actions");
|
||
const startBtn = document.createElement("button");
|
||
startBtn.className = "secondary";
|
||
startBtn.textContent = "Start";
|
||
startBtn.addEventListener("click", () =>
|
||
apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {})
|
||
);
|
||
const stopBtn = document.createElement("button");
|
||
stopBtn.className = "danger";
|
||
stopBtn.textContent = "Stop";
|
||
stopBtn.addEventListener("click", () =>
|
||
apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {})
|
||
);
|
||
actions.append(startBtn, stopBtn);
|
||
}
|
||
|
||
dom.opsEquipmentArea.appendChild(card);
|
||
|
||
// Register DOM elements for WS updates
|
||
SIGNAL_ROLES.forEach((role) => {
|
||
const point = roleMap[role];
|
||
if (!point) return;
|
||
const valueEl = card.querySelector(`[data-ops-value="${point.id}"]`);
|
||
const qualityEl = card.querySelector(`[data-ops-quality="${point.id}"]`);
|
||
if (valueEl && qualityEl) {
|
||
state.opsPointEls.set(point.id, { valueEl, qualityEl });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
export function startOps() {
|
||
renderOpsUnits();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add `selectedOpsUnitId` to `web/js/state.js`**
|
||
|
||
```js
|
||
selectedOpsUnitId: null,
|
||
```
|
||
|
||
- [ ] **Step 3: Wire ops into `web/js/app.js`**
|
||
|
||
Add import:
|
||
```js
|
||
import { startOps, renderOpsUnits } from "./ops.js";
|
||
```
|
||
|
||
In `bootstrap`, add after `loadUnits`:
|
||
```js
|
||
await withStatus(loadUnits()); // already exists
|
||
startOps(); // initialise ops unit list
|
||
```
|
||
|
||
Also update the `equipments-updated` listener to also call `renderOpsUnits`:
|
||
```js
|
||
document.addEventListener("equipments-updated", () => {
|
||
renderUnits();
|
||
renderOpsUnits();
|
||
});
|
||
```
|
||
|
||
After `loadUnits()` is called anywhere (e.g., `refreshUnitBtn`), `renderOpsUnits()` should also be triggered. Simplest: call `renderOpsUnits()` inside `loadUnits()` in `units.js` — add at end of that function:
|
||
|
||
In `web/js/units.js`, at end of `loadUnits()`:
|
||
```js
|
||
// notify ops view
|
||
document.dispatchEvent(new Event("units-loaded"));
|
||
```
|
||
|
||
In `web/js/app.js`:
|
||
```js
|
||
document.addEventListener("units-loaded", renderOpsUnits);
|
||
```
|
||
|
||
- [ ] **Step 4: Verify unit list renders and card area populates on click**
|
||
|
||
Click 运维 tab → unit list shows → click a unit → equipment cards appear with signal row placeholders.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web/js/ops.js web/js/state.js web/js/app.js web/js/units.js
|
||
git commit -m "feat(web): ops view unit list and equipment card rendering"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Realtime signal values in ops cards
|
||
|
||
**Files:**
|
||
- Modify: `web/js/logs.js`
|
||
|
||
The WebSocket `PointNewValue` handler already updates `state.pointEls`. Add a second lookup for `state.opsPointEls`.
|
||
|
||
- [ ] **Step 1: Update WebSocket handler in `web/js/logs.js`**
|
||
|
||
In the `PointNewValue` branch, after the existing `state.pointEls` block:
|
||
|
||
```js
|
||
if (payload.type === "PointNewValue" || payload.type === "point_new_value") {
|
||
const data = payload.data;
|
||
|
||
// config view point table
|
||
const entry = state.pointEls.get(data.point_id);
|
||
if (entry) {
|
||
entry.value.textContent = formatValue(data);
|
||
entry.quality.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`;
|
||
entry.quality.textContent = (data.quality || "unknown").toUpperCase();
|
||
entry.time.textContent = data.timestamp || "--";
|
||
}
|
||
|
||
// ops view signal cell
|
||
const opsEntry = state.opsPointEls.get(data.point_id);
|
||
if (opsEntry) {
|
||
opsEntry.valueEl.textContent = formatValue(data);
|
||
opsEntry.qualityEl.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`;
|
||
opsEntry.qualityEl.textContent = (data.quality || "unknown").toUpperCase();
|
||
}
|
||
|
||
if (state.chartPointId === data.point_id) {
|
||
appendChartPoint(data);
|
||
}
|
||
return;
|
||
}
|
||
```
|
||
|
||
Also update `UnitRuntimeChanged` to re-render ops unit list:
|
||
|
||
```js
|
||
if (payload.type === "UnitRuntimeChanged") {
|
||
const runtime = payload.data;
|
||
state.runtimes.set(runtime.unit_id, runtime);
|
||
renderUnits();
|
||
// lazy import to avoid circular dep
|
||
import("./ops.js").then(({ renderOpsUnits }) => renderOpsUnits());
|
||
return;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify realtime updates**
|
||
|
||
With a live OPC UA source connected, open ops view, select a unit — signal cells should show live quality badges and values updating in real time.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add web/js/logs.js
|
||
git commit -m "feat(web): ops card signal cells update from WebSocket PointNewValue"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Log stream panel for config view
|
||
|
||
**Files:**
|
||
- Create: `web/html/log-stream-panel.html`
|
||
- Modify: `web/js/logs.js`
|
||
- Modify: `web/js/dom.js`
|
||
- Modify: `web/js/app.js`
|
||
|
||
Restore the SSE `EventSource` log stream, but only active when in config view. The `startLogs` / `stopLogs` functions are called by `switchView` in `app.js` (already wired in Task 1 Step 5).
|
||
|
||
- [ ] **Step 1: Create `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>
|
||
```
|
||
|
||
- [ ] **Step 2: Restore `startLogs` / `stopLogs` in `web/js/logs.js`**
|
||
|
||
Add before `startPointSocket`:
|
||
|
||
```js
|
||
function escapeHtml(text) {
|
||
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||
}
|
||
|
||
function parseLogLine(line) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
||
try { return JSON.parse(trimmed); } catch { return null; }
|
||
}
|
||
|
||
export function appendLog(line) {
|
||
if (!dom.logView) return;
|
||
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
|
||
const div = document.createElement("div");
|
||
const parsed = parseLogLine(line);
|
||
if (!parsed) {
|
||
div.className = "log-line";
|
||
div.textContent = line;
|
||
} else {
|
||
const levelRaw = (parsed.level || "").toString();
|
||
const level = levelRaw.toLowerCase();
|
||
div.className = `log-line${level ? ` level-${level}` : ""}`;
|
||
div.innerHTML = [
|
||
`<span class="level">${escapeHtml(levelRaw || "LOG")}</span>`,
|
||
parsed.timestamp ? `<span class="muted"> ${escapeHtml(parsed.timestamp)}</span>` : "",
|
||
parsed.target ? `<span class="muted"> ${escapeHtml(parsed.target)}</span>` : "",
|
||
`<span class="message">${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}</span>`,
|
||
].join("");
|
||
}
|
||
dom.logView.appendChild(div);
|
||
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add `logView` to `web/js/dom.js`** (already added in Task 1 Step 4 — verify it's present)
|
||
|
||
- [ ] **Step 4: Verify config view shows SSE log stream**
|
||
|
||
Click 配置 tab → bottom-middle panel should show "实时日志" with live log lines streaming. Click 运维 tab → SSE connection closes.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web/html/log-stream-panel.html web/js/logs.js web/js/dom.js
|
||
git commit -m "feat(web): restore SSE log stream panel in config view"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Final wiring, cleanup and polish
|
||
|
||
**Files:**
|
||
- Modify: `web/styles.css` (log panel, minor tweaks)
|
||
- Modify: `web/js/units.js` (dispatch units-loaded)
|
||
- Modify: `web/index.html` (version bump)
|
||
|
||
- [ ] **Step 1: Add log panel CSS** (if not already in styles.css from previous work)
|
||
|
||
Verify `.log`, `.log-line`, `.level-info`, `.level-warn`, `.level-error` styles exist. If not, add:
|
||
|
||
```css
|
||
.log {
|
||
flex: 1 1 auto;
|
||
overflow-y: auto;
|
||
font-family: monospace;
|
||
font-size: 12px;
|
||
padding: 4px 8px;
|
||
}
|
||
|
||
.log-line { padding: 1px 0; border-bottom: 1px solid var(--border-light); }
|
||
.log-line .level { font-weight: 700; margin-right: 6px; }
|
||
.log-line.level-error { color: var(--danger); }
|
||
.log-line.level-warn { color: var(--warning); }
|
||
.log-line.level-info { color: var(--text-2); }
|
||
.log-line .message { color: var(--text); }
|
||
```
|
||
|
||
- [ ] **Step 2: Bump version in `web/index.html`**
|
||
|
||
Change `?v=20260325a` → `?v=20260325b` on both CSS and JS links.
|
||
|
||
- [ ] **Step 3: Final verification checklist**
|
||
|
||
- [ ] 运维 tab: unit list renders, click unit → equipment cards appear
|
||
- [ ] Equipment cards show REM / RUN / FLT rows with live values
|
||
- [ ] Start/Stop buttons work (coal_feeder / distributor only)
|
||
- [ ] `UnitRuntimeChanged` WS message updates ops unit list badges
|
||
- [ ] 配置 tab: all existing panels visible (equipment, points, sources, chart)
|
||
- [ ] 配置 tab bottom-mid shows SSE log stream, lines append in real time
|
||
- [ ] Switching tabs starts/stops SSE correctly (no duplicate connections)
|
||
- [ ] 配置 tab events/chart/points work as before
|
||
|
||
- [ ] **Step 4: Final commit**
|
||
|
||
```bash
|
||
git add web/styles.css web/js/units.js web/index.html
|
||
git commit -m "feat(web): dual-view UI complete — ops cards + config log stream"
|
||
```
|
||
|
||
---
|
||
|
||
## Notes for implementer
|
||
|
||
- `state.opsPointEls` is cleared and rebuilt every time a different unit is selected in ops view — no stale references.
|
||
- The lazy `import("./ops.js")` in `logs.js` for `UnitRuntimeChanged` avoids a circular dependency (`ops.js` → `logs.js` → `ops.js`). Alternatively, expose a `document.dispatchEvent(new Event("unit-runtime-changed"))` and listen in `ops.js`.
|
||
- The ops view does **not** reload `state.equipments` separately — it uses the `/api/unit/{id}/detail` response which is self-contained.
|
||
- `startLogs()` is idempotent (guards with `if (state.logSource) return`), so double-calling is safe.
|
||
- Backend log CSS classes: the existing styles from before the log removal commit should still be in `styles.css`. If they were removed, add them back per Task 6 Step 1.
|