From ce8383e8153de6e168dee3849ec2a1808e6084e4 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 26 Mar 2026 11:09:23 +0800 Subject: [PATCH] feat(ops): replace signal dots with pills, gate Start/Stop on REM/FLT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal indicators are now wider pill badges (40×20px rect) with the role label (REM/RUN/FLT) embedded inside, replacing the 10px dot+label rows. Equipment Start/Stop buttons are disabled when: - auto control is active - REM = 0 (device in local mode, not accepting remote commands) - FLT = 1 (fault active) Button state reacts in real time to WS signal updates via a per-equipment syncBtns closure registered in state.opsUnitSyncFns. Co-Authored-By: Claude Sonnet 4.6 --- web/js/logs.js | 14 ++++--- web/js/ops.js | 99 +++++++++++++++++++++++++++++-------------------- web/js/state.js | 4 +- web/styles.css | 48 +++++++++--------------- 4 files changed, 88 insertions(+), 77 deletions(-) diff --git a/web/js/logs.js b/web/js/logs.js index d87f1bd..4950f4b 100644 --- a/web/js/logs.js +++ b/web/js/logs.js @@ -113,13 +113,15 @@ export function startPointSocket() { entry.time.textContent = data.timestamp || "--"; } - // ops view signal dot + // ops view signal pill 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); + const { pillEl, syncBtns } = opsEntry; + state.opsSignalCache.set(data.point_id, { quality: data.quality, value_text: data.value_text }); + const role = pillEl.dataset.opsRole; + import("./ops.js").then(({ sigPillClass }) => { + pillEl.className = sigPillClass(role, data.quality, data.value_text); + syncBtns?.(); }); } @@ -140,7 +142,7 @@ export function startPointSocket() { // lazy import to avoid circular dep (ops.js -> logs.js -> ops.js) import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => { renderOpsUnits(); - syncEquipmentButtonsForUnit(runtime.unit_id, runtime.auto_enabled); + syncEquipmentButtonsForUnit(runtime.unit_id); }); return; } diff --git a/web/js/ops.js b/web/js/ops.js index 3c499db..a93d069 100644 --- a/web/js/ops.js +++ b/web/js/ops.js @@ -6,12 +6,17 @@ import { loadUnits } from "./units.js"; const SIGNAL_ROLES = ["rem", "run", "flt"]; const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" }; -export function sigDotClass(role, quality, valueText) { - if (!quality || quality.toLowerCase() !== "good") return "sig-dot sig-warn"; +function isSignalOn(quality, valueText) { + if (!quality || quality.toLowerCase() !== "good") return false; const v = String(valueText ?? "").trim().toLowerCase(); - const on = v === "1" || v === "true" || v === "on"; - if (!on) return "sig-dot"; - return role === "flt" ? "sig-dot sig-fault" : "sig-dot sig-on"; + return v === "1" || v === "true" || v === "on"; +} + +export function sigPillClass(role, quality, valueText) { + if (!quality || quality.toLowerCase() !== "good") return "sig-pill sig-warn"; + const on = isSignalOn(quality, valueText); + if (!on) return "sig-pill"; + return role === "flt" ? "sig-pill sig-fault" : "sig-pill sig-on"; } function runtimeBadge(runtime) { @@ -104,6 +109,8 @@ export function loadAllEquipmentCards() { function renderOpsEquipments(equipments) { dom.opsEquipmentArea.innerHTML = ""; + state.opsUnitSyncFns.clear(); + if (!equipments.length) { dom.opsEquipmentArea.innerHTML = '
该单元下暂无设备
'; return; @@ -113,24 +120,18 @@ function renderOpsEquipments(equipments) { const card = document.createElement("div"); card.className = "ops-eq-card"; - // Build role → point map from role_points const roleMap = {}; - (eq.role_points || []).forEach((p) => { - roleMap[p.signal_role] = p; - }); + (eq.role_points || []).forEach((p) => { roleMap[p.signal_role] = p; }); - // Signal rows HTML (placeholders; WS will fill values) + // Signal pills — one pill per bound role, text label inside const signalRowsHtml = SIGNAL_ROLES.map((role) => { const point = roleMap[role]; if (!point) return ""; - return ` -
- ${ROLE_LABELS[role] || role} - -
`; + return `${ROLE_LABELS[role] || role}`; }).join(""); const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor"; + const unitId = eq.unit_id ?? null; card.innerHTML = `
@@ -138,46 +139,72 @@ function renderOpsEquipments(equipments) { ${eq.kind || "--"}
${signalRowsHtml || '无绑定信号'}
- ${canControl ? `
` : ""} + ${canControl ? `
` : ""} `; + let syncBtns = null; + if (canControl) { const actions = card.querySelector(".ops-eq-card-actions"); - const autoOn = !!(eq.unit_id && state.runtimes.get(eq.unit_id)?.auto_enabled); + const remPointId = roleMap["rem"]?.point_id ?? null; + const fltPointId = roleMap["flt"]?.point_id ?? null; + const startBtn = document.createElement("button"); startBtn.className = "secondary"; startBtn.textContent = "Start"; - startBtn.disabled = autoOn; - startBtn.title = autoOn ? "自动控制运行中,请先停止自动" : ""; 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.disabled = autoOn; - stopBtn.title = autoOn ? "自动控制运行中,请先停止自动" : ""; stopBtn.addEventListener("click", () => apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {}) ); actions.append(startBtn, stopBtn); + + syncBtns = function () { + const autoOn = !!(unitId && state.runtimes.get(unitId)?.auto_enabled); + const remSig = remPointId ? state.opsSignalCache.get(remPointId) : null; + const fltSig = fltPointId ? state.opsSignalCache.get(fltPointId) : null; + const remOk = !remPointId || isSignalOn(remSig?.quality, remSig?.value_text); + const fltActive = !!(fltPointId && isSignalOn(fltSig?.quality, fltSig?.value_text)); + const disabled = autoOn || !remOk || fltActive; + const title = autoOn ? "自动控制运行中,请先停止自动" + : !remOk ? "设备未切换至远程模式" + : fltActive ? "设备故障中" + : ""; + startBtn.disabled = disabled; + stopBtn.disabled = disabled; + startBtn.title = title; + stopBtn.title = title; + }; } dom.opsEquipmentArea.appendChild(card); - // Register DOM elements for WS updates, then seed from cached monitor data + // Register pills for WS updates; seed signal cache from initial point_monitor data SIGNAL_ROLES.forEach((role) => { const point = roleMap[role]; if (!point) return; - const dotEl = card.querySelector(`[data-ops-dot="${point.point_id}"]`); - if (dotEl) { - state.opsPointEls.set(point.point_id, { dotEl }); - if (point.point_monitor) { - const m = point.point_monitor; - dotEl.className = sigDotClass(role, m.quality, m.value_text); - } + const pillEl = card.querySelector(`[data-ops-dot="${point.point_id}"]`); + if (!pillEl) return; + if (point.point_monitor) { + const m = point.point_monitor; + state.opsSignalCache.set(point.point_id, { quality: m.quality, value_text: m.value_text }); + pillEl.className = sigPillClass(role, m.quality, m.value_text); } + const isSyncRole = canControl && (role === "rem" || role === "flt"); + state.opsPointEls.set(point.point_id, { pillEl, syncBtns: isSyncRole ? syncBtns : null }); }); + + if (canControl) { + syncBtns(); + if (unitId) { + if (!state.opsUnitSyncFns.has(unitId)) state.opsUnitSyncFns.set(unitId, new Set()); + state.opsUnitSyncFns.get(unitId).add(syncBtns); + } + } }); } @@ -197,15 +224,7 @@ export function startOps() { }); } -/** Called by WS handler when a unit's runtime changes — syncs manual button disabled state. */ -export function syncEquipmentButtonsForUnit(unitId, autoEnabled) { - if (!dom.opsEquipmentArea) return; - dom.opsEquipmentArea - .querySelectorAll(`.ops-eq-card-actions[data-unit-id="${unitId}"]`) - .forEach((actions) => { - actions.querySelectorAll("button").forEach((btn) => { - btn.disabled = autoEnabled; - btn.title = autoEnabled ? "自动控制运行中,请先停止自动" : ""; - }); - }); +/** Called by WS handler when a unit's runtime changes — re-evaluates all equipment button states. */ +export function syncEquipmentButtonsForUnit(unitId) { + state.opsUnitSyncFns.get(unitId)?.forEach((fn) => fn()); } diff --git a/web/js/state.js b/web/js/state.js index 26d79cf..7cbf656 100644 --- a/web/js/state.js +++ b/web/js/state.js @@ -22,7 +22,9 @@ export const state = { apiDocLoaded: false, runtimes: new Map(), // unit_id -> UnitRuntime activeView: "ops", // "ops" | "config" - opsPointEls: new Map(), // point_id -> { dotEl } + opsPointEls: new Map(), // point_id -> { pillEl, syncBtns? } + opsSignalCache: new Map(), // point_id -> { quality, value_text } + opsUnitSyncFns: new Map(), // unit_id -> Set logSource: null, selectedOpsUnitId: null, }; diff --git a/web/styles.css b/web/styles.css index 71f2db8..0e63918 100644 --- a/web/styles.css +++ b/web/styles.css @@ -237,41 +237,29 @@ body { .ops-signal-rows { padding: 6px 10px; display: flex; - flex-direction: column; - gap: 3px; -} - -.ops-signal-row { - display: flex; + flex-direction: row; + gap: 4px; align-items: center; - gap: 6px; - font-size: 12px; } -.ops-signal-label { - width: 36px; +.sig-pill { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 20px; + border-radius: 3px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + background: var(--surface-2, #e0e0e0); color: var(--text-3); - font-size: 11px; - text-transform: uppercase; - flex-shrink: 0; + transition: background 0.2s, color 0.2s; + user-select: none; } - -.ops-signal-value { - flex: 1; - font-weight: 500; -} - -.sig-dot { - width: 10px; - height: 10px; - border-radius: 50%; - flex-shrink: 0; - background: var(--text-3); - transition: background 0.2s; -} -.sig-dot.sig-on { background: var(--success); } -.sig-dot.sig-fault { background: var(--danger); } -.sig-dot.sig-warn { background: var(--warning); } +.sig-pill.sig-on { background: var(--success); color: #fff; } +.sig-pill.sig-fault { background: var(--danger); color: #fff; } +.sig-pill.sig-warn { background: var(--warning); color: #333; } .ops-eq-card-actions { padding: 6px 10px 8px;