feat(web): dual-view UI — 运维/配置 tab, ops equipment cards with live signal values
- Add 运维/配置 tab switch; grid-ops / grid-config layout classes - New ops-panel: unit sidebar + equipment card grid (REM/RUN/FLT signals) - All equipment cards shown by default; unit click acts as filter - Signal cells seed from point_monitor cache on render, then update via WS PointNewValue - New log-stream-panel: SSE realtime log stream, active only in config view - Backend: get_unit_detail now includes point_monitor (current value) in each point Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c2ed1e70fb
commit
4076f6575e
|
|
@ -114,11 +114,18 @@ pub async fn get_unit(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct PointDetail {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub point: crate::model::Point,
|
||||||
|
pub point_monitor: Option<crate::telemetry::PointMonitorInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct EquipmentDetail {
|
pub struct EquipmentDetail {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub equipment: crate::model::Equipment,
|
pub equipment: crate::model::Equipment,
|
||||||
pub points: Vec<crate::model::Point>,
|
pub points: Vec<PointDetail>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
|
|
@ -140,13 +147,21 @@ pub async fn get_unit_detail(
|
||||||
let equipment_ids: Vec<Uuid> = equipments.iter().map(|e| e.id).collect();
|
let equipment_ids: Vec<Uuid> = equipments.iter().map(|e| e.id).collect();
|
||||||
let all_points = crate::service::get_points_by_equipment_ids(&state.pool, &equipment_ids).await?;
|
let all_points = crate::service::get_points_by_equipment_ids(&state.pool, &equipment_ids).await?;
|
||||||
|
|
||||||
|
let monitor_guard = state
|
||||||
|
.connection_manager
|
||||||
|
.get_point_monitor_data_read_guard()
|
||||||
|
.await;
|
||||||
|
|
||||||
let equipments = equipments
|
let equipments = equipments
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|eq| {
|
.map(|eq| {
|
||||||
let points = all_points
|
let points = all_points
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| p.equipment_id == Some(eq.id))
|
.filter(|p| p.equipment_id == Some(eq.id))
|
||||||
.cloned()
|
.map(|p| PointDetail {
|
||||||
|
point_monitor: monitor_guard.get(&p.id).cloned(),
|
||||||
|
point: p.clone(),
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
EquipmentDetail { equipment: eq, points }
|
EquipmentDetail { equipment: eq, points }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<section class="panel bottom-mid">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>实时日志</h2>
|
||||||
|
</div>
|
||||||
|
<div class="log" id="logView"></div>
|
||||||
|
</section>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="panel bottom-middle">
|
<section class="panel ops-bottom">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>系统事件</h2>
|
<h2>系统事件</h2>
|
||||||
<button type="button" class="secondary" id="refreshEventBtn">刷新</button>
|
<button type="button" class="secondary" id="refreshEventBtn">刷新</button>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<section class="panel ops-main">
|
||||||
|
<div class="ops-layout">
|
||||||
|
<aside class="ops-unit-sidebar">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>控制单元</h2>
|
||||||
|
</div>
|
||||||
|
<div class="list ops-unit-list" id="opsUnitList"></div>
|
||||||
|
</aside>
|
||||||
|
<div class="ops-equipment-area" id="opsEquipmentArea">
|
||||||
|
<div class="muted ops-placeholder">← 选择控制单元</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="title">PLC Control</div>
|
<div class="title">PLC Control</div>
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button type="button" class="tab-btn active" id="tabOps">运维</button>
|
||||||
|
<button type="button" class="tab-btn" id="tabConfig">配置</button>
|
||||||
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button>
|
<button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button>
|
||||||
<button type="button" class="secondary" id="openApiDoc">API.md</button>
|
<button type="button" class="secondary" id="openApiDoc">API.md</button>
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,17 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>PLC Control</title>
|
<title>PLC Control</title>
|
||||||
<link rel="stylesheet" href="/ui/styles.css?v=20260324a" />
|
<link rel="stylesheet" href="/ui/styles.css?v=20260325b" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div data-partial="/ui/html/topbar.html"></div>
|
<div data-partial="/ui/html/topbar.html"></div>
|
||||||
|
|
||||||
<main class="grid">
|
<main class="grid-ops">
|
||||||
|
<div data-partial="/ui/html/ops-panel.html"></div>
|
||||||
<div data-partial="/ui/html/equipment-panel.html"></div>
|
<div data-partial="/ui/html/equipment-panel.html"></div>
|
||||||
<div data-partial="/ui/html/points-panel.html"></div>
|
<div data-partial="/ui/html/points-panel.html"></div>
|
||||||
<div data-partial="/ui/html/source-panel.html"></div>
|
<div data-partial="/ui/html/source-panel.html"></div>
|
||||||
|
<div data-partial="/ui/html/log-stream-panel.html"></div>
|
||||||
<div data-partial="/ui/html/logs-panel.html"></div>
|
<div data-partial="/ui/html/logs-panel.html"></div>
|
||||||
<div data-partial="/ui/html/chart-panel.html"></div>
|
<div data-partial="/ui/html/chart-panel.html"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -20,6 +22,6 @@
|
||||||
<div data-partial="/ui/html/modals.html"></div>
|
<div data-partial="/ui/html/modals.html"></div>
|
||||||
<div data-partial="/ui/html/api-doc-drawer.html"></div>
|
<div data-partial="/ui/html/api-doc-drawer.html"></div>
|
||||||
|
|
||||||
<script type="module" src="/ui/js/index.js?v=20260324a"></script>
|
<script type="module" src="/ui/js/index.js?v=20260325b"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ import {
|
||||||
resetEquipmentForm,
|
resetEquipmentForm,
|
||||||
saveEquipment,
|
saveEquipment,
|
||||||
} from "./equipment.js";
|
} from "./equipment.js";
|
||||||
import { startPointSocket } from "./logs.js";
|
import { startPointSocket, startLogs, stopLogs } from "./logs.js";
|
||||||
|
import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js";
|
||||||
import {
|
import {
|
||||||
clearBatchBinding,
|
clearBatchBinding,
|
||||||
browseAndLoadTree,
|
browseAndLoadTree,
|
||||||
|
|
@ -34,6 +35,36 @@ import { state } from "./state.js";
|
||||||
import { loadSources, saveSource } from "./sources.js";
|
import { loadSources, saveSource } from "./sources.js";
|
||||||
import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js";
|
import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js";
|
||||||
|
|
||||||
|
function switchView(view) {
|
||||||
|
state.activeView = view;
|
||||||
|
const main = document.querySelector("main");
|
||||||
|
main.className = view === "ops" ? "grid-ops" : "grid-config";
|
||||||
|
|
||||||
|
dom.tabOps.classList.toggle("active", view === "ops");
|
||||||
|
dom.tabConfig.classList.toggle("active", view === "config");
|
||||||
|
|
||||||
|
// config-only panels
|
||||||
|
["top-left", "top-right", "bottom-left", "bottom-right"].forEach((cls) => {
|
||||||
|
const el = main.querySelector(`.panel.${cls}`);
|
||||||
|
if (el) el.classList.toggle("hidden", view === "ops");
|
||||||
|
});
|
||||||
|
// bottom-mid is log-stream in config, hidden in ops
|
||||||
|
const logStreamPanel = main.querySelector(".panel.bottom-mid");
|
||||||
|
if (logStreamPanel) logStreamPanel.classList.toggle("hidden", view === "ops");
|
||||||
|
|
||||||
|
// ops-only panels
|
||||||
|
const opsMain = main.querySelector(".panel.ops-main");
|
||||||
|
const opsBottom = main.querySelector(".panel.ops-bottom");
|
||||||
|
if (opsMain) opsMain.classList.toggle("hidden", view === "config");
|
||||||
|
if (opsBottom) opsBottom.classList.toggle("hidden", view === "config");
|
||||||
|
|
||||||
|
if (view === "config") {
|
||||||
|
startLogs();
|
||||||
|
} else {
|
||||||
|
stopLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
|
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
|
||||||
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
|
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
|
||||||
|
|
@ -125,13 +156,23 @@ function bindEvents() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dom.tabOps.addEventListener("click", () => switchView("ops"));
|
||||||
|
dom.tabConfig.addEventListener("click", () => switchView("config"));
|
||||||
|
|
||||||
document.addEventListener("equipments-updated", () => {
|
document.addEventListener("equipments-updated", () => {
|
||||||
renderUnits();
|
renderUnits();
|
||||||
|
renderOpsUnits();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("units-loaded", () => {
|
||||||
|
renderOpsUnits();
|
||||||
|
if (!state.selectedOpsUnitId) loadAllEquipmentCards();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
bindEvents();
|
bindEvents();
|
||||||
|
switchView("ops");
|
||||||
renderSelectedNodes();
|
renderSelectedNodes();
|
||||||
updateSelectedPointSummary();
|
updateSelectedPointSummary();
|
||||||
updatePointFilterSummary();
|
updatePointFilterSummary();
|
||||||
|
|
@ -139,6 +180,7 @@ async function bootstrap() {
|
||||||
startPointSocket();
|
startPointSocket();
|
||||||
|
|
||||||
await withStatus(loadUnits());
|
await withStatus(loadUnits());
|
||||||
|
startOps();
|
||||||
await withStatus(loadSources());
|
await withStatus(loadSources());
|
||||||
await withStatus(loadEquipments());
|
await withStatus(loadEquipments());
|
||||||
await withStatus(loadEvents());
|
await withStatus(loadEvents());
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ const byId = (id) => document.getElementById(id);
|
||||||
|
|
||||||
export const dom = {
|
export const dom = {
|
||||||
statusText: byId("statusText"),
|
statusText: byId("statusText"),
|
||||||
|
tabOps: byId("tabOps"),
|
||||||
|
tabConfig: byId("tabConfig"),
|
||||||
|
opsUnitList: byId("opsUnitList"),
|
||||||
|
opsEquipmentArea: byId("opsEquipmentArea"),
|
||||||
|
logView: byId("logView"),
|
||||||
sourceList: byId("sourceList"),
|
sourceList: byId("sourceList"),
|
||||||
unitList: byId("unitList"),
|
unitList: byId("unitList"),
|
||||||
eventList: byId("eventList"),
|
eventList: byId("eventList"),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,60 @@
|
||||||
import { appendChartPoint } from "./chart.js";
|
import { appendChartPoint } from "./chart.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
import { prependEvent } from "./events.js";
|
import { prependEvent } from "./events.js";
|
||||||
import { formatValue } from "./points.js";
|
import { formatValue } from "./points.js";
|
||||||
import { state } from "./state.js";
|
import { state } from "./state.js";
|
||||||
import { renderUnits } from "./units.js";
|
import { renderUnits } from "./units.js";
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLogLine(line) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
||||||
|
try { return JSON.parse(trimmed); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendLog(line) {
|
||||||
|
if (!dom.logView) return;
|
||||||
|
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
|
||||||
|
const div = document.createElement("div");
|
||||||
|
const parsed = parseLogLine(line);
|
||||||
|
if (!parsed) {
|
||||||
|
div.className = "log-line";
|
||||||
|
div.textContent = line;
|
||||||
|
} else {
|
||||||
|
const levelRaw = (parsed.level || "").toString();
|
||||||
|
const level = levelRaw.toLowerCase();
|
||||||
|
div.className = `log-line${level ? ` level-${level}` : ""}`;
|
||||||
|
div.innerHTML = [
|
||||||
|
`<span class="level">${escapeHtml(levelRaw || "LOG")}</span>`,
|
||||||
|
parsed.timestamp ? `<span class="muted"> ${escapeHtml(parsed.timestamp)}</span>` : "",
|
||||||
|
parsed.target ? `<span class="muted"> ${escapeHtml(parsed.target)}</span>` : "",
|
||||||
|
`<span class="message">${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}</span>`,
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
dom.logView.appendChild(div);
|
||||||
|
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startLogs() {
|
||||||
|
if (state.logSource) return;
|
||||||
|
state.logSource = new EventSource("/api/logs/stream");
|
||||||
|
state.logSource.addEventListener("log", (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
(data.lines || []).forEach(appendLog);
|
||||||
|
});
|
||||||
|
state.logSource.addEventListener("error", () => appendLog("[log stream error]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopLogs() {
|
||||||
|
if (state.logSource) {
|
||||||
|
state.logSource.close();
|
||||||
|
state.logSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function startPointSocket() {
|
export function startPointSocket() {
|
||||||
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||||
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
|
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
|
||||||
|
|
@ -14,6 +65,8 @@ export function startPointSocket() {
|
||||||
const payload = JSON.parse(event.data);
|
const payload = JSON.parse(event.data);
|
||||||
if (payload.type === "PointNewValue" || payload.type === "point_new_value") {
|
if (payload.type === "PointNewValue" || payload.type === "point_new_value") {
|
||||||
const data = payload.data;
|
const data = payload.data;
|
||||||
|
|
||||||
|
// config view point table
|
||||||
const entry = state.pointEls.get(data.point_id);
|
const entry = state.pointEls.get(data.point_id);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
entry.value.textContent = formatValue(data);
|
entry.value.textContent = formatValue(data);
|
||||||
|
|
@ -22,6 +75,14 @@ export function startPointSocket() {
|
||||||
entry.time.textContent = data.timestamp || "--";
|
entry.time.textContent = data.timestamp || "--";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ops view signal cell
|
||||||
|
const opsEntry = state.opsPointEls.get(data.point_id);
|
||||||
|
if (opsEntry) {
|
||||||
|
opsEntry.valueEl.textContent = formatValue(data);
|
||||||
|
opsEntry.qualityEl.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`;
|
||||||
|
opsEntry.qualityEl.textContent = (data.quality || "unknown").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
if (state.chartPointId === data.point_id) {
|
if (state.chartPointId === data.point_id) {
|
||||||
appendChartPoint(data);
|
appendChartPoint(data);
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +97,8 @@ export function startPointSocket() {
|
||||||
const runtime = payload.data;
|
const runtime = payload.data;
|
||||||
state.runtimes.set(runtime.unit_id, runtime);
|
state.runtimes.set(runtime.unit_id, runtime);
|
||||||
renderUnits();
|
renderUnits();
|
||||||
|
// lazy import to avoid circular dep (ops.js -> logs.js -> ops.js)
|
||||||
|
import("./ops.js").then(({ renderOpsUnits }) => renderOpsUnits());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { formatValue } from "./points.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
const SIGNAL_ROLES = ["rem", "run", "flt"];
|
||||||
|
const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" };
|
||||||
|
|
||||||
|
export function renderOpsUnits() {
|
||||||
|
if (!dom.opsUnitList) return;
|
||||||
|
dom.opsUnitList.innerHTML = "";
|
||||||
|
|
||||||
|
if (!state.units.length) {
|
||||||
|
dom.opsUnitList.innerHTML = '<div class="muted" style="padding:12px">暂无控制单元</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.units.forEach((unit) => {
|
||||||
|
const runtime = state.runtimes.get(unit.id);
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = `ops-unit-item${state.selectedOpsUnitId === unit.id ? " selected" : ""}`;
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="ops-unit-item-name">${unit.code} / ${unit.name}</div>
|
||||||
|
<div class="ops-unit-item-meta">
|
||||||
|
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
|
||||||
|
${runtime ? `<span>Acc ${Math.floor(runtime.accumulated_run_sec / 1000)}s</span>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
item.addEventListener("click", () => selectOpsUnit(unit.id));
|
||||||
|
dom.opsUnitList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectOpsUnit(unitId) {
|
||||||
|
state.selectedOpsUnitId = unitId === state.selectedOpsUnitId ? null : unitId;
|
||||||
|
renderOpsUnits();
|
||||||
|
|
||||||
|
if (!state.selectedOpsUnitId) {
|
||||||
|
await loadAllEquipmentCards();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">加载中...</div>';
|
||||||
|
state.opsPointEls.clear();
|
||||||
|
|
||||||
|
const detail = await apiFetch(`/api/unit/${state.selectedOpsUnitId}/detail`);
|
||||||
|
renderOpsEquipments(detail.equipments || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAllEquipmentCards() {
|
||||||
|
if (!dom.opsEquipmentArea) return;
|
||||||
|
if (!state.units.length) {
|
||||||
|
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">暂无控制单元</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">加载中...</div>';
|
||||||
|
state.opsPointEls.clear();
|
||||||
|
|
||||||
|
const details = await Promise.all(
|
||||||
|
state.units.map((u) => apiFetch(`/api/unit/${u.id}/detail`).catch(() => ({ equipments: [] })))
|
||||||
|
);
|
||||||
|
const allEquipments = details.flatMap((d) => d.equipments || []);
|
||||||
|
renderOpsEquipments(allEquipments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOpsEquipments(equipments) {
|
||||||
|
dom.opsEquipmentArea.innerHTML = "";
|
||||||
|
if (!equipments.length) {
|
||||||
|
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">该单元下暂无设备</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
equipments.forEach((eq) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "ops-eq-card";
|
||||||
|
|
||||||
|
// Build role → point map
|
||||||
|
const roleMap = {};
|
||||||
|
(eq.points || []).forEach((p) => {
|
||||||
|
if (p.signal_role) roleMap[p.signal_role] = p;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signal rows HTML (placeholders; WS will fill values)
|
||||||
|
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="badge quality-unknown" data-ops-quality="${point.id}">?</span>
|
||||||
|
<span class="ops-signal-value" data-ops-value="${point.id}">--</span>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor";
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="ops-eq-card-head">
|
||||||
|
<strong title="${eq.name}">${eq.code}</strong>
|
||||||
|
<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"></div>' : ""}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (canControl) {
|
||||||
|
const actions = card.querySelector(".ops-eq-card-actions");
|
||||||
|
const startBtn = document.createElement("button");
|
||||||
|
startBtn.className = "secondary";
|
||||||
|
startBtn.textContent = "Start";
|
||||||
|
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.addEventListener("click", () =>
|
||||||
|
apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {})
|
||||||
|
);
|
||||||
|
actions.append(startBtn, stopBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.opsEquipmentArea.appendChild(card);
|
||||||
|
|
||||||
|
// Register DOM elements for WS updates, then seed from cached monitor data
|
||||||
|
SIGNAL_ROLES.forEach((role) => {
|
||||||
|
const point = roleMap[role];
|
||||||
|
if (!point) return;
|
||||||
|
const valueEl = card.querySelector(`[data-ops-value="${point.id}"]`);
|
||||||
|
const qualityEl = card.querySelector(`[data-ops-quality="${point.id}"]`);
|
||||||
|
if (valueEl && qualityEl) {
|
||||||
|
state.opsPointEls.set(point.id, { valueEl, qualityEl });
|
||||||
|
if (point.point_monitor) {
|
||||||
|
const m = point.point_monitor;
|
||||||
|
valueEl.textContent = formatValue(m);
|
||||||
|
qualityEl.className = `badge quality-${(m.quality || "unknown").toLowerCase()}`;
|
||||||
|
qualityEl.textContent = (m.quality || "unknown").toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOps() {
|
||||||
|
renderOpsUnits();
|
||||||
|
loadAllEquipmentCards();
|
||||||
|
}
|
||||||
|
|
@ -21,4 +21,8 @@ export const state = {
|
||||||
pointSocket: null,
|
pointSocket: null,
|
||||||
apiDocLoaded: false,
|
apiDocLoaded: false,
|
||||||
runtimes: new Map(), // unit_id -> UnitRuntime
|
runtimes: new Map(), // unit_id -> UnitRuntime
|
||||||
|
activeView: "ops", // "ops" | "config"
|
||||||
|
opsPointEls: new Map(), // point_id -> { valueEl, qualityEl }
|
||||||
|
logSource: null,
|
||||||
|
selectedOpsUnitId: null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,7 @@ export async function loadUnits() {
|
||||||
renderUnits();
|
renderUnits();
|
||||||
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
|
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
|
||||||
renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId);
|
renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId);
|
||||||
|
document.dispatchEvent(new Event("units-loaded"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveUnit(event) {
|
export async function saveUnit(event) {
|
||||||
|
|
|
||||||
196
web/styles.css
196
web/styles.css
|
|
@ -59,6 +59,30 @@ body {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tabs ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.link-button {
|
.link-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -79,19 +103,33 @@ body {
|
||||||
|
|
||||||
/* ── Grid Layout ────────────────────────────────── */
|
/* ── Grid Layout ────────────────────────────────── */
|
||||||
|
|
||||||
.grid {
|
.grid-ops,
|
||||||
|
.grid-config {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
|
|
||||||
grid-template-rows: 1fr 380px;
|
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
height: calc(100vh - var(--topbar-h));
|
height: calc(100vh - var(--topbar-h));
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel.top-left { grid-column: 1; grid-row: 1; }
|
.grid-config {
|
||||||
.panel.top-right { grid-column: 2 / 4; grid-row: 1; }
|
grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
|
||||||
.panel.bottom-left { grid-column: 1; grid-row: 2; min-height: 0; }
|
grid-template-rows: 1fr 380px;
|
||||||
.panel.bottom-middle { grid-column: 2; grid-row: 2; min-height: 0; }
|
}
|
||||||
.panel.bottom-right { grid-column: 3; grid-row: 2; min-height: 0; }
|
|
||||||
|
.grid-ops {
|
||||||
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
|
grid-template-rows: 1fr 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* config view slot assignments */
|
||||||
|
.grid-config .panel.top-left { grid-column: 1; grid-row: 1; }
|
||||||
|
.grid-config .panel.top-right { grid-column: 2 / 4; grid-row: 1; }
|
||||||
|
.grid-config .panel.bottom-left { grid-column: 1; grid-row: 2; }
|
||||||
|
.grid-config .panel.bottom-mid { grid-column: 2; grid-row: 2; }
|
||||||
|
.grid-config .panel.bottom-right{ grid-column: 3; grid-row: 2; }
|
||||||
|
|
||||||
|
/* ops view slot assignments */
|
||||||
|
.grid-ops .panel.ops-main { grid-column: 1 / 3; grid-row: 1; }
|
||||||
|
.grid-ops .panel.ops-bottom { grid-column: 1 / 3; grid-row: 2; }
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|
@ -119,6 +157,138 @@ body {
|
||||||
border-top: 1px solid var(--border-light);
|
border-top: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Ops View ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.ops-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-unit-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-unit-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-equipment-area {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-placeholder {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Equipment ops card */
|
||||||
|
.ops-eq-card {
|
||||||
|
width: 220px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-eq-card-head {
|
||||||
|
padding: 8px 10px 6px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-eq-card-head strong {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-signal-rows {
|
||||||
|
padding: 6px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-signal-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-signal-label {
|
||||||
|
width: 36px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-signal-value {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-eq-card-actions {
|
||||||
|
padding: 6px 10px 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-eq-card-actions button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 3px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ops unit list item */
|
||||||
|
.ops-unit-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-unit-item:hover { background: var(--accent-bg); }
|
||||||
|
.ops-unit-item.selected {
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-unit-item-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-unit-item-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Panel Header ───────────────────────────────── */
|
/* ── Panel Header ───────────────────────────────── */
|
||||||
|
|
||||||
.panel-head {
|
.panel-head {
|
||||||
|
|
@ -585,6 +755,7 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
.modal.hidden { display: none; }
|
.modal.hidden { display: none; }
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
|
|
@ -1084,7 +1255,8 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
/* ── Responsive ───────────────────────────────────── */
|
/* ── Responsive ───────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.grid {
|
.grid-config,
|
||||||
|
.grid-ops {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: auto auto auto auto;
|
grid-template-rows: auto auto auto auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
@ -1092,9 +1264,9 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
body { height: auto; overflow: auto; }
|
body { height: auto; overflow: auto; }
|
||||||
.panel.top-left { min-height: 200px; }
|
.panel.top-left { min-height: 200px; }
|
||||||
.panel.top-right { min-height: 300px; }
|
.panel.top-right { min-height: 300px; }
|
||||||
.panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
|
.grid-config .panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
|
||||||
.panel.bottom-middle { grid-column: 1; grid-row: 4; min-height: 200px; }
|
.grid-config .panel.bottom-mid { grid-column: 1; grid-row: 4; min-height: 200px; }
|
||||||
.panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; }
|
.grid-config .panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; }
|
||||||
.drawer { width: 100vw; }
|
.drawer { width: 100vw; }
|
||||||
.drawer-body { grid-template-columns: 1fr; }
|
.drawer-body { grid-template-columns: 1fr; }
|
||||||
.equipment-layout { grid-template-columns: 1fr; }
|
.equipment-layout { grid-template-columns: 1fr; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue