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)}` : "", + ``, + ].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 = '