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:
parent
5613c9f0d5
commit
07239ebff1
|
|
@ -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`
|
||||
14
README.md
14
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` 的同名冲突)。
|
||||
|
||||
## 实时日志设计
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
`<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<header class="topbar">
|
||||
<div class="title">投煤器布料机控制系统</div>
|
||||
<div class="title">投煤控制系统</div>
|
||||
<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="tabConfig">平台配置</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -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 = '<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.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()]));
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
`<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;
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
<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="status" id="statusText">
|
||||
<span class="ws-dot" id="wsDot"></span>
|
||||
|
|
|
|||
|
|
@ -3,27 +3,34 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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/ops-styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<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">
|
||||
<div class="ops-view" data-view="monitor">
|
||||
<div data-partial="/ui/html/segment-panel.html"></div>
|
||||
<div data-partial="/ui/html/logs-panel.html"></div>
|
||||
</div>
|
||||
<div class="ops-view hidden" data-view="config">
|
||||
<div data-partial="/ui/html/config-panel.html"></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>
|
||||
|
||||
<div data-partial="/ui/html/modals.html"></div>
|
||||
|
||||
<script type="module" src="/ui/js/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue