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;