feat(ops): replace signal dots with pills, gate Start/Stop on REM/FLT

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 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-03-26 11:09:23 +08:00
parent 00c16ae3d7
commit ce8383e815
4 changed files with 88 additions and 77 deletions

View File

@ -113,13 +113,15 @@ export function startPointSocket() {
entry.time.textContent = data.timestamp || "--"; entry.time.textContent = data.timestamp || "--";
} }
// ops view signal dot // ops view signal pill
const opsEntry = state.opsPointEls.get(data.point_id); const opsEntry = state.opsPointEls.get(data.point_id);
if (opsEntry) { if (opsEntry) {
const { dotEl } = opsEntry; const { pillEl, syncBtns } = opsEntry;
const role = dotEl.dataset.opsRole; state.opsSignalCache.set(data.point_id, { quality: data.quality, value_text: data.value_text });
import("./ops.js").then(({ sigDotClass }) => { const role = pillEl.dataset.opsRole;
dotEl.className = sigDotClass(role, data.quality, data.value_text); 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) // lazy import to avoid circular dep (ops.js -> logs.js -> ops.js)
import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => { import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => {
renderOpsUnits(); renderOpsUnits();
syncEquipmentButtonsForUnit(runtime.unit_id, runtime.auto_enabled); syncEquipmentButtonsForUnit(runtime.unit_id);
}); });
return; return;
} }

View File

@ -6,12 +6,17 @@ import { loadUnits } from "./units.js";
const SIGNAL_ROLES = ["rem", "run", "flt"]; const SIGNAL_ROLES = ["rem", "run", "flt"];
const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" }; const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" };
export function sigDotClass(role, quality, valueText) { function isSignalOn(quality, valueText) {
if (!quality || quality.toLowerCase() !== "good") return "sig-dot sig-warn"; if (!quality || quality.toLowerCase() !== "good") return false;
const v = String(valueText ?? "").trim().toLowerCase(); const v = String(valueText ?? "").trim().toLowerCase();
const on = v === "1" || v === "true" || v === "on"; return v === "1" || v === "true" || v === "on";
if (!on) return "sig-dot"; }
return role === "flt" ? "sig-dot sig-fault" : "sig-dot sig-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) { function runtimeBadge(runtime) {
@ -104,6 +109,8 @@ export function loadAllEquipmentCards() {
function renderOpsEquipments(equipments) { function renderOpsEquipments(equipments) {
dom.opsEquipmentArea.innerHTML = ""; dom.opsEquipmentArea.innerHTML = "";
state.opsUnitSyncFns.clear();
if (!equipments.length) { if (!equipments.length) {
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">该单元下暂无设备</div>'; dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">该单元下暂无设备</div>';
return; return;
@ -113,24 +120,18 @@ function renderOpsEquipments(equipments) {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "ops-eq-card"; card.className = "ops-eq-card";
// Build role → point map from role_points
const roleMap = {}; const roleMap = {};
(eq.role_points || []).forEach((p) => { (eq.role_points || []).forEach((p) => { roleMap[p.signal_role] = 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 signalRowsHtml = SIGNAL_ROLES.map((role) => {
const point = roleMap[role]; const point = roleMap[role];
if (!point) return ""; if (!point) return "";
return ` return `<span class="sig-pill sig-warn" data-ops-dot="${point.point_id}" data-ops-role="${role}">${ROLE_LABELS[role] || role}</span>`;
<div class="ops-signal-row">
<span class="ops-signal-label">${ROLE_LABELS[role] || role}</span>
<span class="sig-dot sig-warn" data-ops-dot="${point.point_id}" data-ops-role="${role}"></span>
</div>`;
}).join(""); }).join("");
const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor"; const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor";
const unitId = eq.unit_id ?? null;
card.innerHTML = ` card.innerHTML = `
<div class="ops-eq-card-head"> <div class="ops-eq-card-head">
@ -138,46 +139,72 @@ function renderOpsEquipments(equipments) {
<span class="badge">${eq.kind || "--"}</span> <span class="badge">${eq.kind || "--"}</span>
</div> </div>
<div class="ops-signal-rows">${signalRowsHtml || '<span class="muted" style="font-size:11px;padding:2px 0">无绑定信号</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" data-unit-id="${eq.unit_id || ""}"></div>` : ""} ${canControl ? `<div class="ops-eq-card-actions" data-unit-id="${unitId || ""}"></div>` : ""}
`; `;
let syncBtns = null;
if (canControl) { if (canControl) {
const actions = card.querySelector(".ops-eq-card-actions"); 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"); const startBtn = document.createElement("button");
startBtn.className = "secondary"; startBtn.className = "secondary";
startBtn.textContent = "Start"; startBtn.textContent = "Start";
startBtn.disabled = autoOn;
startBtn.title = autoOn ? "自动控制运行中,请先停止自动" : "";
startBtn.addEventListener("click", () => startBtn.addEventListener("click", () =>
apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {}) apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {})
); );
const stopBtn = document.createElement("button"); const stopBtn = document.createElement("button");
stopBtn.className = "danger"; stopBtn.className = "danger";
stopBtn.textContent = "Stop"; stopBtn.textContent = "Stop";
stopBtn.disabled = autoOn;
stopBtn.title = autoOn ? "自动控制运行中,请先停止自动" : "";
stopBtn.addEventListener("click", () => stopBtn.addEventListener("click", () =>
apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {}) apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {})
); );
actions.append(startBtn, stopBtn); 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); 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) => { SIGNAL_ROLES.forEach((role) => {
const point = roleMap[role]; const point = roleMap[role];
if (!point) return; if (!point) return;
const dotEl = card.querySelector(`[data-ops-dot="${point.point_id}"]`); const pillEl = card.querySelector(`[data-ops-dot="${point.point_id}"]`);
if (dotEl) { if (!pillEl) return;
state.opsPointEls.set(point.point_id, { dotEl });
if (point.point_monitor) { if (point.point_monitor) {
const m = point.point_monitor; const m = point.point_monitor;
dotEl.className = sigDotClass(role, m.quality, m.value_text); 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. */ /** Called by WS handler when a unit's runtime changes — re-evaluates all equipment button states. */
export function syncEquipmentButtonsForUnit(unitId, autoEnabled) { export function syncEquipmentButtonsForUnit(unitId) {
if (!dom.opsEquipmentArea) return; state.opsUnitSyncFns.get(unitId)?.forEach((fn) => fn());
dom.opsEquipmentArea
.querySelectorAll(`.ops-eq-card-actions[data-unit-id="${unitId}"]`)
.forEach((actions) => {
actions.querySelectorAll("button").forEach((btn) => {
btn.disabled = autoEnabled;
btn.title = autoEnabled ? "自动控制运行中,请先停止自动" : "";
});
});
} }

View File

@ -22,7 +22,9 @@ export const state = {
apiDocLoaded: false, apiDocLoaded: false,
runtimes: new Map(), // unit_id -> UnitRuntime runtimes: new Map(), // unit_id -> UnitRuntime
activeView: "ops", // "ops" | "config" 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<syncBtns fn>
logSource: null, logSource: null,
selectedOpsUnitId: null, selectedOpsUnitId: null,
}; };

View File

@ -237,41 +237,29 @@ body {
.ops-signal-rows { .ops-signal-rows {
padding: 6px 10px; padding: 6px 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 3px; gap: 4px;
}
.ops-signal-row {
display: flex;
align-items: center; align-items: center;
gap: 6px;
font-size: 12px;
} }
.ops-signal-label { .sig-pill {
width: 36px; 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); color: var(--text-3);
font-size: 11px; transition: background 0.2s, color 0.2s;
text-transform: uppercase; user-select: none;
flex-shrink: 0;
} }
.sig-pill.sig-on { background: var(--success); color: #fff; }
.ops-signal-value { .sig-pill.sig-fault { background: var(--danger); color: #fff; }
flex: 1; .sig-pill.sig-warn { background: var(--warning); color: #333; }
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); }
.ops-eq-card-actions { .ops-eq-card-actions {
padding: 6px 10px 8px; padding: 6px 10px 8px;