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:
parent
00c16ae3d7
commit
ce8383e815
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue