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 || "--";
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
state.opsSignalCache.set(point.point_id, { quality: m.quality, value_text: m.value_text });
|
||||||
dotEl.className = sigDotClass(role, m.quality, 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 ? "自动控制运行中,请先停止自动" : "";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue