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)]
|
||||
pub struct EquipmentDetail {
|
||||
#[serde(flatten)]
|
||||
pub equipment: crate::model::Equipment,
|
||||
pub points: Vec<crate::model::Point>,
|
||||
pub points: Vec<PointDetail>,
|
||||
}
|
||||
|
||||
#[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 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
|
||||
.into_iter()
|
||||
.map(|eq| {
|
||||
let points = all_points
|
||||
.iter()
|
||||
.filter(|p| p.equipment_id == Some(eq.id))
|
||||
.cloned()
|
||||
.map(|p| PointDetail {
|
||||
point_monitor: monitor_guard.get(&p.id).cloned(),
|
||||
point: p.clone(),
|
||||
})
|
||||
.collect();
|
||||
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">
|
||||
<h2>系统事件</h2>
|
||||
<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">
|
||||
<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">
|
||||
<button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button>
|
||||
<button type="button" class="secondary" id="openApiDoc">API.md</button>
|
||||
|
|
|
|||
|
|
@ -4,15 +4,17 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PLC Control</title>
|
||||
<link rel="stylesheet" href="/ui/styles.css?v=20260324a" />
|
||||
<link rel="stylesheet" href="/ui/styles.css?v=20260325b" />
|
||||
</head>
|
||||
<body>
|
||||
<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/points-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/chart-panel.html"></div>
|
||||
</main>
|
||||
|
|
@ -20,6 +22,6 @@
|
|||
<div data-partial="/ui/html/modals.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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import {
|
|||
resetEquipmentForm,
|
||||
saveEquipment,
|
||||
} from "./equipment.js";
|
||||
import { startPointSocket } from "./logs.js";
|
||||
import { startPointSocket, startLogs, stopLogs } from "./logs.js";
|
||||
import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js";
|
||||
import {
|
||||
clearBatchBinding,
|
||||
browseAndLoadTree,
|
||||
|
|
@ -34,6 +35,36 @@ import { state } from "./state.js";
|
|||
import { loadSources, saveSource } from "./sources.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() {
|
||||
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(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", () => {
|
||||
renderUnits();
|
||||
renderOpsUnits();
|
||||
});
|
||||
|
||||
document.addEventListener("units-loaded", () => {
|
||||
renderOpsUnits();
|
||||
if (!state.selectedOpsUnitId) loadAllEquipmentCards();
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
bindEvents();
|
||||
switchView("ops");
|
||||
renderSelectedNodes();
|
||||
updateSelectedPointSummary();
|
||||
updatePointFilterSummary();
|
||||
|
|
@ -139,6 +180,7 @@ async function bootstrap() {
|
|||
startPointSocket();
|
||||
|
||||
await withStatus(loadUnits());
|
||||
startOps();
|
||||
await withStatus(loadSources());
|
||||
await withStatus(loadEquipments());
|
||||
await withStatus(loadEvents());
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ const byId = (id) => document.getElementById(id);
|
|||
|
||||
export const dom = {
|
||||
statusText: byId("statusText"),
|
||||
tabOps: byId("tabOps"),
|
||||
tabConfig: byId("tabConfig"),
|
||||
opsUnitList: byId("opsUnitList"),
|
||||
opsEquipmentArea: byId("opsEquipmentArea"),
|
||||
logView: byId("logView"),
|
||||
sourceList: byId("sourceList"),
|
||||
unitList: byId("unitList"),
|
||||
eventList: byId("eventList"),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,60 @@
|
|||
import { appendChartPoint } from "./chart.js";
|
||||
import { dom } from "./dom.js";
|
||||
import { prependEvent } from "./events.js";
|
||||
import { formatValue } from "./points.js";
|
||||
import { state } from "./state.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() {
|
||||
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
|
||||
|
|
@ -14,6 +65,8 @@ export function startPointSocket() {
|
|||
const payload = JSON.parse(event.data);
|
||||
if (payload.type === "PointNewValue" || payload.type === "point_new_value") {
|
||||
const data = payload.data;
|
||||
|
||||
// config view point table
|
||||
const entry = state.pointEls.get(data.point_id);
|
||||
if (entry) {
|
||||
entry.value.textContent = formatValue(data);
|
||||
|
|
@ -22,6 +75,14 @@ export function startPointSocket() {
|
|||
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) {
|
||||
appendChartPoint(data);
|
||||
}
|
||||
|
|
@ -36,6 +97,8 @@ export function startPointSocket() {
|
|||
const runtime = payload.data;
|
||||
state.runtimes.set(runtime.unit_id, runtime);
|
||||
renderUnits();
|
||||
// lazy import to avoid circular dep (ops.js -> logs.js -> ops.js)
|
||||
import("./ops.js").then(({ renderOpsUnits }) => renderOpsUnits());
|
||||
return;
|
||||
}
|
||||
} 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,
|
||||
apiDocLoaded: false,
|
||||
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();
|
||||
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
|
||||
renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId);
|
||||
document.dispatchEvent(new Event("units-loaded"));
|
||||
}
|
||||
|
||||
export async function saveUnit(event) {
|
||||
|
|
|
|||
196
web/styles.css
196
web/styles.css
|
|
@ -59,6 +59,30 @@ body {
|
|||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -79,19 +103,33 @@ body {
|
|||
|
||||
/* ── Grid Layout ────────────────────────────────── */
|
||||
|
||||
.grid {
|
||||
.grid-ops,
|
||||
.grid-config {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
|
||||
grid-template-rows: 1fr 380px;
|
||||
gap: 1px;
|
||||
height: calc(100vh - var(--topbar-h));
|
||||
}
|
||||
|
||||
.panel.top-left { grid-column: 1; grid-row: 1; }
|
||||
.panel.top-right { grid-column: 2 / 4; grid-row: 1; }
|
||||
.panel.bottom-left { grid-column: 1; grid-row: 2; min-height: 0; }
|
||||
.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-config {
|
||||
grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
|
||||
grid-template-rows: 1fr 380px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: var(--surface);
|
||||
|
|
@ -119,6 +157,138 @@ body {
|
|||
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-head {
|
||||
|
|
@ -585,6 +755,7 @@ button.danger:hover { background: var(--danger-hover); }
|
|||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
.modal.hidden { display: none; }
|
||||
|
||||
.modal-content {
|
||||
|
|
@ -1084,7 +1255,8 @@ button.danger:hover { background: var(--danger-hover); }
|
|||
/* ── Responsive ───────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid {
|
||||
.grid-config,
|
||||
.grid-ops {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto auto;
|
||||
height: auto;
|
||||
|
|
@ -1092,9 +1264,9 @@ button.danger:hover { background: var(--danger-hover); }
|
|||
body { height: auto; overflow: auto; }
|
||||
.panel.top-left { min-height: 200px; }
|
||||
.panel.top-right { min-height: 300px; }
|
||||
.panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
|
||||
.panel.bottom-middle { 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-left { grid-column: 1; grid-row: 3; min-height: 200px; }
|
||||
.grid-config .panel.bottom-mid { grid-column: 1; grid-row: 4; min-height: 200px; }
|
||||
.grid-config .panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; }
|
||||
.drawer { width: 100vw; }
|
||||
.drawer-body { grid-template-columns: 1fr; }
|
||||
.equipment-layout { grid-template-columns: 1fr; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue