Unify two apps' UI: shared platform config + matching tabs

- 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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-29 13:39:19 +08:00
parent 5613c9f0d5
commit 07239ebff1
26 changed files with 399 additions and 232 deletions

42
CLAUDE.md Normal file
View File

@ -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 监听 → 凡引入平台 JSevents.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())`,平台 CRUDsource/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`

View File

@ -57,9 +57,9 @@ plc_control/
app_feeder_distributor/ # 投煤器布料机专用版 app_feeder_distributor/ # 投煤器布料机专用版
app_operation_system/ # 运转系统专用版 app_operation_system/ # 运转系统专用版
web/ web/
core/ # 共享 HTML/CSS数据源、点位、设备、图表、日志等 core/ # 共享 HTML/CSS + 平台 JS(数据源、点位、设备、图表、日志等)
feeder/ # 投煤器布料机页面 + JS feeder/ # 投煤控制系统页面 + 业务 JS
ops/ # 运转系统页面 + JS ops/ # 隧道窑运转系统页面 + 业务 JS
``` ```
### 共享平台核心库 (`plc_platform_core`) ### 共享平台核心库 (`plc_platform_core`)
@ -122,11 +122,11 @@ deploy/
前端采用原生 ES Module 和分片 HTML 结构,按应用拆分目录: 前端采用原生 ES Module 和分片 HTML 结构,按应用拆分目录:
- `web/core/` — 共享 HTML 面板(数据源、点位、设备、图表、日志、文档抽屉)和样式 - `web/core/` — 共享 HTML 面板(数据源、点位、设备、图表、日志、文档抽屉)、样式,以及共享平台 JS `web/core/js/platform/`(数据源 / 点位 / 设备配置逻辑feeder 与 ops 共用,避免重复)
- `web/feeder/` — 投煤器专用入口、运维面板、控制单元表单、全部 JS 模块 - `web/feeder/`(投煤控制系统)— 三个页签:运行监控 / 应用配置 / 平台配置;仅保留投煤业务 JS控制单元、运行监控卡片等
- `web/ops/` — 运转系统专用入口(开发中 - `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` 的同名冲突)。
## 实时日志设计 ## 实时日志设计

View File

@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
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 = [
`<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;
}
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;
}
}

View File

@ -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 = '<div class="muted">点击"加载节点"获取节点树</div>';
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();
}

View File

@ -1,7 +1,7 @@
<header class="topbar"> <header class="topbar">
<div class="title">投煤器布料机控制系统</div> <div class="title">投煤控制系统</div>
<div class="tab-bar"> <div class="tab-bar">
<button type="button" class="tab-btn active" id="tabOps"></button> <button type="button" class="tab-btn active" id="tabOps">行监控</button>
<button type="button" class="tab-btn" id="tabAppConfig">应用配置</button> <button type="button" class="tab-btn" id="tabAppConfig">应用配置</button>
<button type="button" class="tab-btn" id="tabConfig">平台配置</button> <button type="button" class="tab-btn" id="tabConfig">平台配置</button>
</div> </div>

View File

@ -3,7 +3,7 @@
<head> <head>
<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>投煤控制系统</title>
<link rel="stylesheet" href="/ui/styles.css?v=20260325f" /> <link rel="stylesheet" href="/ui/styles.css?v=20260325f" />
</head> </head>
<body> <body>

View File

@ -1,36 +1,25 @@
import { withStatus } from "./api.js"; import { withStatus } from "./platform/api.js";
import { openChart, renderChart } from "./chart.js"; import { dom } from "./platform/dom.js";
import { dom } from "./dom.js"; import { closeApiDocDrawer, openApiDocDrawer, openReadmeDrawer } from "./platform/docs.js";
import { closeApiDocDrawer, openApiDocDrawer, openReadmeDrawer } from "./docs.js"; import { loadEvents } from "./platform/events.js";
import { loadEvents } from "./events.js"; import { loadEquipments } from "./platform/equipment.js";
import { import {
clearPointBinding, bindPlatformConfigEvents,
closeEquipmentModal, initPlatformConfigUi,
loadEquipments, loadPlatformConfig,
openCreateEquipmentModal, } from "./platform/platform-config.js";
resetEquipmentForm,
saveEquipment,
} from "./equipment.js";
import { startPointSocket, startLogs, stopLogs } from "./logs.js"; import { startPointSocket, startLogs, stopLogs } from "./logs.js";
import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js"; import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js";
import { state } from "./platform/state.js";
import { import {
clearBatchBinding, bindUnitEquipmentModalEvents,
browseAndLoadTree, closeUnitModal,
clearSelectedPoints, loadUnits,
createPoints, openCreateUnitModal,
loadPoints, resetUnitForm,
loadTree, renderUnits,
openBatchBinding, saveUnit,
openPointCreateModal, } from "./units.js";
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";
let _configLoaded = false; let _configLoaded = false;
let _appConfigLoaded = false; let _appConfigLoaded = false;
@ -69,10 +58,7 @@ function switchView(view) {
startLogs(); startLogs();
if (!_configLoaded) { if (!_configLoaded) {
_configLoaded = true; _configLoaded = true;
withStatus((async () => { withStatus(loadPlatformConfig());
await Promise.all([loadSources(), loadEquipments(), loadEvents()]);
await loadPoints();
})());
} }
} else { } else {
stopLogs(); stopLogs();
@ -87,92 +73,19 @@ function switchView(view) {
} }
function bindEvents() { function bindEvents() {
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event))); // Shared data-source / point / equipment listeners.
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event))); bindPlatformConfigEvents();
dom.equipmentForm.addEventListener("submit", (event) => withStatus(saveEquipment(event)));
dom.pointBindingForm.addEventListener("submit", (event) => withStatus(savePointBinding(event)));
dom.batchBindingForm.addEventListener("submit", (event) => withStatus(saveBatchBinding(event)));
// Feeder-specific (control unit) listeners.
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
dom.unitResetBtn.addEventListener("click", resetUnitForm); dom.unitResetBtn.addEventListener("click", resetUnitForm);
if (dom.refreshUnitBtn) dom.refreshUnitBtn.addEventListener("click", () => withStatus(loadUnits().then(loadEvents))); if (dom.refreshUnitBtn) dom.refreshUnitBtn.addEventListener("click", () => withStatus(loadUnits().then(loadEvents)));
if (dom.newUnitBtn) dom.newUnitBtn.addEventListener("click", openCreateUnitModal); if (dom.newUnitBtn) dom.newUnitBtn.addEventListener("click", openCreateUnitModal);
dom.closeUnitModalBtn.addEventListener("click", closeUnitModal); 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 = '<div class="muted">点击"加载节点"获取节点树</div>';
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.openReadmeDocBtn.addEventListener("click", () => withStatus(openReadmeDrawer()));
dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer())); dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer()));
dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer); 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.tabOps.addEventListener("click", () => switchView("ops"));
dom.tabAppConfig.addEventListener("click", () => switchView("app-config")); dom.tabAppConfig.addEventListener("click", () => switchView("app-config"));
@ -197,10 +110,7 @@ function bindEvents() {
async function bootstrap() { async function bootstrap() {
bindEvents(); bindEvents();
switchView("ops"); switchView("ops");
renderSelectedNodes(); initPlatformConfigUi();
updateSelectedPointSummary();
updatePointFilterSummary();
renderChart();
startPointSocket(); startPointSocket();
await withStatus(Promise.all([loadUnits(), loadEvents()])); await withStatus(Promise.all([loadUnits(), loadEvents()]));

View File

@ -1,76 +1,14 @@
import { appendChartPoint } from "./chart.js"; import { appendChartPoint } from "./platform/chart.js";
import { dom } from "./dom.js"; import { dom } from "./platform/dom.js";
import { prependEvent } from "./events.js"; import { prependEvent } from "./platform/events.js";
import { formatValue } from "./points.js"; import { formatValue } from "./platform/points.js";
import { state } from "./state.js"; import { state } from "./platform/state.js";
import { loadUnits, renderUnits } from "./units.js"; import { loadUnits, renderUnits } from "./units.js";
import { loadEquipments } from "./equipment.js"; import { loadEquipments } from "./platform/equipment.js";
import { showToast } from "./api.js"; import { showToast } from "./platform/api.js";
function escapeHtml(text) { // Real-time SSE log stream lives in the shared platform module (also used by ops).
return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;"); export { startLogs, stopLogs } from "./platform/log-stream.js";
}
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 = [
`<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;
}
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;
}
}
let _disconnectToast = null; let _disconnectToast = null;

View File

@ -1,6 +1,6 @@
import { apiFetch } from "./api.js"; import { apiFetch } from "./platform/api.js";
import { dom } from "./dom.js"; import { dom } from "./platform/dom.js";
import { state } from "./state.js"; import { state } from "./platform/state.js";
import { loadUnits } from "./units.js"; import { loadUnits } from "./units.js";
const SIGNAL_ROLES = ["rem", "run", "flt"]; const SIGNAL_ROLES = ["rem", "run", "flt"];

View File

@ -1,8 +1,8 @@
import { apiFetch, withStatus } from "./api.js"; import { apiFetch, withStatus } from "./platform/api.js";
import { dom } from "./dom.js"; import { dom } from "./platform/dom.js";
import { loadEvents } from "./events.js"; import { loadEvents } from "./platform/events.js";
import { loadEquipments, renderEquipments } from "./equipment.js"; import { loadEquipments, renderEquipments } from "./platform/equipment.js";
import { state } from "./state.js"; import { state } from "./platform/state.js";
function equipmentOf(item) { function equipmentOf(item) {
return item && item.equipment ? item.equipment : item; return item && item.equipment ? item.equipment : item;

View File

@ -1,5 +1,10 @@
<header class="topbar"> <header class="topbar">
<div class="title">运转系统</div> <div class="title">隧道窑运转系统</div>
<div class="tab-bar">
<button type="button" class="tab-btn active" id="tabMonitor">运行监控</button>
<button type="button" class="tab-btn" id="tabConfig">应用配置</button>
<button type="button" class="tab-btn" id="tabPlatform">平台配置</button>
</div>
<div class="topbar-actions"> <div class="topbar-actions">
<div class="status" id="statusText"> <div class="status" id="statusText">
<span class="ws-dot" id="wsDot"></span> <span class="ws-dot" id="wsDot"></span>

View File

@ -3,27 +3,34 @@
<head> <head>
<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>运转系统</title> <title>隧道窑运转系统</title>
<link rel="stylesheet" href="/ui/styles.css" /> <link rel="stylesheet" href="/ui/styles.css" />
<link rel="stylesheet" href="/ui/ops-styles.css" /> <link rel="stylesheet" href="/ui/ops-styles.css" />
</head> </head>
<body> <body>
<div data-partial="/ui/html/topbar.html"></div> <div data-partial="/ui/html/topbar.html"></div>
<nav class="ops-tabbar">
<button type="button" class="tab-btn" id="tabMonitor">运行监控</button>
<button type="button" class="tab-btn" id="tabConfig">段 / 工位配置</button>
</nav>
<main class="ops-main"> <main class="ops-main">
<div class="ops-view" data-view="monitor"> <div class="ops-view" data-view="monitor">
<div data-partial="/ui/html/segment-panel.html"></div> <div data-partial="/ui/html/segment-panel.html"></div>
<div data-partial="/ui/html/logs-panel.html"></div>
</div> </div>
<div class="ops-view hidden" data-view="config"> <div class="ops-view hidden" data-view="config">
<div data-partial="/ui/html/config-panel.html"></div> <div data-partial="/ui/html/config-panel.html"></div>
</div> </div>
<div class="ops-view hidden" data-view="platform">
<div class="ops-platform-grid">
<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/chart-panel.html"></div>
</div>
</div>
</main> </main>
<div data-partial="/ui/html/modals.html"></div>
<script type="module" src="/ui/js/index.js"></script> <script type="module" src="/ui/js/index.js"></script>
</body> </body>
</html> </html>

View File

@ -3,6 +3,7 @@ import { bindSegmentConfigEvents } from "./segments-config.js";
import { bindStationEvents } from "./stations.js"; import { bindStationEvents } from "./stations.js";
import { bindViewTabs } from "./views.js"; import { bindViewTabs } from "./views.js";
import { startOpsSocket } from "./ws.js"; import { startOpsSocket } from "./ws.js";
import { loadEvents } from "./platform/events.js";
async function bootstrap() { async function bootstrap() {
bindViewTabs(); bindViewTabs();
@ -10,6 +11,7 @@ async function bootstrap() {
bindStationEvents(); bindStationEvents();
bindSegmentConfigEvents(); bindSegmentConfigEvents();
startOpsSocket(); startOpsSocket();
loadEvents().catch(() => {});
try { try {
await loadSegments(); await loadSegments();
} catch (err) { } catch (err) {

View File

@ -1,30 +1,51 @@
import { el } from "./dom.js"; import { el } from "./dom.js";
import { loadSegmentsConfig } from "./segments-config.js"; import { loadSegmentsConfig } from "./segments-config.js";
import { loadStations } from "./stations.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 configLoaded = false;
let platformLoaded = false;
function show(viewName) { function show(viewName) {
const monitor = document.querySelector("[data-view='monitor']"); VIEWS.forEach((name) => {
const config = document.querySelector("[data-view='config']"); const view = document.querySelector(`[data-view='${name}']`);
if (monitor) monitor.classList.toggle("hidden", viewName !== "monitor"); if (view) view.classList.toggle("hidden", name !== viewName);
if (config) config.classList.toggle("hidden", viewName !== "config"); const tab = el(TAB_IDS[name]);
if (tab) tab.classList.toggle("active", name === viewName);
const tabMon = el("tabMonitor"); });
const tabCfg = el("tabConfig");
if (tabMon) tabMon.classList.toggle("active", viewName === "monitor");
if (tabCfg) tabCfg.classList.toggle("active", viewName === "config");
if (viewName === "config" && !configLoaded) { if (viewName === "config" && !configLoaded) {
configLoaded = true; configLoaded = true;
Promise.allSettled([loadStations(), loadSegmentsConfig()]); 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() { export function bindViewTabs() {
const tabMon = el("tabMonitor"); VIEWS.forEach((name) => {
const tabCfg = el("tabConfig"); const tab = el(TAB_IDS[name]);
if (tabMon) tabMon.addEventListener("click", () => show("monitor")); if (tab) tab.addEventListener("click", () => show(name));
if (tabCfg) tabCfg.addEventListener("click", () => show("config")); });
// Platform-config listeners/UI bind once; data loads lazily on first view.
bindPlatformConfigEvents();
initPlatformConfigUi();
show("monitor"); show("monitor");
} }

View File

@ -1,4 +1,5 @@
import { applyRuntimeUpdate } from "./segments.js"; import { applyRuntimeUpdate } from "./segments.js";
import { prependEvent } from "./platform/events.js";
const RECONNECT_INITIAL_MS = 1_000; const RECONNECT_INITIAL_MS = 1_000;
const RECONNECT_MAX_MS = 30_000; const RECONNECT_MAX_MS = 30_000;
@ -19,6 +20,13 @@ function setWsStatus(connected) {
function handleMessage(payload) { function handleMessage(payload) {
if (!payload || typeof payload !== "object") return; 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; if (payload.type !== "app_event") return;
const event = payload.data; const event = payload.data;
if (!event || event.app !== "operation-system") return; if (!event || event.app !== "operation-system") return;

View File

@ -1,15 +1,7 @@
/* Operation-system specific styles. Loaded after /ui/styles.css. */ /* 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 { main.ops-main {
height: calc(100vh - var(--topbar-h) - 42px); height: calc(100vh - var(--topbar-h));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@ -465,3 +457,45 @@ main.ops-main {
font-size: 11px; font-size: 11px;
word-break: break-all; 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;
}