From 07239ebff122ec311cfbc3858a38f4233cdcccf2 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 29 Jun 2026 13:39:19 +0800 Subject: [PATCH] Unify two apps' UI: shared platform config + matching tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move shared platform JS (source/point/equipment/chart/events/...) from web/feeder/js to web/core/js/platform; both apps load it via the ServeDir core fallback (subdir avoids shadowing ops' own api.js/dom.js). - Add platform-config.js and log-stream.js as shared modules; feeder app.js and logs.js now delegate instead of inlining (no duplication). - ops: add 平台配置 tab reusing the shared panels; rename 段/工位配置 -> 应用配置; move tabs into the topbar like feeder. - Align view layout across both apps: 系统事件 in 运行监控 (live via WS), 实时日志 in 平台配置. - Titles: 投煤控制系统 / 隧道窑运转系统; feeder 运维 tab -> 运行监控. - Update README; add CLAUDE.md documenting the shared-frontend architecture. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 42 ++++++ README.md | 14 +- web/{feeder/js => core/js/platform}/api.js | 0 web/{feeder/js => core/js/platform}/chart.js | 0 web/{feeder/js => core/js/platform}/docs.js | 0 web/{feeder/js => core/js/platform}/dom.js | 0 .../js => core/js/platform}/equipment.js | 0 web/{feeder/js => core/js/platform}/events.js | 0 web/core/js/platform/log-stream.js | 73 +++++++++ web/core/js/platform/platform-config.js | 127 ++++++++++++++++ web/{feeder/js => core/js/platform}/points.js | 0 web/{feeder/js => core/js/platform}/roles.js | 0 .../js => core/js/platform}/sources.js | 0 web/{feeder/js => core/js/platform}/state.js | 0 web/feeder/html/topbar.html | 4 +- web/feeder/index.html | 2 +- web/feeder/js/app.js | 138 +++--------------- web/feeder/js/logs.js | 80 ++-------- web/feeder/js/ops.js | 6 +- web/feeder/js/units.js | 10 +- web/ops/html/topbar.html | 7 +- web/ops/index.html | 19 ++- web/ops/js/app.js | 2 + web/ops/js/views.js | 47 ++++-- web/ops/js/ws.js | 8 + web/ops/ops-styles.css | 52 +++++-- 26 files changed, 399 insertions(+), 232 deletions(-) create mode 100644 CLAUDE.md rename web/{feeder/js => core/js/platform}/api.js (100%) rename web/{feeder/js => core/js/platform}/chart.js (100%) rename web/{feeder/js => core/js/platform}/docs.js (100%) rename web/{feeder/js => core/js/platform}/dom.js (100%) rename web/{feeder/js => core/js/platform}/equipment.js (100%) rename web/{feeder/js => core/js/platform}/events.js (100%) create mode 100644 web/core/js/platform/log-stream.js create mode 100644 web/core/js/platform/platform-config.js rename web/{feeder/js => core/js/platform}/points.js (100%) rename web/{feeder/js => core/js/platform}/roles.js (100%) rename web/{feeder/js => core/js/platform}/sources.js (100%) rename web/{feeder/js => core/js/platform}/state.js (100%) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..29fd031 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# CLAUDE.md + +本仓库是一个 Cargo workspace,承载两个 PLC 上位机应用,共享一个平台核心库与一套前端基础设施。 + +## 两个应用 + +- **投煤控制系统** — crate `app_feeder_distributor`,前端 `web/feeder/`,默认端口 `60309`,单实例锁名 `PLCControl.FeederDistributor`(约定)。业务模型是 `control unit + run/stop/acc/bl 时长`。 +- **隧道窑运转系统** — crate `app_operation_system`,前端 `web/ops/`,默认端口 `3100`,单实例锁名 `PLCControl.OperationSystem`。业务模型是 `工位 + 流程段(segment) + 步骤 + 联锁 + 完成确认`,不要套用 feeder 的 unit 模型。 +- 共享核心库 `plc_platform_core`:config / db / connection(OPC UA) / event / websocket / service / 平台 handler。 + +两个应用页签完全一致:**运行监控 / 应用配置 / 平台配置**,布局也对齐——**系统事件在「运行监控」**,**实时日志(SSE)在「平台配置」**。feeder 的「应用配置」是控制单元,ops 的是段/工位配置。 + +## 前端架构(关键、易踩坑) + +- `/ui` 路由 = `ServeDir(应用目录).fallback(ServeDir("web/core"))`(见 `plc_platform_core/src/http.rs::static_ui_routes`)。**物理放在 `web/core/` 的文件,对两个应用都暴露在相同的 `/ui/*` URL**。所以共享前端无需改 import 路径,只要移动文件位置。 +- **共享平台 JS 在 `web/core/js/platform/`**(api/dom/state/roles/sources/points/equipment/events/chart/docs/platform-config)。放子目录是有意为之:`web/ops/js/` 自带 **同名但不同内容**的 `api.js`、`dom.js`(导出 `segmentApi`/`el`,而非 `apiFetch`/`dom`)。若把共享模块放 `web/core/js/` 顶层,会被 ops 本地同名文件 shadow。放 `js/platform/` 子目录后两个应用都回退到 core,得到单一实例。 +- 平台配置页的事件绑定 / 初始化 / 数据加载统一在 `web/core/js/platform/platform-config.js`:`bindPlatformConfigEvents()` / `initPlatformConfigUi()` / `loadPlatformConfig()`,全部 null-guard(各应用只含部分面板)。feeder 的 `app.js` 和 ops 的 `views.js` 都调用它,**不要再复制这套逻辑**。 +- `web/core/js/platform/events.js` 在**模块加载时**就给 `dom.eventList` 加 scroll 监听 → 凡引入平台 JS(events.js)的页面,DOM 里必须有 `#eventList`(`logs-panel.html`,现放在「运行监控」),否则 import 链在加载期就崩。 +- 实时日志(SSE `/api/logs/stream` → `#logView`)在共享的 `web/core/js/platform/log-stream.js`(`startLogs`/`stopLogs`),feeder 与 ops 的「平台配置」都用它。 +- HTML 是分片 `data-partial`,由各 `index.js` 先加载完所有 partial 再 `import('./app.js')`;core 的 `dom.js` 在 import 期 `byId`,依赖这个加载顺序。 +- 静态文件 ServeDir 直接读磁盘,**改 JS/HTML/CSS 不用重新编译**,刷新即可;改 Rust 才需重启进程(注意单实例锁,旧进程不退会占锁)。 + +## 后端要点 + +- ops 的 router 已 `merge(plc_platform_core::handler::platform_routes())`,平台 CRUD(source/point/equipment/tag/page)两个应用共用。`/api/event` 是 **ops 自己的** `runtime_routes`,不在 platform_routes 里。 +- **数据库迁移不自动执行**(`db.rs` 注释)。首次启动前手动跑 `migrations/`:`sqlx migrate run --source migrations`。 +- 事件类型用命名空间前缀:`platform.*` / `feeder.*` / `ops.*`。 + +## 构建 / 运行 + +```powershell +cargo build -p app_operation_system # 或 -p app_feeder_distributor +cargo run -p app_operation_system # 开发态 +``` + +ops 调试用环境变量:`OPS_SEED_TEMPLATES=1`(写入 12 段+11 工位骨架)、`SIMULATE_PLC=1`(自动回写确认信号,无 PLC 也能跑通段)。详见 `run.md`。 + +## 文档 + +- 运转系统方案:`docs/运转系统实现方案.md` +- 双应用共享核心设计:`docs/superpowers/specs/2026-04-14-dual-app-shared-core-design.md` +- API:`docs/api-feeder.md`、`docs/api-ops.md` diff --git a/README.md b/README.md index 580ca65..c810449 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,9 @@ plc_control/ app_feeder_distributor/ # 投煤器布料机专用版 app_operation_system/ # 运转系统专用版 web/ - core/ # 共享 HTML/CSS(数据源、点位、设备、图表、日志等) - feeder/ # 投煤器布料机页面 + JS - ops/ # 运转系统页面 + JS + core/ # 共享 HTML/CSS + 平台 JS(数据源、点位、设备、图表、日志等) + feeder/ # 投煤控制系统页面 + 业务 JS + ops/ # 隧道窑运转系统页面 + 业务 JS ``` ### 共享平台核心库 (`plc_platform_core`) @@ -122,11 +122,11 @@ deploy/ 前端采用原生 ES Module 和分片 HTML 结构,按应用拆分目录: -- `web/core/` — 共享 HTML 面板(数据源、点位、设备、图表、日志、文档抽屉)和样式 -- `web/feeder/` — 投煤器专用入口、运维面板、控制单元表单、全部 JS 模块 -- `web/ops/` — 运转系统专用入口(开发中) +- `web/core/` — 共享 HTML 面板(数据源、点位、设备、图表、日志、文档抽屉)、样式,以及共享平台 JS `web/core/js/platform/`(数据源 / 点位 / 设备配置逻辑,feeder 与 ops 共用,避免重复) +- `web/feeder/`(投煤控制系统)— 三个页签:运行监控 / 应用配置 / 平台配置;仅保留投煤业务 JS(控制单元、运行监控卡片等) +- `web/ops/`(隧道窑运转系统)— 三个页签:运行监控 / 应用配置 / 平台配置;仅保留运转业务 JS(段、工位、联锁) -每个应用的 Axum 路由使用 `ServeDir` 回退链:先查应用目录,再查 core 目录,URL 路径无需变化。 +每个应用的 Axum 路由使用 `ServeDir` 回退链:先查应用目录,再查 core 目录,URL 路径无需变化。共享平台 JS 正是依赖该回退链——它放在 `web/core/js/platform/` 子目录,两个应用本地都没有同名子目录,故都回退到 core,得到同一份实例(也因此避开了 ops 自带 `web/ops/js/api.js`、`dom.js` 的同名冲突)。 ## 实时日志设计 diff --git a/web/feeder/js/api.js b/web/core/js/platform/api.js similarity index 100% rename from web/feeder/js/api.js rename to web/core/js/platform/api.js diff --git a/web/feeder/js/chart.js b/web/core/js/platform/chart.js similarity index 100% rename from web/feeder/js/chart.js rename to web/core/js/platform/chart.js diff --git a/web/feeder/js/docs.js b/web/core/js/platform/docs.js similarity index 100% rename from web/feeder/js/docs.js rename to web/core/js/platform/docs.js diff --git a/web/feeder/js/dom.js b/web/core/js/platform/dom.js similarity index 100% rename from web/feeder/js/dom.js rename to web/core/js/platform/dom.js diff --git a/web/feeder/js/equipment.js b/web/core/js/platform/equipment.js similarity index 100% rename from web/feeder/js/equipment.js rename to web/core/js/platform/equipment.js diff --git a/web/feeder/js/events.js b/web/core/js/platform/events.js similarity index 100% rename from web/feeder/js/events.js rename to web/core/js/platform/events.js diff --git a/web/core/js/platform/log-stream.js b/web/core/js/platform/log-stream.js new file mode 100644 index 0000000..a90c5ff --- /dev/null +++ b/web/core/js/platform/log-stream.js @@ -0,0 +1,73 @@ +// Shared real-time log stream (SSE /api/logs/stream -> #logView). +// Depends only on the platform dom/state, so both feeder and ops can use it. +import { dom } from "./dom.js"; +import { state } from "./state.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; + } +} + +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 = [ + `${escapeHtml(levelRaw || "LOG")}`, + parsed.timestamp ? ` ${escapeHtml(parsed.timestamp)}` : "", + parsed.target ? ` ${escapeHtml(parsed.target)}` : "", + `${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}`, + ].join(""); + } + dom.logView.appendChild(div); + if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight; +} + +function appendLogDivider(text) { + if (!dom.logView) return; + const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10; + const div = document.createElement("div"); + div.className = "log-line muted"; + div.textContent = text; + dom.logView.appendChild(div); + if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight; +} + +export function startLogs() { + if (state.logSource) return; + let currentLogFile = null; + state.logSource = new EventSource("/api/logs/stream"); + state.logSource.addEventListener("log", (event) => { + const data = JSON.parse(event.data); + if (data.reset && data.file && data.file !== currentLogFile) { + appendLogDivider(`[log switched to ${data.file}]`); + } + currentLogFile = data.file || currentLogFile; + (data.lines || []).forEach(appendLog); + }); + state.logSource.addEventListener("error", () => appendLog("[log stream error]")); +} + +export function stopLogs() { + if (state.logSource) { + state.logSource.close(); + state.logSource = null; + } +} diff --git a/web/core/js/platform/platform-config.js b/web/core/js/platform/platform-config.js new file mode 100644 index 0000000..825bbbe --- /dev/null +++ b/web/core/js/platform/platform-config.js @@ -0,0 +1,127 @@ +// Shared platform-config wiring (data source / point / equipment management). +// Used by both feeder and ops so the heavy module logic lives in one place. +// Every listener is null-guarded because each app includes only a subset of +// the platform partials (e.g. ops has no README/API doc buttons). +import { withStatus } from "./api.js"; +import { dom } from "./dom.js"; +import { openChart, renderChart } from "./chart.js"; +import { loadEvents } from "./events.js"; +import { + clearPointBinding, + closeEquipmentModal, + loadEquipments, + openCreateEquipmentModal, + resetEquipmentForm, + saveEquipment, +} from "./equipment.js"; +import { + browseAndLoadTree, + clearBatchBinding, + clearSelectedPoints, + createPoints, + loadPoints, + loadTree, + openBatchBinding, + openPointCreateModal, + renderSelectedNodes, + savePointBinding, + saveBatchBinding, + updatePointFilterSummary, + updateSelectedPointSummary, +} from "./points.js"; +import { state } from "./state.js"; +import { loadSources, saveSource } from "./sources.js"; + +const on = (elm, evt, fn) => { + if (elm) elm.addEventListener(evt, fn); +}; + +/** Bind every platform-config DOM listener. Safe to call when some elements are absent. */ +export function bindPlatformConfigEvents() { + on(dom.sourceForm, "submit", (event) => withStatus(saveSource(event))); + on(dom.equipmentForm, "submit", (event) => withStatus(saveEquipment(event))); + on(dom.pointBindingForm, "submit", (event) => withStatus(savePointBinding(event))); + on(dom.batchBindingForm, "submit", (event) => withStatus(saveBatchBinding(event))); + + on(dom.sourceResetBtn, "click", () => dom.sourceForm && dom.sourceForm.reset()); + on(dom.equipmentResetBtn, "click", resetEquipmentForm); + on(dom.refreshEquipmentBtn, "click", () => withStatus(loadEquipments())); + on(dom.newEquipmentBtn, "click", openCreateEquipmentModal); + on(dom.closeEquipmentModalBtn, "click", closeEquipmentModal); + + on(dom.openPointModalBtn, "click", openPointCreateModal); + on(dom.pointSourceSelect, "change", () => { + if (dom.nodeTree) dom.nodeTree.innerHTML = '
点击"加载节点"获取节点树
'; + if (dom.pointSourceNodeCount) dom.pointSourceNodeCount.textContent = "节点: 0"; + }); + on(dom.browseNodesBtn, "click", () => withStatus(browseAndLoadTree())); + on(dom.refreshTreeBtn, "click", () => withStatus(loadTree())); + on(dom.createPointsBtn, "click", () => withStatus(createPoints())); + on(dom.closeModalBtn, "click", () => dom.pointModal.classList.add("hidden")); + + on(dom.openSourceFormBtn, "click", () => { + dom.sourceForm.reset(); + dom.sourceId.value = ""; + dom.sourceModal.classList.remove("hidden"); + }); + on(dom.closeSourceModalBtn, "click", () => dom.sourceModal.classList.add("hidden")); + + on(dom.clearPointBindingBtn, "click", () => withStatus(clearPointBinding())); + on(dom.closePointBindingModalBtn, "click", () => dom.pointBindingModal.classList.add("hidden")); + + on(dom.openBatchBindingBtn, "click", openBatchBinding); + on(dom.clearSelectedPointsBtn, "click", clearSelectedPoints); + on(dom.closeBatchBindingModalBtn, "click", () => dom.batchBindingModal.classList.add("hidden")); + on(dom.clearBatchBindingBtn, "click", () => withStatus(clearBatchBinding())); + + on(dom.toggleAllPoints, "change", () => { + const checked = dom.toggleAllPoints.checked; + dom.pointList.querySelectorAll('input[data-point-select="true"]').forEach((input) => { + input.checked = checked; + input.dispatchEvent(new Event("change")); + }); + }); + + on(dom.refreshChartBtn, "click", () => { + if (!state.chartPointId) return; + withStatus(openChart(state.chartPointId, state.chartPointName)); + }); + + on(dom.prevPointsBtn, "click", () => { + if (state.pointsPage > 1) { + state.pointsPage -= 1; + withStatus(loadPoints()); + } + }); + on(dom.nextPointsBtn, "click", () => { + const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize)); + if (state.pointsPage < totalPages) { + state.pointsPage += 1; + withStatus(loadPoints()); + } + }); + + on(dom.equipmentKeyword, "keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + withStatus(loadEquipments()); + } + }); + + on(dom.refreshEventBtn, "click", () => withStatus(loadEvents())); +} + +/** Initialise the static text bits of the platform-config UI. */ +export function initPlatformConfigUi() { + renderSelectedNodes(); + updateSelectedPointSummary(); + updatePointFilterSummary(); + renderChart(); +} + +/** Load all platform-config data (sources, equipment, points). Events live in the + * monitoring view, not here, so they are loaded by each app's bootstrap. */ +export async function loadPlatformConfig() { + await Promise.all([loadSources(), loadEquipments()]); + await loadPoints(); +} diff --git a/web/feeder/js/points.js b/web/core/js/platform/points.js similarity index 100% rename from web/feeder/js/points.js rename to web/core/js/platform/points.js diff --git a/web/feeder/js/roles.js b/web/core/js/platform/roles.js similarity index 100% rename from web/feeder/js/roles.js rename to web/core/js/platform/roles.js diff --git a/web/feeder/js/sources.js b/web/core/js/platform/sources.js similarity index 100% rename from web/feeder/js/sources.js rename to web/core/js/platform/sources.js diff --git a/web/feeder/js/state.js b/web/core/js/platform/state.js similarity index 100% rename from web/feeder/js/state.js rename to web/core/js/platform/state.js diff --git a/web/feeder/html/topbar.html b/web/feeder/html/topbar.html index b36defc..b1f6492 100644 --- a/web/feeder/html/topbar.html +++ b/web/feeder/html/topbar.html @@ -1,7 +1,7 @@
-
投煤器布料机控制系统
+
投煤控制系统
- +
diff --git a/web/feeder/index.html b/web/feeder/index.html index 42c19ec..1dcf120 100644 --- a/web/feeder/index.html +++ b/web/feeder/index.html @@ -3,7 +3,7 @@ - PLC Control + 投煤控制系统 diff --git a/web/feeder/js/app.js b/web/feeder/js/app.js index fb6d762..3e24f3b 100644 --- a/web/feeder/js/app.js +++ b/web/feeder/js/app.js @@ -1,36 +1,25 @@ -import { withStatus } from "./api.js"; -import { openChart, renderChart } from "./chart.js"; -import { dom } from "./dom.js"; -import { closeApiDocDrawer, openApiDocDrawer, openReadmeDrawer } from "./docs.js"; -import { loadEvents } from "./events.js"; +import { withStatus } from "./platform/api.js"; +import { dom } from "./platform/dom.js"; +import { closeApiDocDrawer, openApiDocDrawer, openReadmeDrawer } from "./platform/docs.js"; +import { loadEvents } from "./platform/events.js"; +import { loadEquipments } from "./platform/equipment.js"; import { - clearPointBinding, - closeEquipmentModal, - loadEquipments, - openCreateEquipmentModal, - resetEquipmentForm, - saveEquipment, -} from "./equipment.js"; + bindPlatformConfigEvents, + initPlatformConfigUi, + loadPlatformConfig, +} from "./platform/platform-config.js"; import { startPointSocket, startLogs, stopLogs } from "./logs.js"; import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js"; +import { state } from "./platform/state.js"; import { - clearBatchBinding, - browseAndLoadTree, - clearSelectedPoints, - createPoints, - loadPoints, - loadTree, - openBatchBinding, - openPointCreateModal, - renderSelectedNodes, - saveBatchBinding, - savePointBinding, - updatePointFilterSummary, - updateSelectedPointSummary, -} from "./points.js"; -import { state } from "./state.js"; -import { loadSources, saveSource } from "./sources.js"; -import { bindUnitEquipmentModalEvents, closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js"; + bindUnitEquipmentModalEvents, + closeUnitModal, + loadUnits, + openCreateUnitModal, + resetUnitForm, + renderUnits, + saveUnit, +} from "./units.js"; let _configLoaded = false; let _appConfigLoaded = false; @@ -69,10 +58,7 @@ function switchView(view) { startLogs(); if (!_configLoaded) { _configLoaded = true; - withStatus((async () => { - await Promise.all([loadSources(), loadEquipments(), loadEvents()]); - await loadPoints(); - })()); + withStatus(loadPlatformConfig()); } } else { stopLogs(); @@ -87,92 +73,19 @@ function switchView(view) { } function bindEvents() { - dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event))); - dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event))); - dom.equipmentForm.addEventListener("submit", (event) => withStatus(saveEquipment(event))); - dom.pointBindingForm.addEventListener("submit", (event) => withStatus(savePointBinding(event))); - dom.batchBindingForm.addEventListener("submit", (event) => withStatus(saveBatchBinding(event))); + // Shared data-source / point / equipment listeners. + bindPlatformConfigEvents(); + // Feeder-specific (control unit) listeners. + dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event))); dom.unitResetBtn.addEventListener("click", resetUnitForm); if (dom.refreshUnitBtn) dom.refreshUnitBtn.addEventListener("click", () => withStatus(loadUnits().then(loadEvents))); if (dom.newUnitBtn) dom.newUnitBtn.addEventListener("click", openCreateUnitModal); dom.closeUnitModalBtn.addEventListener("click", closeUnitModal); - dom.sourceResetBtn.addEventListener("click", () => dom.sourceForm.reset()); - dom.equipmentResetBtn.addEventListener("click", resetEquipmentForm); - dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments())); - dom.newEquipmentBtn.addEventListener("click", openCreateEquipmentModal); - dom.closeEquipmentModalBtn.addEventListener("click", closeEquipmentModal); - dom.openPointModalBtn.addEventListener("click", openPointCreateModal); - dom.pointSourceSelect.addEventListener("change", () => { - dom.nodeTree.innerHTML = '
点击"加载节点"获取节点树
'; - dom.pointSourceNodeCount.textContent = "节点: 0"; - }); - dom.browseNodesBtn.addEventListener("click", () => withStatus(browseAndLoadTree())); - dom.refreshTreeBtn.addEventListener("click", () => withStatus(loadTree())); - dom.createPointsBtn.addEventListener("click", () => withStatus(createPoints())); - dom.closeModalBtn.addEventListener("click", () => dom.pointModal.classList.add("hidden")); - - dom.openSourceFormBtn.addEventListener("click", () => { - dom.sourceForm.reset(); - dom.sourceId.value = ""; - dom.sourceModal.classList.remove("hidden"); - }); - dom.closeSourceModalBtn.addEventListener("click", () => dom.sourceModal.classList.add("hidden")); - - dom.clearPointBindingBtn.addEventListener("click", () => withStatus(clearPointBinding())); - dom.closePointBindingModalBtn.addEventListener("click", () => { - dom.pointBindingModal.classList.add("hidden"); - }); - - dom.openBatchBindingBtn.addEventListener("click", openBatchBinding); - dom.clearSelectedPointsBtn.addEventListener("click", clearSelectedPoints); - dom.closeBatchBindingModalBtn.addEventListener("click", () => { - dom.batchBindingModal.classList.add("hidden"); - }); - dom.clearBatchBindingBtn.addEventListener("click", () => withStatus(clearBatchBinding())); - - dom.toggleAllPoints.addEventListener("change", () => { - const checked = dom.toggleAllPoints.checked; - dom.pointList.querySelectorAll('input[data-point-select="true"]').forEach((input) => { - input.checked = checked; - input.dispatchEvent(new Event("change")); - }); - }); - dom.openReadmeDocBtn.addEventListener("click", () => withStatus(openReadmeDrawer())); dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer())); dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer); - dom.refreshEventBtn.addEventListener("click", () => withStatus(loadEvents())); - - dom.refreshChartBtn.addEventListener("click", () => { - if (!state.chartPointId) { - return; - } - withStatus(openChart(state.chartPointId, state.chartPointName)); - }); - - dom.prevPointsBtn.addEventListener("click", () => { - if (state.pointsPage > 1) { - state.pointsPage -= 1; - withStatus(loadPoints()); - } - }); - - dom.nextPointsBtn.addEventListener("click", () => { - const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize)); - if (state.pointsPage < totalPages) { - state.pointsPage += 1; - withStatus(loadPoints()); - } - }); - - dom.equipmentKeyword.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - event.preventDefault(); - withStatus(loadEquipments()); - } - }); dom.tabOps.addEventListener("click", () => switchView("ops")); dom.tabAppConfig.addEventListener("click", () => switchView("app-config")); @@ -197,10 +110,7 @@ function bindEvents() { async function bootstrap() { bindEvents(); switchView("ops"); - renderSelectedNodes(); - updateSelectedPointSummary(); - updatePointFilterSummary(); - renderChart(); + initPlatformConfigUi(); startPointSocket(); await withStatus(Promise.all([loadUnits(), loadEvents()])); diff --git a/web/feeder/js/logs.js b/web/feeder/js/logs.js index 6651c14..84beea2 100644 --- a/web/feeder/js/logs.js +++ b/web/feeder/js/logs.js @@ -1,76 +1,14 @@ -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 { appendChartPoint } from "./platform/chart.js"; +import { dom } from "./platform/dom.js"; +import { prependEvent } from "./platform/events.js"; +import { formatValue } from "./platform/points.js"; +import { state } from "./platform/state.js"; import { loadUnits, renderUnits } from "./units.js"; -import { loadEquipments } from "./equipment.js"; -import { showToast } from "./api.js"; +import { loadEquipments } from "./platform/equipment.js"; +import { showToast } from "./platform/api.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; } -} - -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 = [ - `${escapeHtml(levelRaw || "LOG")}`, - parsed.timestamp ? ` ${escapeHtml(parsed.timestamp)}` : "", - parsed.target ? ` ${escapeHtml(parsed.target)}` : "", - `${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}`, - ].join(""); - } - dom.logView.appendChild(div); - if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight; -} - -function appendLogDivider(text) { - if (!dom.logView) return; - const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10; - const div = document.createElement("div"); - div.className = "log-line muted"; - div.textContent = text; - dom.logView.appendChild(div); - if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight; -} - -export function startLogs() { - if (state.logSource) return; - let currentLogFile = null; - state.logSource = new EventSource("/api/logs/stream"); - state.logSource.addEventListener("log", (event) => { - const data = JSON.parse(event.data); - if (data.reset && data.file && data.file !== currentLogFile) { - appendLogDivider(`[log switched to ${data.file}]`); - } - currentLogFile = data.file || currentLogFile; - (data.lines || []).forEach(appendLog); - }); - state.logSource.addEventListener("error", () => appendLog("[log stream error]")); -} - -export function stopLogs() { - if (state.logSource) { - state.logSource.close(); - state.logSource = null; - } -} +// Real-time SSE log stream lives in the shared platform module (also used by ops). +export { startLogs, stopLogs } from "./platform/log-stream.js"; let _disconnectToast = null; diff --git a/web/feeder/js/ops.js b/web/feeder/js/ops.js index ec468d2..cdbfa8f 100644 --- a/web/feeder/js/ops.js +++ b/web/feeder/js/ops.js @@ -1,6 +1,6 @@ -import { apiFetch } from "./api.js"; -import { dom } from "./dom.js"; -import { state } from "./state.js"; +import { apiFetch } from "./platform/api.js"; +import { dom } from "./platform/dom.js"; +import { state } from "./platform/state.js"; import { loadUnits } from "./units.js"; const SIGNAL_ROLES = ["rem", "run", "flt"]; diff --git a/web/feeder/js/units.js b/web/feeder/js/units.js index dcd8708..605b273 100644 --- a/web/feeder/js/units.js +++ b/web/feeder/js/units.js @@ -1,8 +1,8 @@ -import { apiFetch, withStatus } from "./api.js"; -import { dom } from "./dom.js"; -import { loadEvents } from "./events.js"; -import { loadEquipments, renderEquipments } from "./equipment.js"; -import { state } from "./state.js"; +import { apiFetch, withStatus } from "./platform/api.js"; +import { dom } from "./platform/dom.js"; +import { loadEvents } from "./platform/events.js"; +import { loadEquipments, renderEquipments } from "./platform/equipment.js"; +import { state } from "./platform/state.js"; function equipmentOf(item) { return item && item.equipment ? item.equipment : item; diff --git a/web/ops/html/topbar.html b/web/ops/html/topbar.html index 13ab23e..17b1bc4 100644 --- a/web/ops/html/topbar.html +++ b/web/ops/html/topbar.html @@ -1,5 +1,10 @@
-
运转系统
+
隧道窑运转系统
+
+ + + +
diff --git a/web/ops/index.html b/web/ops/index.html index 57f7d07..6b76669 100644 --- a/web/ops/index.html +++ b/web/ops/index.html @@ -3,27 +3,34 @@ - 运转系统 + 隧道窑运转系统
- -
+
+
+
+ diff --git a/web/ops/js/app.js b/web/ops/js/app.js index 1d3302e..0329352 100644 --- a/web/ops/js/app.js +++ b/web/ops/js/app.js @@ -3,6 +3,7 @@ import { bindSegmentConfigEvents } from "./segments-config.js"; import { bindStationEvents } from "./stations.js"; import { bindViewTabs } from "./views.js"; import { startOpsSocket } from "./ws.js"; +import { loadEvents } from "./platform/events.js"; async function bootstrap() { bindViewTabs(); @@ -10,6 +11,7 @@ async function bootstrap() { bindStationEvents(); bindSegmentConfigEvents(); startOpsSocket(); + loadEvents().catch(() => {}); try { await loadSegments(); } catch (err) { diff --git a/web/ops/js/views.js b/web/ops/js/views.js index 8407036..a01e3d5 100644 --- a/web/ops/js/views.js +++ b/web/ops/js/views.js @@ -1,30 +1,51 @@ import { el } from "./dom.js"; import { loadSegmentsConfig } from "./segments-config.js"; import { loadStations } from "./stations.js"; +import { + bindPlatformConfigEvents, + initPlatformConfigUi, + loadPlatformConfig, +} from "./platform/platform-config.js"; +import { startLogs, stopLogs } from "./platform/log-stream.js"; + +const VIEWS = ["monitor", "config", "platform"]; +const TAB_IDS = { monitor: "tabMonitor", config: "tabConfig", platform: "tabPlatform" }; let configLoaded = false; +let platformLoaded = false; function show(viewName) { - const monitor = document.querySelector("[data-view='monitor']"); - const config = document.querySelector("[data-view='config']"); - if (monitor) monitor.classList.toggle("hidden", viewName !== "monitor"); - if (config) config.classList.toggle("hidden", viewName !== "config"); - - const tabMon = el("tabMonitor"); - const tabCfg = el("tabConfig"); - if (tabMon) tabMon.classList.toggle("active", viewName === "monitor"); - if (tabCfg) tabCfg.classList.toggle("active", viewName === "config"); + VIEWS.forEach((name) => { + const view = document.querySelector(`[data-view='${name}']`); + if (view) view.classList.toggle("hidden", name !== viewName); + const tab = el(TAB_IDS[name]); + if (tab) tab.classList.toggle("active", name === viewName); + }); if (viewName === "config" && !configLoaded) { configLoaded = true; Promise.allSettled([loadStations(), loadSegmentsConfig()]); } + + // Real-time log stream only runs while the platform-config view is visible. + if (viewName === "platform") { + startLogs(); + if (!platformLoaded) { + platformLoaded = true; + loadPlatformConfig().catch(() => {}); + } + } else { + stopLogs(); + } } export function bindViewTabs() { - const tabMon = el("tabMonitor"); - const tabCfg = el("tabConfig"); - if (tabMon) tabMon.addEventListener("click", () => show("monitor")); - if (tabCfg) tabCfg.addEventListener("click", () => show("config")); + VIEWS.forEach((name) => { + const tab = el(TAB_IDS[name]); + if (tab) tab.addEventListener("click", () => show(name)); + }); + // Platform-config listeners/UI bind once; data loads lazily on first view. + bindPlatformConfigEvents(); + initPlatformConfigUi(); show("monitor"); } diff --git a/web/ops/js/ws.js b/web/ops/js/ws.js index 5fc6d23..f3884ab 100644 --- a/web/ops/js/ws.js +++ b/web/ops/js/ws.js @@ -1,4 +1,5 @@ import { applyRuntimeUpdate } from "./segments.js"; +import { prependEvent } from "./platform/events.js"; const RECONNECT_INITIAL_MS = 1_000; const RECONNECT_MAX_MS = 30_000; @@ -19,6 +20,13 @@ function setWsStatus(connected) { function handleMessage(payload) { if (!payload || typeof payload !== "object") return; + + // System events -> 运行监控 event panel. + if (payload.type === "EventCreated" || payload.type === "event_created") { + prependEvent(payload.data); + return; + } + if (payload.type !== "app_event") return; const event = payload.data; if (!event || event.app !== "operation-system") return; diff --git a/web/ops/ops-styles.css b/web/ops/ops-styles.css index d2d921e..7985788 100644 --- a/web/ops/ops-styles.css +++ b/web/ops/ops-styles.css @@ -1,15 +1,7 @@ /* Operation-system specific styles. Loaded after /ui/styles.css. */ -.ops-tabbar { - display: flex; - gap: 4px; - padding: 6px 12px; - background: var(--surface); - border-bottom: 1px solid var(--border); -} - main.ops-main { - height: calc(100vh - var(--topbar-h) - 42px); + height: calc(100vh - var(--topbar-h)); display: flex; flex-direction: column; overflow: hidden; @@ -465,3 +457,45 @@ main.ops-main { font-size: 11px; word-break: break-all; } + +/* Platform-config view layout (data source / point / equipment management), + reuses the shared core panels (.panel.top-left / .top-right / ...). */ +.ops-platform-grid { + display: grid; + grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr); + grid-template-rows: minmax(0, 1fr) 320px; + gap: 1px; + background: var(--border); + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.ops-platform-grid .panel.top-left { grid-column: 1; grid-row: 1; } +.ops-platform-grid .panel.top-right { grid-column: 2 / 4; grid-row: 1; } +.ops-platform-grid .panel.bottom-left { grid-column: 1; grid-row: 2; } +.ops-platform-grid .panel.bottom-mid { grid-column: 2; grid-row: 2; } +.ops-platform-grid .panel.bottom-right { grid-column: 3; grid-row: 2; } + +@media (max-width: 1100px) { + .ops-platform-grid { + grid-template-columns: 1fr; + grid-template-rows: none; + grid-auto-rows: minmax(220px, auto); + overflow-y: auto; + } + .ops-platform-grid .panel.top-left, + .ops-platform-grid .panel.top-right, + .ops-platform-grid .panel.bottom-left, + .ops-platform-grid .panel.bottom-mid, + .ops-platform-grid .panel.bottom-right { + grid-column: 1; + grid-row: auto; + } +} + +/* Monitor view: segment cards fill, system-event panel pinned at the bottom. */ +.ops-view[data-view="monitor"] .panel.ops-bottom { + flex: 0 0 220px; + min-height: 0; +}