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 || "--";
}
// 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;
}

View File

@ -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 = '<div class="muted ops-placeholder">该单元下暂无设备</div>';
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 `
<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>`;
return `<span class="sig-pill sig-warn" data-ops-dot="${point.point_id}" data-ops-role="${role}">${ROLE_LABELS[role] || role}</span>`;
}).join("");
const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor";
const unitId = eq.unit_id ?? null;
card.innerHTML = `
<div class="ops-eq-card-head">
@ -138,46 +139,72 @@ function renderOpsEquipments(equipments) {
<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" data-unit-id="${eq.unit_id || ""}"></div>` : ""}
${canControl ? `<div class="ops-eq-card-actions" data-unit-id="${unitId || ""}"></div>` : ""}
`;
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());
}

View File

@ -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<syncBtns fn>
logSource: null,
selectedOpsUnitId: null,
};

View File

@ -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;