From 6102ed712fd13facfbab7e930530d78882d4f358 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 21 Apr 2026 08:36:32 +0800 Subject: [PATCH] refactor(web): remove legacy feeder/ops/core web files after split Clean up old web module files that were superseded by the per-app split architecture. Includes plan documentation for the migration. Co-Authored-By: Claude Opus 4.6 --- ...04-17-three-panel-and-handler-migration.md | 928 +++++++++++ .../plans/2026-04-17-web-split-and-cleanup.md | 564 +++++++ web/core/html/api-doc-drawer.html | 15 - web/core/html/chart-panel.html | 10 - web/core/html/equipment-panel.html | 11 - web/core/html/log-stream-panel.html | 6 - web/core/html/logs-panel.html | 7 - web/core/html/modals.html | 131 -- web/core/html/points-panel.html | 37 - web/core/html/source-panel.html | 7 - web/core/styles.css | 1402 ----------------- web/feeder/html/modals.html | 135 -- web/feeder/html/ops-panel.html | 17 - web/feeder/html/topbar.html | 16 - web/feeder/html/unit-modal.html | 51 - web/feeder/html/unit-panel.html | 10 - web/feeder/index.html | 43 - web/feeder/js/api.js | 87 - web/feeder/js/app.js | 210 --- web/feeder/js/chart.js | 183 --- web/feeder/js/docs.js | 137 -- web/feeder/js/dom.js | 110 -- web/feeder/js/equipment.js | 245 --- web/feeder/js/events.js | 86 - web/feeder/js/index.js | 20 - web/feeder/js/logs.js | 176 --- web/feeder/js/ops.js | 232 --- web/feeder/js/points.js | 363 ----- web/feeder/js/roles.js | 29 - web/feeder/js/sources.js | 138 -- web/feeder/js/state.js | 29 - web/feeder/js/units.js | 324 ---- web/ops/html/topbar.html | 9 - web/ops/index.html | 18 - web/ops/js/app.js | 5 - web/ops/js/index.js | 20 - 36 files changed, 1492 insertions(+), 4319 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-17-three-panel-and-handler-migration.md create mode 100644 docs/superpowers/plans/2026-04-17-web-split-and-cleanup.md delete mode 100644 web/core/html/api-doc-drawer.html delete mode 100644 web/core/html/chart-panel.html delete mode 100644 web/core/html/equipment-panel.html delete mode 100644 web/core/html/log-stream-panel.html delete mode 100644 web/core/html/logs-panel.html delete mode 100644 web/core/html/modals.html delete mode 100644 web/core/html/points-panel.html delete mode 100644 web/core/html/source-panel.html delete mode 100644 web/core/styles.css delete mode 100644 web/feeder/html/modals.html delete mode 100644 web/feeder/html/ops-panel.html delete mode 100644 web/feeder/html/topbar.html delete mode 100644 web/feeder/html/unit-modal.html delete mode 100644 web/feeder/html/unit-panel.html delete mode 100644 web/feeder/index.html delete mode 100644 web/feeder/js/api.js delete mode 100644 web/feeder/js/app.js delete mode 100644 web/feeder/js/chart.js delete mode 100644 web/feeder/js/docs.js delete mode 100644 web/feeder/js/dom.js delete mode 100644 web/feeder/js/equipment.js delete mode 100644 web/feeder/js/events.js delete mode 100644 web/feeder/js/index.js delete mode 100644 web/feeder/js/logs.js delete mode 100644 web/feeder/js/ops.js delete mode 100644 web/feeder/js/points.js delete mode 100644 web/feeder/js/roles.js delete mode 100644 web/feeder/js/sources.js delete mode 100644 web/feeder/js/state.js delete mode 100644 web/feeder/js/units.js delete mode 100644 web/ops/html/topbar.html delete mode 100644 web/ops/index.html delete mode 100644 web/ops/js/app.js delete mode 100644 web/ops/js/index.js diff --git a/docs/superpowers/plans/2026-04-17-three-panel-and-handler-migration.md b/docs/superpowers/plans/2026-04-17-three-panel-and-handler-migration.md new file mode 100644 index 0000000..c8eaa07 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-three-panel-and-handler-migration.md @@ -0,0 +1,928 @@ +# Three-Panel Web Restructure And Handler Migration + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restructure each app's UI into three logical panels (platform-config / app-config / ops), move stateless handlers (log, doc) to the shared core, and split API.md per application. + +**Architecture:** Each app gets three tabs: "运维" (ops view), "应用配置" (app-specific config), "平台配置" (shared platform config). Core HTML pages are cleaned of unit references. Feeder overrides core pages that need unit-related UI via the ServeDir fallback chain. Stateless handlers (log, doc) move to `plc_platform_core::handler` so both apps can register them. Each app serves its own API doc. Handlers that depend on `AppState` (source, point, equipment, tag, page) stay in the feeder app — they require the `PlatformContext` refactor to move, which is deferred to a follow-up plan. + +**Tech Stack:** Rust (Axum, tower-http), HTML/CSS/JS (vanilla ES modules), Cargo workspace + +--- + +## File Map + +### Core HTML changes (remove unit references) + +- Modify: `web/core/html/source-panel.html` — remove unit list section, keep data source only +- Modify: `web/core/html/equipment-panel.html` — remove batch-unit toolbar +- Modify: `web/core/html/modals.html` — remove unit select from equipment modal + +### Feeder HTML overrides (add unit references back) + +- Create: `web/feeder/html/source-panel.html` — override with units + sources stacked +- Create: `web/feeder/html/equipment-panel.html` — override with unit batch toolbar +- Create: `web/feeder/html/modals.html` — override with unit select in equipment modal +- Create: `web/feeder/html/unit-panel.html` — standalone unit config panel for app-config view + +### Feeder three-tab UI + +- Modify: `web/feeder/html/topbar.html` — add third tab +- Modify: `web/feeder/index.html` — add unit-panel partial, add grid-app-config layout +- Modify: `web/feeder/js/app.js` — three-way view switching +- Modify: `web/feeder/js/dom.js` — add tabAppConfig selector +- Modify: `web/core/styles.css` — add grid-app-config layout + +### Handler migration to core + +- Create: `crates/plc_platform_core/src/handler.rs` — module declarations +- Create: `crates/plc_platform_core/src/handler/log.rs` — moved from feeder +- Create: `crates/plc_platform_core/src/handler/doc.rs` — generic markdown serving +- Modify: `crates/plc_platform_core/src/lib.rs` — export handler module +- Modify: `crates/plc_platform_core/Cargo.toml` — add async-stream if needed +- Modify: `crates/app_feeder_distributor/src/handler.rs` — replace log/doc with re-exports +- Modify: `crates/app_feeder_distributor/src/handler/doc.rs` — thin wrapper calling core +- Delete: `crates/app_feeder_distributor/src/handler/log.rs` — replaced by core + +### Ops app handler registration + +- Modify: `crates/app_operation_system/src/lib.rs` — add handler module +- Create: `crates/app_operation_system/src/handler.rs` — module declarations +- Create: `crates/app_operation_system/src/handler/doc.rs` — ops-specific doc handler +- Modify: `crates/app_operation_system/src/router.rs` — register log/doc routes +- Modify: `crates/app_operation_system/Cargo.toml` — add needed deps + +### API.md split + +- Rename: `API.md` → `docs/api-feeder.md` +- Create: `docs/api-ops.md` — ops API (health endpoint for now) + +--- + +## Task 1: Clean Core HTML — Remove Unit References + +**Files:** +- Modify: `web/core/html/source-panel.html` +- Modify: `web/core/html/equipment-panel.html` +- Modify: `web/core/html/modals.html` + +- [ ] **Step 1: Rewrite core source-panel to data sources only** + +Replace `web/core/html/source-panel.html` with: + +```html +
+
+

数据源

+ +
+
+
+``` + +- [ ] **Step 2: Remove batch-unit toolbar from core equipment-panel** + +Replace `web/core/html/equipment-panel.html` with: + +```html +
+
+

设备

+ +
+
+ + +
+
+
+``` + +- [ ] **Step 3: Remove unit select from core equipment modal** + +In `web/core/html/modals.html`, remove the unit select label from the equipment modal. Replace the equipment modal section (lines 1-35) with: + +```html + +``` + +Keep the rest of modals.html (point, source, binding modals) unchanged. + +- [ ] **Step 4: Commit** + +```bash +git add web/core/html/source-panel.html web/core/html/equipment-panel.html web/core/html/modals.html +git commit -m "refactor(web): remove unit references from core HTML pages" +``` + +## Task 2: Create Feeder Override Pages With Unit References + +**Files:** +- Create: `web/feeder/html/source-panel.html` +- Create: `web/feeder/html/equipment-panel.html` +- Create: `web/feeder/html/modals.html` + +- [ ] **Step 1: Create feeder source-panel override (units + sources stacked)** + +`web/feeder/html/source-panel.html` — same as the original file with both sections: + +```html +
+
+
+
+

控制单元

+
+ + +
+
+
+
+ +
+
+

数据源

+ +
+
+
+
+
+``` + +- [ ] **Step 2: Create feeder equipment-panel override (with unit batch toolbar)** + +`web/feeder/html/equipment-panel.html` — same as original with batch-unit toolbar: + +```html +
+
+

设备

+ +
+
+ + +
+
+
已选 0 台设备
+ + + +
+
+
+``` + +- [ ] **Step 3: Create feeder modals override (with unit select in equipment modal)** + +`web/feeder/html/modals.html` — copy of the ORIGINAL core modals.html (before Task 1 removes the unit select). This file overrides the core version via the ServeDir fallback chain. The equipment modal retains the unit select field that Task 1 removes from core. + +Write the full file with this content (identical to the original core modals.html): + +```html + + + + + + + + + +``` + +- [ ] **Step 4: Commit** + +```bash +git add web/feeder/html/source-panel.html web/feeder/html/equipment-panel.html web/feeder/html/modals.html +git commit -m "refactor(web): create feeder overrides for unit-dependent pages" +``` + +## Task 3: Create Feeder Unit Panel And Add Three-Tab UI + +**Files:** +- Create: `web/feeder/html/unit-panel.html` +- Modify: `web/feeder/html/topbar.html` +- Modify: `web/feeder/index.html` +- Modify: `web/feeder/js/dom.js` +- Modify: `web/feeder/js/app.js` +- Modify: `web/core/styles.css` + +- [ ] **Step 1: Create standalone unit-panel for app-config view** + +`web/feeder/html/unit-panel.html`: + +```html +
+
+

控制单元配置

+
+ + +
+
+
+
+``` + +Note: Uses separate IDs (`refreshUnitBtn2`, `newUnitBtn2`, `unitConfigList`) to avoid DOM ID conflicts with the unit list in the source-panel override. The JS wiring will connect these to the same handler functions. + +- [ ] **Step 2: Update topbar for three tabs** + +Replace `web/feeder/html/topbar.html`: + +```html +
+
投煤器布料机控制系统
+
+ + + +
+
+ + +
+ + 连接中… +
+
+
+``` + +- [ ] **Step 3: Replace feeder index.html `
` contents to include unit-panel partial** + +Replace the entire `
` block in `web/feeder/index.html`: + +```html +
+
+
+
+
+
+
+
+
+
+``` + +- [ ] **Step 4: Add grid-app-config layout to styles.css** + +Add after the existing `.grid-ops` rule in `web/core/styles.css`: + +```css +.grid-app-config { + display: grid; + gap: 1px; + height: calc(100vh - var(--topbar-h)); + grid-template-columns: 1fr; + grid-template-rows: 1fr; +} + +.grid-app-config .panel.app-config-main { grid-column: 1; grid-row: 1; } +``` + +Also add the responsive override inside the existing `@media (max-width: 900px)` block: + +```css + .grid-app-config { + grid-template-columns: 1fr; + grid-template-rows: auto; + height: auto; + } +``` + +- [ ] **Step 5: Add tabAppConfig to dom.js** + +In `web/feeder/js/dom.js`, add: + +```javascript + tabAppConfig: byId("tabAppConfig"), + refreshUnitBtn2: byId("refreshUnitBtn2"), + newUnitBtn2: byId("newUnitBtn2"), + unitConfigList: byId("unitConfigList"), +``` + +- [ ] **Step 6: Update app.js for three-way view switching** + +Replace the `switchView` function in `web/feeder/js/app.js`: + +```javascript +function switchView(view) { + state.activeView = view; + const main = document.querySelector("main"); + main.className = + view === "ops" ? "grid-ops" : + view === "app-config" ? "grid-app-config" : + "grid-config"; + + dom.tabOps.classList.toggle("active", view === "ops"); + dom.tabAppConfig.classList.toggle("active", view === "app-config"); + dom.tabConfig.classList.toggle("active", view === "config"); + + // config-only panels (platform config view) + ["top-left", "top-right", "bottom-left", "bottom-right"].forEach((cls) => { + const el = main.querySelector(`.panel.${cls}`); + if (el) el.classList.toggle("hidden", view !== "config"); + }); + const logStreamPanel = main.querySelector(".panel.bottom-mid"); + if (logStreamPanel) logStreamPanel.classList.toggle("hidden", view !== "config"); + + // ops-only panels + const opsMain = main.querySelector(".panel.ops-main"); + const opsBottom = main.querySelector(".panel.ops-bottom"); + if (opsMain) opsMain.classList.toggle("hidden", view !== "ops"); + if (opsBottom) opsBottom.classList.toggle("hidden", view !== "ops"); + + // app-config-only panels + const appConfigMain = main.querySelector(".panel.app-config-main"); + if (appConfigMain) appConfigMain.classList.toggle("hidden", view !== "app-config"); + + if (view === "config") { + startLogs(); + if (!_configLoaded) { + _configLoaded = true; + withStatus((async () => { + await Promise.all([loadSources(), loadEquipments(), loadEvents()]); + await loadPoints(); + })()); + } + } else { + stopLogs(); + } + + if (view === "app-config") { + if (!_appConfigLoaded) { + _appConfigLoaded = true; + withStatus(loadUnits()); + } + } +} +``` + +Add `let _appConfigLoaded = false;` alongside the existing `let _configLoaded = false;`. + +Update `bindEvents` to add: + +```javascript + dom.tabAppConfig.addEventListener("click", () => switchView("app-config")); + dom.refreshUnitBtn2.addEventListener("click", () => withStatus(loadUnits())); + dom.newUnitBtn2.addEventListener("click", openCreateUnitModal); +``` + +- [ ] **Step 7: Verify no broken partials** + +Run feeder locally, check all three tabs load without console errors. + +Run: + +```bash +cargo check -p app_feeder_distributor +``` + +Expected: PASS (no Rust changes in this task) + +- [ ] **Step 8: Commit** + +```bash +git add web/feeder/html/unit-panel.html web/feeder/html/topbar.html web/feeder/index.html web/feeder/js/dom.js web/feeder/js/app.js web/core/styles.css +git commit -m "feat(feeder): add three-tab UI (ops / app-config / platform-config)" +``` + +## Task 4: Move Log Handler To Core + +**Files:** +- Create: `crates/plc_platform_core/src/handler.rs` +- Create: `crates/plc_platform_core/src/handler/log.rs` +- Modify: `crates/plc_platform_core/src/lib.rs` +- Modify: `crates/app_feeder_distributor/src/handler.rs` +- Delete: `crates/app_feeder_distributor/src/handler/log.rs` + +- [ ] **Step 1: Create core handler module** + +`crates/plc_platform_core/src/handler.rs`: + +```rust +pub mod log; +``` + +- [ ] **Step 2: Move log.rs to core** + +Copy `crates/app_feeder_distributor/src/handler/log.rs` to `crates/plc_platform_core/src/handler/log.rs`. + +Change the import path from `plc_platform_core::util::response::ApiErr` to `crate::util::response::ApiErr`. + +- [ ] **Step 3: Export handler module from core lib.rs** + +Add `pub mod handler;` to `crates/plc_platform_core/src/lib.rs`. + +- [ ] **Step 4: Update feeder handler.rs to re-export log from core** + +In `crates/app_feeder_distributor/src/handler.rs`, replace: + +```rust +pub mod log; +``` + +with: + +```rust +pub mod log { + pub use plc_platform_core::handler::log::*; +} +``` + +Delete `crates/app_feeder_distributor/src/handler/log.rs`. + +- [ ] **Step 5: Verify feeder compiles** + +Run: + +```bash +cargo check -p app_feeder_distributor +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add crates/plc_platform_core/src/handler.rs crates/plc_platform_core/src/handler/log.rs crates/plc_platform_core/src/lib.rs crates/app_feeder_distributor/src/handler.rs +git rm crates/app_feeder_distributor/src/handler/log.rs +git commit -m "refactor(core): move log handler to shared platform core" +``` + +## Task 5: Move Doc Handler To Core And Split API.md + +**Files:** +- Create: `crates/plc_platform_core/src/handler/doc.rs` +- Modify: `crates/plc_platform_core/src/handler.rs` +- Modify: `crates/app_feeder_distributor/src/handler.rs` +- Modify: `crates/app_feeder_distributor/src/handler/doc.rs` +- Create: `crates/app_operation_system/src/handler.rs` +- Create: `crates/app_operation_system/src/handler/doc.rs` +- Rename: `API.md` → `docs/api-feeder.md` +- Create: `docs/api-ops.md` + +- [ ] **Step 1: Create core generic markdown serving utility** + +`crates/plc_platform_core/src/handler/doc.rs`: + +```rust +use axum::{ + http::{header, HeaderMap, HeaderValue, StatusCode}, + response::IntoResponse, +}; + +use crate::util::response::ApiErr; + +pub async fn serve_markdown(path: &str) -> Result { + let content = tokio::fs::read_to_string(path) + .await + .map_err(|err| { + tracing::error!("Failed to read {}: {}", path, err); + ApiErr::NotFound(format!("{} not found", path), None) + })?; + + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/markdown; charset=utf-8"), + ); + + Ok((StatusCode::OK, headers, content)) +} +``` + +- [ ] **Step 2: Add doc to core handler module** + +In `crates/plc_platform_core/src/handler.rs`, add: + +```rust +pub mod doc; +pub mod log; +``` + +- [ ] **Step 3: Update feeder doc handler to use core utility** + +Replace `crates/app_feeder_distributor/src/handler/doc.rs`: + +```rust +use axum::response::IntoResponse; +use plc_platform_core::util::response::ApiErr; + +pub async fn get_api_md() -> Result { + plc_platform_core::handler::doc::serve_markdown("docs/api-feeder.md").await +} + +pub async fn get_readme_md() -> Result { + plc_platform_core::handler::doc::serve_markdown("README.md").await +} +``` + +- [ ] **Step 4: Rename API.md to docs/api-feeder.md** + +```bash +git mv API.md docs/api-feeder.md +``` + +- [ ] **Step 5: Create ops API doc** + +`docs/api-ops.md`: + +```markdown +# 运转系统 API + +## 健康检查 + +- `GET /api/health` — 返回应用名称和状态 + +## 日志 + +- `GET /api/logs` — 拉取日志内容 +- `GET /api/logs/stream` — SSE 增量推送 + +## 文档 + +- `GET /api/docs/api-md` — 获取 API 文档 +- `GET /api/docs/readme-md` — 获取 README +``` + +- [ ] **Step 6: Create ops handler module with doc handler** + +`crates/app_operation_system/src/handler.rs`: + +```rust +pub mod doc; +``` + +`crates/app_operation_system/src/handler/doc.rs`: + +```rust +use axum::response::IntoResponse; +use plc_platform_core::util::response::ApiErr; + +pub async fn get_api_md() -> Result { + plc_platform_core::handler::doc::serve_markdown("docs/api-ops.md").await +} + +pub async fn get_readme_md() -> Result { + plc_platform_core::handler::doc::serve_markdown("README.md").await +} +``` + +Add `pub mod handler;` to `crates/app_operation_system/src/lib.rs`. + +- [ ] **Step 7: Verify both apps compile** + +Run: + +```bash +cargo check -p app_feeder_distributor +cargo check -p app_operation_system +``` + +Expected: both PASS + +- [ ] **Step 8: Commit** + +```bash +git add crates/plc_platform_core/src/handler/doc.rs crates/plc_platform_core/src/handler.rs crates/app_feeder_distributor/src/handler/doc.rs crates/app_operation_system/src/handler.rs crates/app_operation_system/src/handler/doc.rs crates/app_operation_system/src/lib.rs crates/app_operation_system/Cargo.toml docs/api-feeder.md docs/api-ops.md +git commit -m "refactor(core): move doc handler to core and split API.md per app" +``` + +## Task 6: Register Core Handlers In Ops App Router + +**Files:** +- Modify: `crates/app_operation_system/src/router.rs` + +- [ ] **Step 1: Add log and doc routes to ops router** + +Update `crates/app_operation_system/src/router.rs`: + +```rust +use axum::{extract::State, routing::get, Router}; +use tower_http::services::ServeDir; + +use crate::app::AppState; + +async fn no_cache( + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let mut response = next.run(req).await; + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-store"), + ); + response +} + +pub fn build_router(state: AppState) -> Router { + Router::new() + .route("/api/health", get(health_check)) + .route("/api/logs", get(plc_platform_core::handler::log::get_logs)) + .route("/api/logs/stream", get(plc_platform_core::handler::log::stream_logs)) + .route("/api/docs/api-md", get(crate::handler::doc::get_api_md)) + .route("/api/docs/readme-md", get(crate::handler::doc::get_readme_md)) + .nest( + "/ui", + Router::new() + .fallback_service( + ServeDir::new("web/ops") + .append_index_html_on_directories(true) + .fallback(ServeDir::new("web/core")), + ) + .layer(axum::middleware::from_fn(no_cache)), + ) + .with_state(state) +} + +async fn health_check(State(state): State) -> String { + format!("{}:ok", state.app_name) +} +``` + +- [ ] **Step 2: Verify ops compiles and tests pass** + +Run: + +```bash +cargo check -p app_operation_system +cargo test -p app_operation_system +``` + +Expected: both PASS + +- [ ] **Step 3: Commit** + +```bash +git add crates/app_operation_system/src/router.rs +git commit -m "feat(ops): register log and doc routes from shared core" +``` + +## Task 7: Update Feeder Router API Doc Path Reference + +**Files:** +- Modify: `crates/app_feeder_distributor/src/router.rs` (no change if doc handler wrapper handles path) + +- [ ] **Step 1: Verify feeder doc route still works with renamed file** + +The feeder doc handler now reads `docs/api-feeder.md` instead of `API.md`. The route `/api/docs/api-md` stays the same — only the file path changed inside the handler. + +Run: + +```bash +cargo test -p app_feeder_distributor +``` + +Expected: PASS + +- [ ] **Step 2: Update README reference to API.md** + +In `README.md`, replace the doc index entry: + +Before: +```markdown +- API 接口说明: `API.md` +``` + +After: +```markdown +- 投煤器布料机 API: `docs/api-feeder.md` +- 运转系统 API: `docs/api-ops.md` +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: update API doc references for per-app split" +``` + +## Task 8: Final Verification + +- [ ] **Step 1: Run all workspace tests** + +Run: + +```bash +cargo test --workspace +``` + +Expected: all PASS + +- [ ] **Step 2: Run release builds** + +Run: + +```bash +cargo build -p app_feeder_distributor --release +cargo build -p app_operation_system --release +``` + +Expected: both produce binaries + +- [ ] **Step 3: Verify web file layout** + +Run: + +```bash +find web -type f | sort +``` + +Expected: core pages have no unit references, feeder overrides contain unit references, both apps have their own topbar and index. + +- [ ] **Step 4: Verify core HTML has no unit references** + +Run: + +```bash +grep -ri "unit" web/core/ --include="*.html" +``` + +Expected: no matches + +## Self-Review + +### Coverage + +- Three-panel UI (ops / app-config / platform-config): Tasks 1–3 +- Core HTML cleaned of unit references: Task 1 +- Feeder overrides via fallback chain: Task 2 +- Log handler to core: Task 4 +- Doc handler to core + API.md split: Tasks 5, 7 +- Ops app gets log/doc routes: Task 6 +- Build verification: Task 8 + +### What this plan does NOT cover (deferred) + +- **PlatformContext completion**: Filling in pool/connection_manager/event_manager/ws_manager in the core context struct. This is a prerequisite for moving the remaining handlers (source, point, equipment, tag, page) to core. +- **Remaining handler migration**: source.rs (626 lines), point.rs (693 lines), equipment.rs (335 lines), tag.rs (126 lines), page.rs (169 lines) all depend on `AppState` and require PlatformContext to move to core. +- **Unit-panel JS wiring**: The standalone unit-panel in app-config view needs JS to render unit list and handle CRUD. Currently the unit rendering logic in `units.js` targets `#unitList` (in source-panel). Wiring `#unitConfigList` to the same data is a follow-up JS task. + +### Key design decisions + +- **ServeDir fallback override pattern**: Feeder overrides core pages by placing same-named files in `web/feeder/html/`. The fallback chain tries app dir first. This means core files are the "clean" base, feeder adds business-specific UI on top. +- **Separate unit-panel IDs**: Uses `refreshUnitBtn2`, `newUnitBtn2`, `unitConfigList` to avoid conflicts with the unit list embedded in the feeder source-panel override. Both are wired to the same `loadUnits()` / `openCreateUnitModal()` functions. +- **Log handler is fully stateless**: Reads files from `./logs` directory. No AppState dependency. Trivially movable to core. +- **Doc handler split**: Core provides `serve_markdown(path)` utility. Each app wraps it to point to its own API doc file. diff --git a/docs/superpowers/plans/2026-04-17-web-split-and-cleanup.md b/docs/superpowers/plans/2026-04-17-web-split-and-cleanup.md new file mode 100644 index 0000000..fca37be --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-web-split-and-cleanup.md @@ -0,0 +1,564 @@ +# Web Page Split And Root Source Cleanup + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restructure the `web/` directory into `core/` + `feeder/` + `ops/` subdirectories, delete the obsolete root `src/` files, and update README to reflect the new workspace layout. + +**Architecture:** Web pages split into a shared `web/core/` (platform HTML partials and CSS) and per-app directories (`web/feeder/`, `web/ops/`). Each app's Axum router uses `ServeDir` with fallback: try app-specific dir first, then core. This means no URL changes in HTML/JS — the fallback chain resolves transparently. The root `src/` contains stale copies of files already migrated to crates and must be removed. + +**Tech Stack:** Rust (Axum, tower-http ServeDir), HTML/CSS/JS (vanilla, ES modules), Cargo workspace + +--- + +## File Map + +### Web core (shared platform pages) + +- Move: `web/styles.css` → `web/core/styles.css` +- Move: `web/html/source-panel.html` → `web/core/html/source-panel.html` +- Move: `web/html/points-panel.html` → `web/core/html/points-panel.html` +- Move: `web/html/equipment-panel.html` → `web/core/html/equipment-panel.html` +- Move: `web/html/chart-panel.html` → `web/core/html/chart-panel.html` +- Move: `web/html/log-stream-panel.html` → `web/core/html/log-stream-panel.html` +- Move: `web/html/logs-panel.html` → `web/core/html/logs-panel.html` +- Move: `web/html/api-doc-drawer.html` → `web/core/html/api-doc-drawer.html` +- Create: `web/core/html/modals.html` (core modals only — equipment, source, point, binding; unit modal removed) + +### Web feeder (feeder-specific pages + all JS) + +- Move: `web/index.html` → `web/feeder/index.html` (add unit-modal partial reference) +- Move: `web/html/topbar.html` → `web/feeder/html/topbar.html` +- Move: `web/html/ops-panel.html` → `web/feeder/html/ops-panel.html` +- Create: `web/feeder/html/unit-modal.html` (extracted from old modals.html) +- Move: `web/js/*.js` → `web/feeder/js/*.js` (all 15 JS files stay together as interconnected module graph) + +### Web ops (operation-system pages) + +- Move: `crates/app_operation_system/web/index.html` → `web/ops/index.html` (updated content) +- Create: `web/ops/html/topbar.html` +- Create: `web/ops/js/index.js` +- Create: `web/ops/js/app.js` + +### Rust router changes + +- Modify: `crates/app_feeder_distributor/src/router.rs` (update ServeDir to use fallback) +- Modify: `crates/app_operation_system/src/router.rs` (update ServeDir to use fallback) + +### Root src cleanup + +- Delete: all 19 files under `src/` (stale duplicates of files in crates) + +### Documentation + +- Modify: `README.md` + +--- + +## Task 1: Split modals.html And Create Unit Modal Partial + +**Files:** +- Create: `web/core/html/modals.html` +- Create: `web/feeder/html/unit-modal.html` + +- [ ] **Step 1: Create core modals (without unit modal)** + +Extract everything except the unit modal div from `web/html/modals.html` into a new file: + +`web/core/html/modals.html`: +```html + + + + + + + + + +``` + +- [ ] **Step 2: Create feeder unit modal partial** + +Extract the unit modal into its own file: + +`web/feeder/html/unit-modal.html`: +```html + +``` + +- [ ] **Step 3: Verify both files contain all original modal content** + +Check that the combined line count of the two new files matches the original `web/html/modals.html` (188 lines total, minus blank lines between sections). + +- [ ] **Step 4: Commit** + +```bash +git add web/core/html/modals.html web/feeder/html/unit-modal.html +git commit -m "refactor(web): split modals into core and feeder unit-modal" +``` + +## Task 2: Move Core HTML And CSS Into web/core + +**Files:** +- Move: `web/styles.css` → `web/core/styles.css` +- Move: `web/html/source-panel.html` → `web/core/html/source-panel.html` +- Move: `web/html/points-panel.html` → `web/core/html/points-panel.html` +- Move: `web/html/equipment-panel.html` → `web/core/html/equipment-panel.html` +- Move: `web/html/chart-panel.html` → `web/core/html/chart-panel.html` +- Move: `web/html/log-stream-panel.html` → `web/core/html/log-stream-panel.html` +- Move: `web/html/logs-panel.html` → `web/core/html/logs-panel.html` +- Move: `web/html/api-doc-drawer.html` → `web/core/html/api-doc-drawer.html` + +- [ ] **Step 1: Create core directories and move files** + +```bash +mkdir -p web/core/html +git mv web/styles.css web/core/styles.css +git mv web/html/source-panel.html web/core/html/source-panel.html +git mv web/html/points-panel.html web/core/html/points-panel.html +git mv web/html/equipment-panel.html web/core/html/equipment-panel.html +git mv web/html/chart-panel.html web/core/html/chart-panel.html +git mv web/html/log-stream-panel.html web/core/html/log-stream-panel.html +git mv web/html/logs-panel.html web/core/html/logs-panel.html +git mv web/html/api-doc-drawer.html web/core/html/api-doc-drawer.html +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/core +git commit -m "refactor(web): move shared HTML partials and CSS into web/core" +``` + +## Task 3: Move Feeder-Specific HTML And All JS Into web/feeder + +**Files:** +- Move: `web/index.html` → `web/feeder/index.html` +- Move: `web/html/topbar.html` → `web/feeder/html/topbar.html` +- Move: `web/html/ops-panel.html` → `web/feeder/html/ops-panel.html` +- Move: `web/js/*.js` → `web/feeder/js/*.js` +- Delete: `web/html/modals.html` (replaced by split files in Task 1) + +- [ ] **Step 1: Create feeder directories and move files** + +```bash +mkdir -p web/feeder/html web/feeder/js +git mv web/html/topbar.html web/feeder/html/topbar.html +git mv web/html/ops-panel.html web/feeder/html/ops-panel.html +git mv web/js/api.js web/feeder/js/api.js +git mv web/js/app.js web/feeder/js/app.js +git mv web/js/chart.js web/feeder/js/chart.js +git mv web/js/docs.js web/feeder/js/docs.js +git mv web/js/dom.js web/feeder/js/dom.js +git mv web/js/equipment.js web/feeder/js/equipment.js +git mv web/js/events.js web/feeder/js/events.js +git mv web/js/index.js web/feeder/js/index.js +git mv web/js/logs.js web/feeder/js/logs.js +git mv web/js/ops.js web/feeder/js/ops.js +git mv web/js/points.js web/feeder/js/points.js +git mv web/js/roles.js web/feeder/js/roles.js +git mv web/js/sources.js web/feeder/js/sources.js +git mv web/js/state.js web/feeder/js/state.js +git mv web/js/units.js web/feeder/js/units.js +``` + +- [ ] **Step 2: Move index.html and delete old modals** + +```bash +git mv web/index.html web/feeder/index.html +git rm web/html/modals.html +``` + +- [ ] **Step 3: Update feeder index.html to add unit-modal partial** + +In `web/feeder/index.html`, change the modals partial line and add a unit-modal partial: + +Before: +```html +
+``` + +After: +```html +
+
+``` + +- [ ] **Step 4: Verify no files remain in old web/html and web/js directories** + +```bash +ls web/html/ 2>/dev/null && echo "ERROR: web/html still has files" || echo "OK: web/html is clean" +ls web/js/ 2>/dev/null && echo "ERROR: web/js still has files" || echo "OK: web/js is clean" +``` + +Expected: both directories are empty or deleted. + +- [ ] **Step 5: Commit** + +```bash +git add web/feeder +git commit -m "refactor(web): move feeder HTML, JS, and index into web/feeder" +``` + +## Task 4: Update Feeder Router To Use Fallback ServeDir + +**Files:** +- Modify: `crates/app_feeder_distributor/src/router.rs` + +- [ ] **Step 1: Update the static file serving to use fallback chain** + +In `crates/app_feeder_distributor/src/router.rs`, replace the current `/ui` nest: + +Before: +```rust + .nest( + "/ui", + Router::new() + .fallback_service(ServeDir::new("web").append_index_html_on_directories(true)) + .layer(axum::middleware::from_fn(no_cache)), + ) +``` + +After: +```rust + .nest( + "/ui", + Router::new() + .fallback_service( + ServeDir::new("web/feeder") + .append_index_html_on_directories(true) + .fallback(ServeDir::new("web/core")), + ) + .layer(axum::middleware::from_fn(no_cache)), + ) +``` + +- [ ] **Step 2: Verify feeder crate compiles** + +Run: + +```bash +cargo check -p app_feeder_distributor +``` + +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add crates/app_feeder_distributor/src/router.rs +git commit -m "refactor(feeder): update static file serving for split web dirs" +``` + +## Task 5: Create Operation-System Web Pages And Update Router + +**Files:** +- Create: `web/ops/index.html` +- Create: `web/ops/html/topbar.html` +- Create: `web/ops/js/index.js` +- Create: `web/ops/js/app.js` +- Modify: `crates/app_operation_system/src/router.rs` +- Delete: `crates/app_operation_system/web/index.html` + +- [ ] **Step 1: Create ops web scaffold** + +`web/ops/index.html`: +```html + + + + + + 运转系统 + + + +
+ +
+
运转系统页面开发中
+
+ + + + +``` + +`web/ops/html/topbar.html`: +```html +
+
运转系统
+
+
+ + 连接中… +
+
+
+``` + +`web/ops/js/index.js`: +```javascript +async function loadPartial(slot) { + const response = await fetch(slot.dataset.partial); + if (!response.ok) { + throw new Error(`Failed to load partial: ${slot.dataset.partial}`); + } + + const html = await response.text(); + slot.insertAdjacentHTML("beforebegin", html); + slot.remove(); +} + +async function bootstrapPage() { + const slots = Array.from(document.querySelectorAll("[data-partial]")); + await Promise.all(slots.map((slot) => loadPartial(slot))); + await import("./app.js"); +} + +bootstrapPage().catch((error) => { + document.body.innerHTML = `
${error.message || String(error)}
`; +}); +``` + +`web/ops/js/app.js`: +```javascript +function bootstrap() { + console.log("Operation system app initialized"); +} + +bootstrap(); +``` + +- [ ] **Step 2: Update ops router to use split web dirs** + +Replace `crates/app_operation_system/src/router.rs`: + +```rust +use axum::{extract::State, routing::get, Router}; +use tower_http::services::ServeDir; + +use crate::app::AppState; + +async fn no_cache(req: axum::extract::Request, next: axum::middleware::Next) -> axum::response::Response { + let mut response = next.run(req).await; + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-store"), + ); + response +} + +pub fn build_router(state: AppState) -> Router { + Router::new() + .route("/api/health", get(health_check)) + .nest( + "/ui", + Router::new() + .fallback_service( + ServeDir::new("web/ops") + .append_index_html_on_directories(true) + .fallback(ServeDir::new("web/core")), + ) + .layer(axum::middleware::from_fn(no_cache)), + ) + .with_state(state) +} + +async fn health_check(State(state): State) -> String { + format!("{}:ok", state.app_name) +} +``` + +- [ ] **Step 3: Delete old ops web placeholder** + +```bash +git rm crates/app_operation_system/web/index.html +rmdir crates/app_operation_system/web 2>/dev/null || true +``` + +- [ ] **Step 4: Verify ops crate compiles** + +Run: + +```bash +cargo check -p app_operation_system +``` + +Expected: PASS + +- [ ] **Step 5: Update ops router smoke test if needed** + +Check `crates/app_operation_system/tests/router_smoke.rs` — if it references the old `WEB_ROOT` constant, update accordingly. + +- [ ] **Step 6: Commit** + +```bash +git add web/ops crates/app_operation_system +git commit -m "refactor(ops): add ops web scaffold and update router for split dirs" +``` + +## Task 6: Delete Obsolete Root src/ Files + +**Files:** +- Delete: all 19 files under `src/` + +- [ ] **Step 1: Verify all root src files are duplicates of crate files** + +Run quick checks: + +```bash +diff src/config.rs crates/app_feeder_distributor/src/config.rs +diff src/handler.rs crates/app_feeder_distributor/src/handler.rs +diff src/middleware.rs crates/app_feeder_distributor/src/middleware.rs +``` + +All should show no functional differences (only BOM or whitespace). + +- [ ] **Step 2: Remove all root src files from git** + +```bash +git rm -r src/ +``` + +- [ ] **Step 3: Verify workspace still builds** + +Run: + +```bash +cargo check --workspace +``` + +Expected: PASS (root src/ is not a workspace member, removing it changes nothing for the build) + +- [ ] **Step 4: Commit** + +```bash +git commit -m "chore: remove obsolete root src/ (migrated to crates)" +``` + +## Task 7: Update README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Update the README to reflect the workspace structure** + +Replace the outdated "后端结构" and add build instructions. Key sections to update: + +- Remove references to `src/main.rs`, `src/handler`, `src/service` +- Add workspace structure overview: + +```markdown +## 项目结构 + +```text +plc_control/ + Cargo.toml # Workspace root + crates/ + plc_platform_core/ # 共享平台核心库 + app_feeder_distributor/ # 投煤器布料机专用版 + app_operation_system/ # 运转系统专用版 + web/ + core/ # 共享 HTML/CSS(点位、设备、数据源等) + feeder/ # 投煤器布料机页面 + JS + ops/ # 运转系统页面 + JS +``` + +## 构建 + +```powershell +# 投煤器布料机 +cargo build -p app_feeder_distributor --release + +# 运转系统 +cargo build -p app_operation_system --release +``` + +## 部署 + +将编译产物和 `web/` 目录放在同一级目录下: + +```text +deploy/ + app_feeder_distributor.exe + web/ + core/ + feeder/ +``` +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: update README for workspace and web split layout" +``` + +## Task 8: Final Verification + +- [ ] **Step 1: Run all tests** + +```bash +cargo test --workspace +``` + +Expected: PASS + +- [ ] **Step 2: Run release builds** + +```bash +cargo build -p app_feeder_distributor --release +cargo build -p app_operation_system --release +``` + +Expected: both produce binaries successfully. + +- [ ] **Step 3: Verify web file layout** + +```bash +find web -type f | sort +``` + +Expected: files organized under `web/core/`, `web/feeder/`, `web/ops/` only. No files remaining directly under `web/html/` or `web/js/`. + +## Self-Review + +### Spec coverage + +- Web split into core + per-app directories: Tasks 1–5 +- Fallback ServeDir for transparent URL resolution: Tasks 4–5 +- Root src cleanup: Task 6 +- README update: Task 7 +- Build verification: Task 8 + +### Key design decision: ServeDir fallback + +Using `ServeDir::new("web/feeder").fallback(ServeDir::new("web/core"))` means: +- No URL changes needed in any HTML partial references or JS imports +- App-specific files override core files of the same name (app takes priority) +- Browser requests are resolved transparently through the chain + +### Spec deviation: web directory location + +The original design spec §8.4 suggested per-app web directories inside each crate (`app_feeder_distributor/web`, `app_operation_system/web`). This plan deliberately places web files at the workspace root (`web/core/`, `web/feeder/`, `web/ops/`) instead. Reason: enables the ServeDir fallback chain to share core assets without duplication, and avoids coupling web resources to Rust crate build paths. This is a justified departure from the spec. + +### What this plan does NOT cover (deferred) + +- `PlatformContext` completion (filling in pool/connection_manager/event_manager/ws_manager) +- `config.rs` migration into shared core +- `control/validator.rs` splitting +- Event namespace migration at call sites +- These are larger refactors that should be planned separately diff --git a/web/core/html/api-doc-drawer.html b/web/core/html/api-doc-drawer.html deleted file mode 100644 index c85a51a..0000000 --- a/web/core/html/api-doc-drawer.html +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/web/core/html/chart-panel.html b/web/core/html/chart-panel.html deleted file mode 100644 index 6d4ba12..0000000 --- a/web/core/html/chart-panel.html +++ /dev/null @@ -1,10 +0,0 @@ -
-
-

点位曲线

- -
-
-
点击上方点位表中的一行查看曲线
- -
-
diff --git a/web/core/html/equipment-panel.html b/web/core/html/equipment-panel.html deleted file mode 100644 index 7601075..0000000 --- a/web/core/html/equipment-panel.html +++ /dev/null @@ -1,11 +0,0 @@ -
-
-

设备

- -
-
- - -
-
-
diff --git a/web/core/html/log-stream-panel.html b/web/core/html/log-stream-panel.html deleted file mode 100644 index 6a2e8a8..0000000 --- a/web/core/html/log-stream-panel.html +++ /dev/null @@ -1,6 +0,0 @@ -
-
-

实时日志

-
-
-
diff --git a/web/core/html/logs-panel.html b/web/core/html/logs-panel.html deleted file mode 100644 index 2d6d5f3..0000000 --- a/web/core/html/logs-panel.html +++ /dev/null @@ -1,7 +0,0 @@ -
-
-

系统事件

- -
-
-
diff --git a/web/core/html/modals.html b/web/core/html/modals.html deleted file mode 100644 index 71c52c9..0000000 --- a/web/core/html/modals.html +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - - - diff --git a/web/core/html/points-panel.html b/web/core/html/points-panel.html deleted file mode 100644 index 458022a..0000000 --- a/web/core/html/points-panel.html +++ /dev/null @@ -1,37 +0,0 @@ -
-
-

点位

-
- - 1 / 1 - -
-
-
- -
当前筛选: 全部点位
-
已选中 0 个点位
- - - -
-
- - - - - - - - - - - - - -
名称质量设备/角色更新时间
-
-
diff --git a/web/core/html/source-panel.html b/web/core/html/source-panel.html deleted file mode 100644 index 14b7f40..0000000 --- a/web/core/html/source-panel.html +++ /dev/null @@ -1,7 +0,0 @@ -
-
-

数据源

- -
-
-
diff --git a/web/core/styles.css b/web/core/styles.css deleted file mode 100644 index afc59e4..0000000 --- a/web/core/styles.css +++ /dev/null @@ -1,1402 +0,0 @@ -:root { - --bg: #f1f5f9; - --surface: #ffffff; - --surface-2: #f8fafc; - --text: #0f172a; - --text-2: #475569; - --text-3: #94a3b8; - --accent: #2563eb; - --accent-hover: #1d4ed8; - --accent-bg: rgba(37, 99, 235, 0.06); - --success: #059669; - --danger: #ef4444; - --danger-hover: #dc2626; - --warning: #d97706; - --border: #e2e8f0; - --border-light: #f1f5f9; - --radius: 0; - --topbar-h: 42px; -} - -* { box-sizing: border-box; margin: 0; } - -body { - font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; - color: var(--text); - background: var(--border); - font-size: 14px; - line-height: 1.5; - height: 100vh; - overflow: hidden; -} - -/* ── Topbar ─────────────────────────────────────── */ - -.topbar { - display: flex; - justify-content: space-between; - align-items: center; - height: var(--topbar-h); - padding: 0 16px; - background: var(--surface); - border-bottom: 1px solid var(--border); -} - -.title { - font-size: 15px; - font-weight: 700; - color: var(--accent); -} - -.status { - font-size: 12px; - color: var(--text-3); - display: flex; - align-items: center; - gap: 5px; -} -.ws-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--text-3); - flex-shrink: 0; - transition: background 0.3s; -} -.ws-dot.connected { background: #22c55e; } -.ws-dot.disconnected { background: #ef4444; } - -.topbar-actions { - display: flex; - align-items: center; - gap: 10px; -} - -/* ── Tabs ───────────────────────────────────────── */ - -.tab-bar { - display: flex; - gap: 2px; -} - -.tab-btn { - padding: 0 16px; - height: 28px; - font-size: 13px; - font-weight: 500; - background: transparent; - border: 1px solid var(--border); - color: var(--text-2); - cursor: pointer; -} - -.tab-btn.active { - background: var(--accent); - border-color: var(--accent); - color: #fff; -} - -.link-button { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 26px; - padding: 0 10px; - color: var(--text-2); - text-decoration: none; - border: 1px solid var(--border); - background: transparent; - font-size: 12px; -} - -.link-button:hover { - background: var(--bg); - border-color: var(--text-3); -} - -/* ── Grid Layout ────────────────────────────────── */ - -.grid-ops, -.grid-config { - display: grid; - gap: 1px; - height: calc(100vh - var(--topbar-h)); -} - -.grid-config { - grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr); - grid-template-rows: 1fr 380px; -} - -.grid-ops { - grid-template-columns: 260px minmax(0, 1fr); - grid-template-rows: 1fr 260px; -} - -.grid-app-config { - display: grid; - gap: 1px; - height: calc(100vh - var(--topbar-h)); - grid-template-columns: 1fr; - grid-template-rows: 1fr; -} - -.grid-app-config .panel.app-config-main { grid-column: 1; grid-row: 1; } - -.list.unit-config-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 8px; - padding: 8px; - align-content: start; - overflow-y: auto; -} - -.unit-config-list .unit-card { - border: 1px solid var(--border); - border-radius: 4px; - padding: 10px; -} - -.unit-config-list .unit-card .unit-equipment-tags { - display: flex; - flex-wrap: wrap; - gap: 4px; - padding-top: 4px; -} - -.unit-config-list .unit-card .unit-equipment-tags .badge { - font-size: 12px; -} - -.unit-equip-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 6px; - padding: 8px; -} - -.unit-equip-grid .unit-equip-item { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 8px; - border: 1px solid var(--border); - border-radius: 4px; - cursor: pointer; - font-size: 13px; - transition: border-color 0.15s; -} - -.unit-equip-grid .unit-equip-item:hover { - border-color: rgba(37, 99, 235, 0.3); - background: var(--accent-bg); -} - -.unit-equip-grid .unit-equip-item input[type="checkbox"] { - margin: 0; -} - -/* config view slot assignments */ -.grid-config .panel.top-left { grid-column: 1; grid-row: 1; } -.grid-config .panel.top-right { grid-column: 2 / 4; grid-row: 1; } -.grid-config .panel.bottom-left { grid-column: 1; grid-row: 2; } -.grid-config .panel.bottom-mid { grid-column: 2; grid-row: 2; } -.grid-config .panel.bottom-right{ grid-column: 3; grid-row: 2; } - -/* ops view slot assignments */ -.grid-ops .panel.ops-main { grid-column: 1 / 3; grid-row: 1; } -.grid-ops .panel.ops-bottom { grid-column: 1 / 3; grid-row: 2; } - -.panel { - background: var(--surface); - display: flex; - flex-direction: column; - min-height: 0; - overflow: hidden; -} - -.stack-panel { - display: flex; - flex-direction: column; - min-height: 0; - flex: 1 1 auto; -} - -.stack-section { - display: flex; - flex-direction: column; - min-height: 0; - flex: 1 1 0; -} - -.stack-section-bordered { - border-top: 1px solid var(--border-light); -} - -/* ── Ops View ───────────────────────────────────── */ - -.ops-layout { - display: flex; - min-height: 0; - flex: 1 1 auto; - overflow: hidden; -} - -.ops-unit-sidebar { - width: 260px; - flex-shrink: 0; - border-right: 1px solid var(--border); - display: flex; - flex-direction: column; - min-height: 0; - overflow: hidden; -} - -.ops-unit-list { - flex: 1 1 auto; - overflow-y: auto; -} - -.ops-equipment-area { - flex: 1 1 auto; - overflow: auto; - padding: 12px; - display: flex; - flex-wrap: wrap; - align-content: flex-start; - gap: 12px; -} - -.ops-placeholder { - padding: 20px; -} - -/* Equipment ops card */ -.ops-eq-card { - width: 220px; - border: 1px solid var(--border); - background: var(--surface); - display: flex; - flex-direction: column; - gap: 0; -} - -.ops-eq-card-head { - padding: 8px 10px 6px; - border-bottom: 1px solid var(--border-light); - display: flex; - align-items: center; - gap: 6px; -} - -.ops-eq-card-head strong { - flex: 1; - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ops-signal-rows { - padding: 6px 10px; - display: flex; - flex-direction: row; - gap: 4px; - align-items: center; -} - -.sig-pill { - display: inline-flex; - align-items: center; - justify-content: center; - width: 40px; - height: 20px; - border-radius: 3px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.5px; - background: var(--surface-2, #e0e0e0); - color: var(--text-3); - transition: background 0.2s, color 0.2s; - user-select: none; -} -.sig-pill.sig-on { background: var(--success); color: #fff; } -.sig-pill.sig-fault { background: var(--danger); color: #fff; } -.sig-pill.sig-warn { background: var(--warning); color: #333; } - -.ops-eq-card-actions { - padding: 6px 10px 8px; - display: flex; - gap: 6px; - border-top: 1px solid var(--border-light); -} - -.ops-eq-card-actions button { - flex: 1; - padding: 3px 0; - font-size: 12px; -} - -/* ops unit list item */ -.ops-unit-item { - padding: 8px 10px; - cursor: pointer; - border-bottom: 1px solid var(--border-light); - display: flex; - flex-direction: column; - gap: 3px; -} - -.ops-unit-item:hover { background: var(--accent-bg); } -.ops-unit-item.selected { - background: var(--accent-bg); - border-left: 3px solid var(--accent); -} - -.ops-unit-item-name { - font-size: 13px; - font-weight: 600; -} - -.ops-unit-item-meta { - font-size: 11px; - color: var(--text-3); - display: flex; - align-items: center; - gap: 6px; -} - -.ops-unit-item-actions { - display: flex; - gap: 4px; - padding-top: 4px; -} - -.ops-unit-item-actions button { - padding: 2px 8px; - font-size: 11px; -} - -/* ── Panel Header ───────────────────────────────── */ - -.panel-head { - display: flex; - justify-content: space-between; - align-items: center; - padding: 7px 12px; - border-bottom: 1px solid var(--border-light); - flex-shrink: 0; - flex-wrap: wrap; - gap: 6px; -} -.ops-batch-actions { - display: flex; - gap: 4px; -} -.ops-batch-actions button { - font-size: 11px; - padding: 2px 8px; -} - -h2, h3 { - font-size: 12px; - font-weight: 600; - color: var(--text-2); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* ── Buttons ────────────────────────────────────── */ - -button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - padding: 4px 10px; - border: none; - border-radius: var(--radius); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s; - background: var(--accent); - color: #fff; - white-space: nowrap; -} - -button:hover { background: var(--accent-hover); } - -button:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -button.secondary { - background: transparent; - color: var(--text-2); - border: 1px solid var(--border); -} - -button.secondary:hover { - background: var(--bg); - border-color: var(--text-3); -} - -button.danger { - background: var(--danger); - color: #fff; -} - -button.danger:hover { background: var(--danger-hover); } - -/* ── Source List ─────────────────────────────────── */ - -.list { - display: flex; - flex-direction: column; - overflow-y: auto; - flex: 1 1 auto; - min-height: 0; - padding: 6px; - gap: 4px; -} - -.list-item { - padding: 7px 10px; - border: 1px solid var(--border); - border-radius: var(--radius); - display: flex; - flex-direction: column; - gap: 4px; - cursor: pointer; - transition: all 0.15s; -} - -.list-item:hover { - background: var(--accent-bg); - border-color: rgba(37, 99, 235, 0.2); -} - -.list-item.selected { - background: var(--accent-bg); - border-color: var(--accent); -} - -.list-item .row { - display: flex; - justify-content: space-between; - align-items: center; - gap: 6px; -} - -.equipment-list { - padding-top: 0; -} - -.equipment-card, -.source-card { - background: var(--surface); -} - -.equipment-card-actions, -.source-card-actions { - padding-top: 4px; -} - -.source-panels { - display: grid; - grid-template-columns: 1fr; - gap: 6px; - padding: 8px; - overflow: auto; -} - -.list-item button { - padding: 2px 8px; - font-size: 11px; -} - -.muted { - color: var(--text-3); - font-size: 12px; -} - -/* ── Badges ──────────────────────────────────────── */ - -.badge { - display: inline-block; - font-size: 10px; - font-weight: 600; - padding: 1px 6px; - border-radius: 0; - text-transform: uppercase; - letter-spacing: 0.3px; - background: rgba(37, 99, 235, 0.1); - color: var(--accent); -} - -.badge.offline { background: rgba(239, 68, 68, 0.1); color: var(--danger); } - -.badge.quality-good { background: rgba(5, 150, 105, 0.1); color: var(--success); } -.badge.quality-bad { background: rgba(239, 68, 68, 0.1); color: #dc2626; } -.badge.quality-uncertain { background: rgba(217, 119, 6, 0.1); color: var(--warning); } -.badge.quality-unknown { background: rgba(148, 163, 184, 0.12); color: #64748b; } - -/* ── Data Table (Points) ─────────────────────────── */ - -.table-wrap { - flex: 1 1 auto; - overflow: auto; - min-height: 0; -} - -.data-table { - width: 100%; - border-collapse: collapse; - font-size: 13px; - table-layout: fixed; -} - -.data-table thead { - position: sticky; - top: 0; - z-index: 1; -} - -.data-table th { - background: var(--surface-2); - text-align: left; - padding: 5px 12px; - font-size: 11px; - font-weight: 600; - color: var(--text-3); - text-transform: uppercase; - letter-spacing: 0.3px; - border-bottom: 1px solid var(--border); - white-space: nowrap; -} - -.data-table td { - padding: 4px 12px; - border-bottom: 1px solid var(--border-light); - vertical-align: middle; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.data-table td.point-actions { - overflow: visible; - text-overflow: clip; - white-space: nowrap; -} - -.point-actions { - text-align: right; -} - -.point-actions button + button { - margin-left: 6px; -} - -.data-table tbody tr { - transition: background 0.1s; -} - -.data-table tbody tr:hover { - background: var(--accent-bg); -} - -.data-table tbody tr.active { - background: rgba(37, 99, 235, 0.1); -} - -.point-name { - font-weight: 500; - color: var(--text); - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; -} - -.point-id { - font-size: 11px; - color: var(--text-3); - font-family: "JetBrains Mono", "Consolas", monospace; -} - -.point-value { - font-family: "JetBrains Mono", "Consolas", monospace; - font-weight: 600; - font-size: 13px; - color: var(--text); -} - -.empty-state { - text-align: center; - color: var(--text-3); - padding: 32px 12px !important; - font-size: 13px; -} - -/* ── Pager ────────────────────────────────────────── */ - -.pager { - display: flex; - align-items: center; - gap: 2px; - font-size: 12px; - color: var(--text-3); -} - -.pager button { - width: 22px; - height: 22px; - padding: 0; - font-size: 16px; - line-height: 1; -} - -/* ── Toolbar ──────────────────────────────────────── */ - -.toolbar { - display: flex; - gap: 6px; - flex-shrink: 0; -} - -.point-batch-toolbar { - align-items: center; - justify-content: flex-end; - padding: 8px 12px; - border-bottom: 1px solid var(--border-light); - flex-wrap: wrap; -} - -.compact-check { - margin-right: auto; -} - -.compact-check input { - margin: 0; -} - -.equipment-toolbar { - padding: 8px 12px 0; -} - -.equipment-toolbar input { - flex: 1; - min-width: 0; - padding: 7px 10px; - border: 1px solid var(--border); - background: var(--surface); -} - -.equipment-batch-toolbar { - padding: 8px 12px; - border-bottom: 1px solid var(--border-light); - align-items: center; - flex-wrap: wrap; -} - -.equipment-batch-toolbar .muted { - min-width: 90px; -} - -.equipment-batch-toolbar select { - flex: 1; - min-width: 0; - padding: 7px 10px; - border: 1px solid var(--border); - background: var(--surface); -} - -/* ── Form ─────────────────────────────────────────── */ - -.form { - display: flex; - flex-direction: column; - gap: 12px; -} - -.form label { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 12px; - color: var(--text-2); -} - -.form input[type="text"], -.form input[type="url"], -.form input[type="password"], -.form input[type="number"], -.form input:not([type]), -.form select { - padding: 7px 10px; - border-radius: var(--radius); - border: 1px solid var(--border); - background: var(--surface); - color: var(--text); - font-size: 14px; - transition: border-color 0.15s; -} - -.form input:focus, -.form select:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12); -} - -.check-row { - flex-direction: row !important; - align-items: center; - gap: 6px; -} - -.form-actions { - display: flex; - gap: 8px; - justify-content: flex-end; - padding-top: 4px; -} - -/* ── Tree ─────────────────────────────────────────── */ - -.tree { - overflow-y: auto; - padding: 4px 8px; - flex: 1 1 auto; - min-height: 0; -} - -.tree details { margin-left: 12px; } - -.tree summary { - list-style: none; - cursor: pointer; - display: flex; - align-items: center; - gap: 4px; - padding: 2px 0; - font-size: 13px; -} - -.tree summary.has-children::before { - content: "▸"; - color: var(--text-3); - font-size: 10px; -} - -.tree details[open] > summary.has-children::before { - content: "▾"; -} - -.tree summary::-webkit-details-marker { display: none; } - -.tree .node-label { color: var(--text); } - -/* ── Log ──────────────────────────────────────────── */ - -.log { - background: #0f172a; - padding: 6px 10px; - font-family: "JetBrains Mono", "Consolas", monospace; - font-size: 12px; - line-height: 1.55; - color: #cbd5e1; - overflow-y: auto; - flex: 1 1 auto; - display: flex; - flex-direction: column; - gap: 0; - min-height: 0; -} - -.log-line { - white-space: pre-wrap; - word-break: break-word; - padding: 0 4px; - border-radius: 0; -} - -.log-line:hover { background: rgba(255, 255, 255, 0.04); } - -.log-line .level { - font-weight: 700; - margin-right: 6px; - font-size: 11px; -} - -.log-line .message { margin-left: 6px; } -.log-line .muted { margin-left: 4px; color: #64748b; } - -.log-line.level-trace .level { color: #64748b; } -.log-line.level-debug .level { color: #38bdf8; } -.log-line.level-info .level { color: #34d399; } -.log-line.level-warn .level { color: #fbbf24; } -.log-line.level-error .level { color: #f87171; } - -/* ── Modal ────────────────────────────────────────── */ - -.modal { - position: fixed; - inset: 0; - background: rgba(15, 23, 42, 0.45); - display: flex; - align-items: center; - justify-content: center; - z-index: 50; - backdrop-filter: blur(2px); -} - -.hidden { display: none !important; } -.modal.hidden { display: none; } - -.modal-content { - width: min(860px, 94vw); - max-height: 85vh; - overflow: hidden; - background: var(--surface); - border-radius: 0; - border: 1px solid var(--border); - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px; -} - -.modal-content.modal-sm { - width: min(420px, 94vw); -} - -.modal-head { - display: flex; - justify-content: space-between; - align-items: center; -} - -.modal-head h3 { - font-size: 15px; - text-transform: none; - letter-spacing: 0; - color: var(--text); -} - -.modal-foot { - display: flex; - justify-content: space-between; - align-items: center; -} - -.chart-meta { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; -} - -.chart-panel { - display: flex; - flex: 1 1 auto; - min-height: 0; - flex-direction: column; - gap: 8px; - padding: 8px 10px 10px; -} - -.chart-canvas { - width: 100%; - height: auto; - min-height: 320px; - flex: 1 1 auto; - border: 1px solid var(--border); - background: - linear-gradient(to bottom, rgba(37, 99, 235, 0.03), rgba(37, 99, 235, 0)), - var(--surface); -} - -.modal-content .tree { - flex: 1; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 8px; - background: var(--surface-2); - max-height: 50vh; -} - -.unit-list { - padding-top: 6px; -} - -.unit-card-actions { - padding-top: 4px; -} - -.event-card { - display: flex; - align-items: baseline; - gap: 6px; - padding: 3px 8px; - font-size: 12px; - border-bottom: 1px solid var(--border); - white-space: nowrap; - overflow: hidden; - flex-shrink: 0; -} - -.event-card:hover { - background: var(--surface-hover, var(--surface)); -} - -.event-badge { - flex-shrink: 0; -} - -.badge.level-info { background: rgba(52, 211, 153, 0.1); color: #34d399; } -.badge.level-warn { background: rgba(251, 191, 36, 0.1); color: #fbbf24; } -.badge.level-error { background: rgba(239, 68, 68, 0.1); color: #f87171; } -.badge.level-critical { background: rgba(239, 68, 68, 0.15); color: #dc2626; } - -.event-time { - flex-shrink: 0; - font-size: 11px; -} - -.event-type { - flex-shrink: 0; - font-weight: 600; -} - -.event-message { - color: var(--text-2); - font-size: 11px; - overflow: hidden; - text-overflow: ellipsis; -} - -.equipment-select-row { - display: flex; - align-items: center; - gap: 6px; - cursor: pointer; -} - -.equipment-select-row input { - margin: 0; -} - -.drawer-backdrop { - position: fixed; - inset: 0; - z-index: 60; - background: rgba(15, 23, 42, 0.28); - display: flex; - justify-content: flex-end; -} - -.drawer-backdrop.hidden { - display: none; -} - -.drawer { - width: min(760px, 88vw); - height: 100vh; - background: var(--surface); - border-left: 1px solid var(--border); - display: flex; - flex-direction: column; - box-shadow: -24px 0 48px rgba(15, 23, 42, 0.16); -} - -.drawer-head { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - padding: 12px 14px; - border-bottom: 1px solid var(--border-light); -} - -.drawer-head h3 { - font-size: 15px; - text-transform: none; - letter-spacing: 0; - color: var(--text); -} - -.drawer-body { - flex: 1 1 auto; - min-height: 0; - overflow: hidden; - padding: 0; - display: grid; - grid-template-columns: 220px minmax(0, 1fr); -} - -.api-drawer { - width: min(1100px, 96vw); -} - -.equipment-drawer { - width: min(1120px, 96vw); -} - -.equipment-layout { - grid-template-columns: 320px minmax(0, 1fr); -} - -.equipment-sidebar, -.equipment-content { - min-height: 0; - display: flex; - flex-direction: column; -} - -.equipment-sidebar { - border-right: 1px solid var(--border-light); - background: var(--surface-2); -} - -.equipment-toolbar { - padding: 10px 12px 0; -} - -.equipment-toolbar input { - flex: 1; - min-width: 0; - padding: 7px 10px; - border: 1px solid var(--border); - background: var(--surface); -} - -.equipment-form { - padding: 14px; - border-bottom: 1px solid var(--border-light); -} - -.equipment-points { - display: flex; - flex: 1 1 auto; - min-height: 0; - flex-direction: column; -} - -.equipment-role-hint { - padding: 8px 14px 0; -} - -.compact-table td, -.compact-table th { - padding-top: 6px; - padding-bottom: 6px; -} - -.point-meta { - display: flex; - flex-direction: column; - gap: 2px; -} - -.point-role { - font-size: 11px; - color: var(--text-3); -} - -.inline-select { - width: 100%; - padding: 6px 8px; - border: 1px solid var(--border); - background: var(--surface); -} - -.doc-toc { - border-right: 1px solid var(--border-light); - background: var(--surface-2); - overflow: auto; - padding: 14px 12px 18px; -} - -.doc-toc-title { - font-size: 12px; - font-weight: 700; - color: var(--text-2); - text-transform: uppercase; - letter-spacing: 0.4px; - margin-bottom: 10px; -} - -.doc-toc-list { - display: flex; - flex-direction: column; - gap: 4px; -} - -.doc-toc-item { - display: block; - text-decoration: none; - color: var(--text-2); - font-size: 13px; - line-height: 1.45; - padding: 4px 6px; -} - -.doc-toc-item:hover { - background: var(--surface); -} - -.doc-toc-item.level-1 { padding-left: 6px; font-weight: 700; color: var(--text); } -.doc-toc-item.level-2 { padding-left: 14px; } -.doc-toc-item.level-3 { padding-left: 22px; } -.doc-toc-item.level-4 { padding-left: 30px; } - -.markdown-doc { - overflow: auto; - padding: 18px 20px 24px; -} - -.markdown-doc { - max-width: 920px; - margin: 0 auto; - color: var(--text); -} - -.markdown-doc > * + * { - margin-top: 14px; -} - -.markdown-doc h1, -.markdown-doc h2, -.markdown-doc h3, -.markdown-doc h4 { - color: var(--text); - text-transform: none; - letter-spacing: 0; - font-weight: 700; -} - -.markdown-doc h1 { - font-size: 28px; - padding-bottom: 10px; - border-bottom: 1px solid var(--border); -} - -.markdown-doc h2 { - font-size: 22px; - padding-top: 4px; -} - -.markdown-doc h3 { - font-size: 18px; -} - -.markdown-doc h4 { - font-size: 15px; -} - -.markdown-doc p, -.markdown-doc li { - font-size: 14px; - line-height: 1.75; -} - -.markdown-doc ul, -.markdown-doc ol { - padding-left: 22px; -} - -.markdown-doc li + li { - margin-top: 6px; -} - -.markdown-doc code { - padding: 1px 5px; - background: var(--surface-2); - border: 1px solid var(--border-light); - font-family: "JetBrains Mono", "Consolas", monospace; - font-size: 12px; -} - -.markdown-doc pre { - overflow: auto; - padding: 12px 14px; - border: 1px solid var(--border); - background: #0f172a; - color: #e2e8f0; -} - -.markdown-doc pre code { - padding: 0; - border: none; - background: transparent; - color: inherit; -} - -.markdown-doc hr { - border: none; - border-top: 1px solid var(--border); -} - -.markdown-doc blockquote { - margin: 0; - padding: 8px 12px; - border-left: 3px solid var(--accent); - background: var(--surface-2); - color: var(--text-2); -} - -.doc-page { - height: auto; - min-height: 100vh; - overflow: auto; -} - -.doc-view { - min-height: calc(100vh - var(--topbar-h)); - padding: 20px; - background: var(--bg); -} - -.doc-card { - max-width: 1100px; - margin: 0 auto; - background: var(--surface); - border: 1px solid var(--border); -} - -.doc-card-head { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - padding: 12px 14px; - border-bottom: 1px solid var(--border-light); -} - -.doc-card-head h2 { - text-transform: none; - letter-spacing: 0; - color: var(--text); -} - -.doc-body { - padding: 16px; -} - -.doc-body pre { - margin: 0; - white-space: pre-wrap; - word-break: break-word; - font-family: "JetBrains Mono", "Consolas", monospace; - font-size: 13px; - line-height: 1.7; - color: var(--text); -} - -/* ── Toast ────────────────────────────────────────── */ - -#toast-container { - position: fixed; - bottom: 20px; - right: 20px; - display: flex; - flex-direction: column-reverse; - gap: 8px; - z-index: 9999; - pointer-events: none; -} - -.toast { - display: flex; - align-items: flex-start; - gap: 8px; - min-width: 240px; - max-width: 380px; - padding: 10px 14px; - background: var(--surface); - border: 1px solid var(--border); - border-left: 3px solid var(--text-3); - box-shadow: 0 4px 12px rgba(0,0,0,0.1); - font-size: 13px; - color: var(--text); - pointer-events: auto; - animation: toast-in 0.15s ease; - cursor: pointer; -} - -.toast.error { border-left-color: var(--danger); } -.toast.warning { border-left-color: var(--warning); } -.toast.success { border-left-color: var(--success); } - -.toast-icon { - flex-shrink: 0; - font-size: 14px; - line-height: 1.5; -} - -.toast-body { flex: 1; word-break: break-word; } -.toast-title { font-weight: 600; margin-bottom: 2px; } -.toast-title:only-child { margin-bottom: 0; } -.toast-message { color: var(--text-2); font-size: 12px; } - -.toast.hiding { animation: toast-out 0.15s ease forwards; } -.toast.shake { animation: toast-shake 0.4s ease; } - -@keyframes toast-in { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } -} -@keyframes toast-out { - from { opacity: 1; transform: translateY(0); } - to { opacity: 0; transform: translateY(8px); } -} -@keyframes toast-shake { - 0%, 100% { transform: translateX(0); } - 20% { transform: translateX(-5px); } - 40% { transform: translateX(5px); } - 60% { transform: translateX(-4px); } - 80% { transform: translateX(4px); } -} - -/* ── Scrollbar ────────────────────────────────────── */ - -::-webkit-scrollbar { width: 5px; height: 5px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 0; } -::-webkit-scrollbar-thumb:hover { background: var(--text-3); } - -/* ── Responsive ───────────────────────────────────── */ - -@media (max-width: 900px) { - .grid-config, - .grid-ops { - grid-template-columns: 1fr; - grid-template-rows: auto auto auto auto; - height: auto; - } - .grid-app-config { - grid-template-columns: 1fr; - grid-template-rows: auto; - height: auto; - } - body { height: auto; overflow: auto; } - .panel.top-left { min-height: 200px; } - .panel.top-right { min-height: 300px; } - .grid-config .panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; } - .grid-config .panel.bottom-mid { grid-column: 1; grid-row: 4; min-height: 200px; } - .grid-config .panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; } - .drawer { width: 100vw; } - .drawer-body { grid-template-columns: 1fr; } - .equipment-layout { grid-template-columns: 1fr; } - .equipment-sidebar { border-right: none; border-bottom: 1px solid var(--border-light); } - .doc-toc { border-right: none; border-bottom: 1px solid var(--border-light); max-height: 180px; } - .doc-view { padding: 0; } - .doc-card { border-left: none; border-right: none; } -} - - diff --git a/web/feeder/html/modals.html b/web/feeder/html/modals.html deleted file mode 100644 index 3f058c4..0000000 --- a/web/feeder/html/modals.html +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - - - - diff --git a/web/feeder/html/ops-panel.html b/web/feeder/html/ops-panel.html deleted file mode 100644 index 9385f09..0000000 --- a/web/feeder/html/ops-panel.html +++ /dev/null @@ -1,17 +0,0 @@ -
-
- -
-
← 选择控制单元
-
-
-
diff --git a/web/feeder/html/topbar.html b/web/feeder/html/topbar.html deleted file mode 100644 index b36defc..0000000 --- a/web/feeder/html/topbar.html +++ /dev/null @@ -1,16 +0,0 @@ -
-
投煤器布料机控制系统
-
- - - -
-
- - -
- - 连接中… -
-
-
diff --git a/web/feeder/html/unit-modal.html b/web/feeder/html/unit-modal.html deleted file mode 100644 index 674b8df..0000000 --- a/web/feeder/html/unit-modal.html +++ /dev/null @@ -1,51 +0,0 @@ - diff --git a/web/feeder/html/unit-panel.html b/web/feeder/html/unit-panel.html deleted file mode 100644 index 7b64ca9..0000000 --- a/web/feeder/html/unit-panel.html +++ /dev/null @@ -1,10 +0,0 @@ -
-
-

控制单元配置

-
- - -
-
-
-
diff --git a/web/feeder/index.html b/web/feeder/index.html deleted file mode 100644 index 42c19ec..0000000 --- a/web/feeder/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - PLC Control - - - -
- -
-
-
-
-
-
-
-
-
-
- -
-
- - -
- - - - diff --git a/web/feeder/js/api.js b/web/feeder/js/api.js deleted file mode 100644 index a946588..0000000 --- a/web/feeder/js/api.js +++ /dev/null @@ -1,87 +0,0 @@ -import { dom } from "./dom.js"; - -export function setStatus(text) { - dom.statusText.textContent = text; -} - -// ── Toast ───────────────────────────────────────── - -function getContainer() { - let el = document.getElementById("toast-container"); - if (!el) { - el = document.createElement("div"); - el.id = "toast-container"; - document.body.appendChild(el); - } - return el; -} - -const ICONS = { error: "✕", warning: "!", success: "✓", info: "i" }; - -/** - * 显示 toast 通知。 - * @param {string} title 主要文字 - * @param {object} [opts] - * @param {string} [opts.message] 次要说明文字 - * @param {"error"|"warning"|"success"|"info"} [opts.level="error"] - * @param {number} [opts.duration=4000] 自动关闭毫秒数,0 表示不自动关闭 - * @param {boolean} [opts.shake=false] 出现时加抖动动画 - * @returns {{ dismiss: () => void }} - */ -export function showToast(title, { message, level = "error", duration = 4000, shake = false } = {}) { - const container = getContainer(); - - const el = document.createElement("div"); - el.className = `toast ${level}${shake ? " shake" : ""}`; - el.innerHTML = ` - ${ICONS[level] ?? "i"} -
-
${title}
- ${message ? `
${message}
` : ""} -
`; - - const dismiss = () => { - if (!el.parentNode) return; - el.classList.remove("shake"); - el.classList.add("hiding"); - el.addEventListener("animationend", () => el.remove(), { once: true }); - }; - - el.addEventListener("click", dismiss); - container.appendChild(el); - - if (duration > 0) setTimeout(dismiss, duration); - return { dismiss }; -} - -// ── apiFetch ────────────────────────────────────── - -export async function apiFetch(url, options = {}) { - const response = await fetch(url, { - headers: { "Content-Type": "application/json" }, - ...options, - }); - - if (!response.ok) { - const text = (await response.text()) || response.statusText; - showToast(`请求失败 ${response.status}`, { message: text }); - throw new Error(text); - } - - if (response.status === 204) { - return null; - } - - const contentType = response.headers.get("content-type") || ""; - if (contentType.includes("application/json")) { - return response.json(); - } - - return response.text(); -} - -export function withStatus(task) { - return task.catch((error) => { - setStatus(error.message || "请求失败"); - }); -} diff --git a/web/feeder/js/app.js b/web/feeder/js/app.js deleted file mode 100644 index fb6d762..0000000 --- a/web/feeder/js/app.js +++ /dev/null @@ -1,210 +0,0 @@ -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 { - clearPointBinding, - closeEquipmentModal, - loadEquipments, - openCreateEquipmentModal, - resetEquipmentForm, - saveEquipment, -} from "./equipment.js"; -import { startPointSocket, startLogs, stopLogs } from "./logs.js"; -import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.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"; - -let _configLoaded = false; -let _appConfigLoaded = false; - -function switchView(view) { - state.activeView = view; - const main = document.querySelector("main"); - main.className = - view === "ops" ? "grid-ops" : - view === "app-config" ? "grid-app-config" : - "grid-config"; - - dom.tabOps.classList.toggle("active", view === "ops"); - dom.tabAppConfig.classList.toggle("active", view === "app-config"); - dom.tabConfig.classList.toggle("active", view === "config"); - - // config-only panels (platform config view) - ["top-left", "top-right", "bottom-left", "bottom-right"].forEach((cls) => { - const el = main.querySelector(`.panel.${cls}`); - if (el) el.classList.toggle("hidden", view !== "config"); - }); - const logStreamPanel = main.querySelector(".panel.bottom-mid"); - if (logStreamPanel) logStreamPanel.classList.toggle("hidden", view !== "config"); - - // ops-only panels - const opsMain = main.querySelector(".panel.ops-main"); - const opsBottom = main.querySelector(".panel.ops-bottom"); - if (opsMain) opsMain.classList.toggle("hidden", view !== "ops"); - if (opsBottom) opsBottom.classList.toggle("hidden", view !== "ops"); - - // app-config-only panels - const appConfigMain = main.querySelector(".panel.app-config-main"); - if (appConfigMain) appConfigMain.classList.toggle("hidden", view !== "app-config"); - - if (view === "config") { - startLogs(); - if (!_configLoaded) { - _configLoaded = true; - withStatus((async () => { - await Promise.all([loadSources(), loadEquipments(), loadEvents()]); - await loadPoints(); - })()); - } - } else { - stopLogs(); - } - - if (view === "app-config") { - if (!_appConfigLoaded) { - _appConfigLoaded = true; - withStatus(Promise.all([loadUnits(), loadEquipments()])); - } - } -} - -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))); - - 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")); - dom.tabConfig.addEventListener("click", () => switchView("config")); - - dom.refreshUnitBtn2.addEventListener("click", () => withStatus(loadUnits().then(loadEvents))); - dom.newUnitBtn2.addEventListener("click", openCreateUnitModal); - bindUnitEquipmentModalEvents(); - - document.addEventListener("equipments-updated", () => { - renderUnits(); - // Re-fetch units so embedded equipment data stays in sync with config changes. - loadUnits().catch(() => {}); - }); - - document.addEventListener("units-loaded", () => { - renderOpsUnits(); - if (!state.selectedOpsUnitId) loadAllEquipmentCards(); - }); -} - -async function bootstrap() { - bindEvents(); - switchView("ops"); - renderSelectedNodes(); - updateSelectedPointSummary(); - updatePointFilterSummary(); - renderChart(); - startPointSocket(); - - await withStatus(Promise.all([loadUnits(), loadEvents()])); - startOps(); -} - -bootstrap(); diff --git a/web/feeder/js/chart.js b/web/feeder/js/chart.js deleted file mode 100644 index c19f8fe..0000000 --- a/web/feeder/js/chart.js +++ /dev/null @@ -1,183 +0,0 @@ -import { apiFetch } from "./api.js"; -import { dom } from "./dom.js"; -import { state } from "./state.js"; - -function normalizeChartItem(item) { - let valueNumber = null; - if (typeof item?.value_number === "number" && Number.isFinite(item.value_number)) { - valueNumber = item.value_number; - } else if (typeof item?.value === "number" && Number.isFinite(item.value)) { - valueNumber = item.value; - } else if (typeof item?.value === "boolean") { - valueNumber = item.value ? 1 : 0; - } else if (typeof item?.value?.float === "number" && Number.isFinite(item.value.float)) { - valueNumber = item.value.float; - } else if (typeof item?.value?.int === "number" && Number.isFinite(item.value.int)) { - valueNumber = item.value.int; - } else if (typeof item?.value?.uint === "number" && Number.isFinite(item.value.uint)) { - valueNumber = item.value.uint; - } else if (typeof item?.value?.bool === "boolean") { - valueNumber = item.value.bool ? 1 : 0; - } else if (typeof item?.value_text === "string") { - const parsed = Number(item.value_text); - if (Number.isFinite(parsed)) { - valueNumber = parsed; - } - } - - return { - timestamp: item?.timestamp || "", - valueNumber, - valueText: item?.value_text || (valueNumber === null ? "" : String(valueNumber)), - }; -} - -function formatAxisValue(value) { - if (!Number.isFinite(value)) { - return "--"; - } - if (Math.abs(value) >= 1000 || Math.abs(value) < 0.01) { - return value.toExponential(2); - } - return value.toFixed(2); -} - -function formatTimeLabel(timestamp) { - if (!timestamp) { - return "--"; - } - const match = String(timestamp).match(/(\d{2}:\d{2}:\d{2})/); - return match ? match[1] : String(timestamp); -} - -export async function openChart(pointId, pointName) { - state.chartPointId = pointId; - state.chartPointName = pointName || "点位"; - dom.chartTitle.textContent = `${state.chartPointName} 趋势图`; - - const items = await apiFetch(`/api/point/${pointId}/history?limit=120`); - state.chartData = (items || []) - .map(normalizeChartItem) - .filter((item) => item.valueNumber !== null); - - renderChart(); -} - -export function appendChartPoint(item) { - if (!state.chartPointId) { - return; - } - - const normalized = normalizeChartItem(item); - if (normalized.valueNumber === null) { - return; - } - - const last = state.chartData[state.chartData.length - 1]; - if ( - last && - last.timestamp === normalized.timestamp && - last.valueText === normalized.valueText && - last.valueNumber === normalized.valueNumber - ) { - return; - } - - state.chartData.push(normalized); - if (state.chartData.length > 120) { - state.chartData = state.chartData.slice(-120); - } - renderChart(); -} - -export function renderChart() { - const ctx = dom.chartCanvas.getContext("2d"); - const width = dom.chartCanvas.width; - const height = dom.chartCanvas.height; - ctx.clearRect(0, 0, width, height); - - if (!state.chartData.length) { - ctx.fillStyle = "#94a3b8"; - ctx.font = "14px Segoe UI"; - ctx.fillText("点击点位行查看图表", 24, 40); - dom.chartSummary.textContent = "点击点位行查看图表"; - return; - } - - const values = state.chartData.map((item) => item.valueNumber); - let min = Math.min(...values); - let max = Math.max(...values); - if (min === max) { - min -= 1; - max += 1; - } - - const padding = { top: 20, right: 20, bottom: 42, left: 64 }; - const plotWidth = width - padding.left - padding.right; - const plotHeight = height - padding.top - padding.bottom; - - ctx.strokeStyle = "#cbd5e1"; - ctx.lineWidth = 1; - - for (let i = 0; i <= 4; i += 1) { - const y = padding.top + (plotHeight / 4) * i; - ctx.beginPath(); - ctx.moveTo(padding.left, y); - ctx.lineTo(width - padding.right, y); - ctx.stroke(); - } - - ctx.beginPath(); - ctx.moveTo(padding.left, padding.top); - ctx.lineTo(padding.left, height - padding.bottom); - ctx.lineTo(width - padding.right, height - padding.bottom); - ctx.strokeStyle = "#94a3b8"; - ctx.stroke(); - - ctx.fillStyle = "#64748b"; - ctx.font = "12px Segoe UI"; - for (let i = 0; i <= 4; i += 1) { - const value = max - ((max - min) / 4) * i; - const y = padding.top + (plotHeight / 4) * i; - ctx.fillText(formatAxisValue(value), 8, y + 4); - } - - const firstLabel = formatTimeLabel(state.chartData[0]?.timestamp); - const middleLabel = formatTimeLabel( - state.chartData[Math.floor((state.chartData.length - 1) / 2)]?.timestamp, - ); - const lastLabel = formatTimeLabel(state.chartData[state.chartData.length - 1]?.timestamp); - - ctx.fillText(firstLabel, padding.left, height - 12); - const middleWidth = ctx.measureText(middleLabel).width; - ctx.fillText(middleLabel, padding.left + plotWidth / 2 - middleWidth / 2, height - 12); - const lastWidth = ctx.measureText(lastLabel).width; - ctx.fillText(lastLabel, width - padding.right - lastWidth, height - 12); - - ctx.save(); - ctx.translate(16, padding.top + plotHeight / 2); - ctx.rotate(-Math.PI / 2); - ctx.fillStyle = "#64748b"; - ctx.fillText("数值", 0, 0); - ctx.restore(); - ctx.fillText("时间", width / 2 - 12, height - 28); - - ctx.strokeStyle = "#2563eb"; - ctx.lineWidth = 2; - ctx.beginPath(); - - state.chartData.forEach((item, index) => { - const x = padding.left + (plotWidth * index) / Math.max(1, state.chartData.length - 1); - const y = padding.top + ((max - item.valueNumber) / (max - min)) * plotHeight; - if (index === 0) { - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); - } - }); - - ctx.stroke(); - - const latest = state.chartData[state.chartData.length - 1]; - dom.chartSummary.textContent = `Latest ${state.chartData.length} points, current value ${latest.valueText || latest.valueNumber}`; -} diff --git a/web/feeder/js/docs.js b/web/feeder/js/docs.js deleted file mode 100644 index a0a4a80..0000000 --- a/web/feeder/js/docs.js +++ /dev/null @@ -1,137 +0,0 @@ -import { apiFetch } from "./api.js"; -import { dom } from "./dom.js"; -import { state } from "./state.js"; - -function escapeHtml(text) { - return text - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">"); -} - -function slugify(text) { - return text - .toLowerCase() - .trim() - .replace(/[^\w\u4e00-\u9fa5]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - -function parseMarkdown(text) { - const lines = text.split(/\r?\n/); - const blocks = []; - const headings = []; - let inCode = false; - let codeBuffer = []; - let paragraph = []; - - const flushParagraph = () => { - if (!paragraph.length) { - return; - } - blocks.push(`

${escapeHtml(paragraph.join(" "))}

`); - paragraph = []; - }; - - const flushCode = () => { - if (!codeBuffer.length) { - return; - } - blocks.push(`
${escapeHtml(codeBuffer.join("\n"))}
`); - codeBuffer = []; - }; - - lines.forEach((line) => { - if (line.startsWith("```")) { - if (inCode) { - flushCode(); - } else { - flushParagraph(); - } - inCode = !inCode; - return; - } - - if (inCode) { - codeBuffer.push(line); - return; - } - - const heading = line.match(/^(#{1,4})\s+(.*)$/); - if (heading) { - flushParagraph(); - const level = heading[1].length; - const textValue = heading[2].trim(); - const id = slugify(textValue); - headings.push({ level, text: textValue, id }); - blocks.push(`${escapeHtml(textValue)}`); - return; - } - - if (!line.trim()) { - flushParagraph(); - return; - } - - paragraph.push(line.trim()); - }); - - flushParagraph(); - flushCode(); - - return { html: blocks.join(""), headings }; -} - -async function loadDoc(url, emptyMessage) { - const text = await apiFetch(url); - const { html, headings } = parseMarkdown(text || ""); - - dom.apiDocContent.innerHTML = html || `

${emptyMessage}

`; - dom.apiDocToc.innerHTML = headings.length - ? headings - .map( - (item) => - `${escapeHtml(item.text)}`, - ) - .join("") - : "
未解析到标题
"; - - dom.apiDocToc.querySelectorAll("a").forEach((link) => { - link.addEventListener("click", (event) => { - event.preventDefault(); - const id = link.getAttribute("href")?.slice(1); - if (!id) { - return; - } - const target = dom.apiDocContent.querySelector(`#${CSS.escape(id)}`); - if (target) { - const offset = target.getBoundingClientRect().top - dom.apiDocContent.getBoundingClientRect().top; - dom.apiDocContent.scrollBy({ top: offset, behavior: "smooth" }); - } - }); - }); -} - -export async function openApiDocDrawer() { - const title = dom.apiDocDrawer.querySelector("h3"); - if (title) title.textContent = "API.md"; - dom.apiDocDrawer.classList.remove("hidden"); - if (state.docDrawerSource !== "api") { - state.docDrawerSource = "api"; - await loadDoc("/api/docs/api-md", "API.md 为空"); - } -} - -export async function openReadmeDrawer() { - const title = dom.apiDocDrawer.querySelector("h3"); - if (title) title.textContent = "README.md"; - dom.apiDocDrawer.classList.remove("hidden"); - if (state.docDrawerSource !== "readme") { - state.docDrawerSource = "readme"; - await loadDoc("/api/docs/readme-md", "README.md 为空"); - } -} - -export function closeApiDocDrawer() { - dom.apiDocDrawer.classList.add("hidden"); -} diff --git a/web/feeder/js/dom.js b/web/feeder/js/dom.js deleted file mode 100644 index a110310..0000000 --- a/web/feeder/js/dom.js +++ /dev/null @@ -1,110 +0,0 @@ -const byId = (id) => document.getElementById(id); - -export const dom = { - statusText: byId("statusText"), - wsDot: byId("wsDot"), - wsLabel: byId("wsLabel"), - batchStartAutoBtn: byId("batchStartAutoBtn"), - batchStopAutoBtn: byId("batchStopAutoBtn"), - tabOps: byId("tabOps"), - tabAppConfig: byId("tabAppConfig"), - tabConfig: byId("tabConfig"), - opsUnitList: byId("opsUnitList"), - opsEquipmentArea: byId("opsEquipmentArea"), - logView: byId("logView"), - sourceList: byId("sourceList"), - unitList: byId("unitList"), - eventList: byId("eventList"), - nodeTree: byId("nodeTree"), - pointList: byId("pointList"), - pointsPageInfo: byId("pointsPageInfo"), - selectedCount: byId("selectedCount"), - selectedPointCount: byId("selectedPointCount"), - pointFilterSummary: byId("pointFilterSummary"), - pointSourceSelect: byId("pointSourceSelect"), - pointSourceNodeCount: byId("pointSourceNodeCount"), - openPointModalBtn: byId("openPointModal"), - chartCanvas: byId("chartCanvas"), - chartTitle: byId("chartTitle"), - chartSummary: byId("chartSummary"), - pointModal: byId("pointModal"), - unitModal: byId("unitModal"), - sourceModal: byId("sourceModal"), - equipmentModal: byId("equipmentModal"), - pointBindingModal: byId("pointBindingModal"), - batchBindingModal: byId("batchBindingModal"), - apiDocDrawer: byId("apiDocDrawer"), - unitForm: byId("unitForm"), - unitId: byId("unitId"), - unitCode: byId("unitCode"), - unitName: byId("unitName"), - unitDescription: byId("unitDescription"), - unitEnabled: byId("unitEnabled"), - unitRunTimeSec: byId("unitRunTimeSec"), - unitStopTimeSec: byId("unitStopTimeSec"), - unitAccTimeSec: byId("unitAccTimeSec"), - unitBlTimeSec: byId("unitBlTimeSec"), - unitManualAck: byId("unitManualAck"), - unitResetBtn: byId("unitReset"), - sourceForm: byId("sourceForm"), - sourceId: byId("sourceId"), - sourceName: byId("sourceName"), - sourceEndpoint: byId("sourceEndpoint"), - sourceEnabled: byId("sourceEnabled"), - sourceResetBtn: byId("sourceReset"), - equipmentForm: byId("equipmentForm"), - equipmentId: byId("equipmentId"), - equipmentUnitId: byId("equipmentUnitId"), - equipmentCode: byId("equipmentCode"), - equipmentName: byId("equipmentName"), - equipmentKind: byId("equipmentKind"), - equipmentDescription: byId("equipmentDescription"), - equipmentResetBtn: byId("equipmentReset"), - equipmentKeyword: byId("equipmentKeyword"), - equipmentList: byId("equipmentList"), - refreshUnitBtn: byId("refreshUnitBtn"), - newUnitBtn: byId("newUnitBtn"), - refreshUnitBtn2: byId("refreshUnitBtn2"), - newUnitBtn2: byId("newUnitBtn2"), - unitConfigList: byId("unitConfigList"), - unitEquipmentModal: byId("unitEquipmentModal"), - unitEquipmentList: byId("unitEquipmentList"), - closeUnitEquipmentModalBtn: byId("closeUnitEquipmentModal"), - cancelUnitEquipmentBtn: byId("cancelUnitEquipment"), - confirmUnitEquipmentBtn: byId("confirmUnitEquipment"), - closeUnitModalBtn: byId("closeUnitModal"), - closeEquipmentModalBtn: byId("closeEquipmentModal"), - refreshEventBtn: byId("refreshEventBtn"), - pointBindingForm: byId("pointBindingForm"), - bindingPointId: byId("bindingPointId"), - bindingPointName: byId("bindingPointName"), - bindingEquipmentId: byId("bindingEquipmentId"), - bindingSignalRole: byId("bindingSignalRole"), - batchBindingForm: byId("batchBindingForm"), - batchBindingSummary: byId("batchBindingSummary"), - batchBindingEquipmentId: byId("batchBindingEquipmentId"), - batchBindingSignalRole: byId("batchBindingSignalRole"), - apiDocToc: byId("apiDocToc"), - apiDocContent: byId("apiDocContent"), - openReadmeDocBtn: byId("openReadmeDoc"), - openApiDocBtn: byId("openApiDoc"), - closeApiDocBtn: byId("closeApiDoc"), - refreshChartBtn: byId("refreshChart"), - prevPointsBtn: byId("prevPoints"), - nextPointsBtn: byId("nextPoints"), - refreshEquipmentBtn: byId("refreshEquipmentBtn"), - newEquipmentBtn: byId("newEquipmentBtn"), - browseNodesBtn: byId("browseNodes"), - refreshTreeBtn: byId("refreshTree"), - createPointsBtn: byId("createPoints"), - closeModalBtn: byId("closeModal"), - openSourceFormBtn: byId("openSourceForm"), - closeSourceModalBtn: byId("closeSourceModal"), - clearPointBindingBtn: byId("clearPointBinding"), - closePointBindingModalBtn: byId("closePointBindingModal"), - toggleAllPoints: byId("toggleAllPoints"), - openBatchBindingBtn: byId("openBatchBinding"), - clearSelectedPointsBtn: byId("clearSelectedPoints"), - closeBatchBindingModalBtn: byId("closeBatchBindingModal"), - clearBatchBindingBtn: byId("clearBatchBinding"), -}; diff --git a/web/feeder/js/equipment.js b/web/feeder/js/equipment.js deleted file mode 100644 index 4464868..0000000 --- a/web/feeder/js/equipment.js +++ /dev/null @@ -1,245 +0,0 @@ -import { apiFetch } from "./api.js"; -import { dom } from "./dom.js"; -import { renderEquipmentKindOptions, renderRoleOptions } from "./roles.js"; -import { clearSelectedPoints, loadPoints, updatePointFilterSummary } from "./points.js"; -import { state } from "./state.js"; - -function equipmentOf(item) { - return item && item.equipment ? item.equipment : item; -} - -function currentUnitLabel(unitId) { - if (!unitId) { - return "未绑定单元"; - } - const unit = state.unitMap.get(unitId); - return unit ? `${unit.code} / ${unit.name}` : "未知单元"; -} - -function filteredEquipments() { - if (!state.selectedUnitId) { - return state.equipments; - } - - return state.equipments.filter((item) => { - const equipment = equipmentOf(item); - return equipment.unit_id === state.selectedUnitId; - }); -} - -function renderEquipmentUnitOptions(selected = "", target = dom.equipmentUnitId) { - if (!target) { - return; - } - - const options = ['']; - state.units.forEach((unit) => { - const isSelected = unit.id === selected ? "selected" : ""; - options.push(``); - }); - target.innerHTML = options.join(""); -} - -export function renderBindingEquipmentOptions(selected = "", target = dom.bindingEquipmentId) { - const options = ['']; - filteredEquipments().forEach((item) => { - const equipment = equipmentOf(item); - const isSelected = equipment.id === selected ? "selected" : ""; - options.push( - ``, - ); - }); - target.innerHTML = options.join(""); -} - -export function renderBatchBindingDefaults() { - renderBindingEquipmentOptions("", dom.batchBindingEquipmentId); - dom.batchBindingSignalRole.innerHTML = renderRoleOptions(""); -} - -export function resetEquipmentForm() { - dom.equipmentForm.reset(); - dom.equipmentId.value = ""; - renderEquipmentUnitOptions(""); - dom.equipmentKind.innerHTML = renderEquipmentKindOptions(""); -} - -function openEquipmentModal() { - dom.equipmentModal.classList.remove("hidden"); -} - -export function closeEquipmentModal() { - dom.equipmentModal.classList.add("hidden"); -} - -export function openCreateEquipmentModal() { - resetEquipmentForm(); - if (state.selectedUnitId && dom.equipmentUnitId) { - dom.equipmentUnitId.value = state.selectedUnitId; - } - openEquipmentModal(); -} - -function openEditEquipmentModal(equipment) { - dom.equipmentId.value = equipment.id || ""; - dom.equipmentUnitId.value = equipment.unit_id || ""; - dom.equipmentCode.value = equipment.code || ""; - dom.equipmentName.value = equipment.name || ""; - dom.equipmentKind.innerHTML = renderEquipmentKindOptions(equipment.kind || ""); - dom.equipmentDescription.value = equipment.description || ""; - openEquipmentModal(); -} - -async function selectEquipment(equipmentId) { - state.selectedEquipmentId = state.selectedEquipmentId === equipmentId ? null : equipmentId; - state.pointsPage = 1; - clearSelectedPoints(); - renderEquipments(); - updatePointFilterSummary(); - await loadPoints(); -} - -export function clearEquipmentFilter() { - state.selectedEquipmentId = null; - state.pointsPage = 1; - renderEquipments(); - updatePointFilterSummary(); - return loadPoints(); -} - -export function renderEquipments() { - dom.equipmentList.innerHTML = ""; - - const items = filteredEquipments(); - if (!items.length) { - dom.equipmentList.innerHTML = '
暂无设备
'; - return; - } - - items.forEach((item) => { - const equipment = equipmentOf(item); - const box = document.createElement("div"); - box.className = `list-item equipment-card ${state.selectedEquipmentId === equipment.id ? "selected" : ""}`; - box.innerHTML = ` -
- ${equipment.code} - ${item.point_count ?? 0} pts -
-
${equipment.name}
-
${equipment.kind || "未分类"}
-
单元: ${currentUnitLabel(equipment.unit_id)}
-
- `; - - box.addEventListener("click", () => { - selectEquipment(equipment.id).catch((error) => { - dom.statusText.textContent = error.message; - }); - }); - - const actionRow = box.querySelector(".equipment-card-actions"); - - const editBtn = document.createElement("button"); - editBtn.className = "secondary"; - editBtn.textContent = "编辑"; - editBtn.addEventListener("click", (event) => { - event.stopPropagation(); - openEditEquipmentModal(equipment); - }); - - const deleteBtn = document.createElement("button"); - deleteBtn.className = "danger"; - deleteBtn.textContent = "删除"; - deleteBtn.addEventListener("click", (event) => { - event.stopPropagation(); - deleteEquipment(equipment.id).catch((error) => { - dom.statusText.textContent = error.message; - }); - }); - - actionRow.append(editBtn, deleteBtn); - - dom.equipmentList.appendChild(box); - }); -} - -export async function loadEquipments() { - const keyword = dom.equipmentKeyword.value.trim(); - const query = keyword - ? `?page=1&page_size=-1&keyword=${encodeURIComponent(keyword)}` - : "?page=1&page_size=-1"; - const data = await apiFetch(`/api/equipment${query}`); - state.equipments = data.data || []; - state.equipmentMap = new Map( - state.equipments.map((item) => { - const equipment = equipmentOf(item); - return [equipment.id, equipment]; - }), - ); - - renderEquipmentUnitOptions(dom.equipmentUnitId?.value || ""); - dom.equipmentKind.innerHTML = renderEquipmentKindOptions(dom.equipmentKind?.value || ""); - renderBindingEquipmentOptions(); - renderBatchBindingDefaults(); - if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) { - state.selectedEquipmentId = null; - } - renderEquipments(); - updatePointFilterSummary(); - document.dispatchEvent(new Event("equipments-updated")); -} - -export async function saveEquipment(event) { - event.preventDefault(); - - const unitId = dom.equipmentUnitId.value || null; - const payload = { - unit_id: unitId, - code: dom.equipmentCode.value.trim(), - name: dom.equipmentName.value.trim(), - kind: dom.equipmentKind.value.trim() || null, - description: dom.equipmentDescription.value.trim() || null, - }; - - const id = dom.equipmentId.value; - const result = await apiFetch(id ? `/api/equipment/${id}` : "/api/equipment", { - method: id ? "PUT" : "POST", - body: JSON.stringify(payload), - }); - - closeEquipmentModal(); - await loadEquipments(); - if (!id && result?.id) { - state.selectedEquipmentId = result.id; - } - renderEquipments(); - updatePointFilterSummary(); - await loadPoints(); -} - -export async function deleteEquipment(equipmentId) { - if (!window.confirm("确认删除该设备?")) { - return; - } - - await apiFetch(`/api/equipment/${equipmentId}`, { method: "DELETE" }); - if (state.selectedEquipmentId === equipmentId) { - state.selectedEquipmentId = null; - } - resetEquipmentForm(); - closeEquipmentModal(); - clearSelectedPoints(); - await loadEquipments(); - await loadPoints(); -} - -export async function clearPointBinding(pointId = dom.bindingPointId.value) { - await apiFetch(`/api/point/${pointId}`, { - method: "PUT", - body: JSON.stringify({ equipment_id: null, signal_role: null }), - }); - - dom.pointBindingModal.classList.add("hidden"); - await loadEquipments(); - await loadPoints(); -} diff --git a/web/feeder/js/events.js b/web/feeder/js/events.js deleted file mode 100644 index 94863de..0000000 --- a/web/feeder/js/events.js +++ /dev/null @@ -1,86 +0,0 @@ -import { apiFetch } from "./api.js"; -import { dom } from "./dom.js"; -import { state } from "./state.js"; - -const PAGE_SIZE = 10; - -let _page = 1; -let _hasMore = false; -let _loading = false; - -function formatTime(value) { - return value || "--"; -} - -function makeCard(item) { - const row = document.createElement("div"); - const level = (item.level || "info").toLowerCase(); - row.className = "event-card"; - row.innerHTML = `${level.toUpperCase()}${formatTime(item.created_at)}${item.event_type}${item.message}`; - return row; -} - -async function loadMore() { - if (_loading || !_hasMore) return; - _loading = true; - - const params = new URLSearchParams({ page: String(_page), page_size: String(PAGE_SIZE) }); - if (state.selectedUnitId) params.set("unit_id", state.selectedUnitId); - - try { - const response = await apiFetch(`/api/event?${params.toString()}`); - const items = response.data || []; - items.forEach((item) => dom.eventList.appendChild(makeCard(item))); - _hasMore = items.length === PAGE_SIZE; - _page += 1; - } finally { - _loading = false; - } -} - -export async function loadEvents() { - _page = 1; - _hasMore = false; - _loading = false; - dom.eventList.innerHTML = ""; - - const params = new URLSearchParams({ page: "1", page_size: String(PAGE_SIZE) }); - if (state.selectedUnitId) params.set("unit_id", state.selectedUnitId); - - _loading = true; - try { - const response = await apiFetch(`/api/event?${params.toString()}`); - const items = response.data || []; - - if (!items.length) { - dom.eventList.innerHTML = '
暂无事件
'; - return; - } - - items.forEach((item) => dom.eventList.appendChild(makeCard(item))); - _hasMore = items.length === PAGE_SIZE; - _page = 2; - } finally { - _loading = false; - } -} - -export function prependEvent(item) { - if (state.selectedUnitId && item.unit_id !== state.selectedUnitId) return; - - const placeholder = dom.eventList.querySelector(".list-item"); - if (placeholder) placeholder.remove(); - - dom.eventList.insertBefore(makeCard(item), dom.eventList.firstChild); - - // Keep DOM bounded to prevent unbounded growth - const cards = dom.eventList.querySelectorAll(".event-card"); - if (cards.length > 100) cards[cards.length - 1].remove(); -} - -dom.eventList.addEventListener("scroll", () => { - const el = dom.eventList; - if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) { - loadMore(); - } -}); diff --git a/web/feeder/js/index.js b/web/feeder/js/index.js deleted file mode 100644 index 727497c..0000000 --- a/web/feeder/js/index.js +++ /dev/null @@ -1,20 +0,0 @@ -async function loadPartial(slot) { - const response = await fetch(slot.dataset.partial); - if (!response.ok) { - throw new Error(`Failed to load partial: ${slot.dataset.partial}`); - } - - const html = await response.text(); - slot.insertAdjacentHTML("beforebegin", html); - slot.remove(); -} - -async function bootstrapPage() { - const slots = Array.from(document.querySelectorAll("[data-partial]")); - await Promise.all(slots.map((slot) => loadPartial(slot))); - await import("./app.js"); -} - -bootstrapPage().catch((error) => { - document.body.innerHTML = `
${error.message || String(error)}
`; -}); diff --git a/web/feeder/js/logs.js b/web/feeder/js/logs.js deleted file mode 100644 index 2afd3cd..0000000 --- a/web/feeder/js/logs.js +++ /dev/null @@ -1,176 +0,0 @@ -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 { loadUnits, renderUnits } from "./units.js"; -import { loadEquipments } from "./equipment.js"; -import { showToast } from "./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; - } -} - -let _disconnectToast = null; - -function setWsStatus(connected) { - if (dom.wsDot) { - dom.wsDot.className = `ws-dot ${connected ? "connected" : "disconnected"}`; - } - if (dom.wsLabel) { - dom.wsLabel.textContent = connected ? "已连接" : "连接断开,重连中…"; - } - if (!connected && !_disconnectToast) { - _disconnectToast = showToast("后端连接断开", { - message: "正在重连,请稍候…", - level: "error", - duration: 0, - shake: true, - }); - } else if (connected && _disconnectToast) { - _disconnectToast.dismiss(); - _disconnectToast = null; - showToast("连接已恢复", { level: "success", duration: 3000 }); - } -} - -let _reconnectDelay = 1000; -let _connectedOnce = false; - -export function startPointSocket() { - const protocol = location.protocol === "https:" ? "wss" : "ws"; - const ws = new WebSocket(`${protocol}://${location.host}/ws/public`); - state.pointSocket = ws; - - ws.onopen = () => { - setWsStatus(true); - _reconnectDelay = 1000; - if (_connectedOnce) { - loadUnits().catch(() => {}); - if (state.activeView === "config") loadEquipments().catch(() => {}); - } - _connectedOnce = true; - }; - - ws.onmessage = (event) => { - try { - const payload = JSON.parse(event.data); - if (payload.type === "PointNewValue" || payload.type === "point_new_value") { - const data = payload.data; - - // config view point table - const entry = state.pointEls.get(data.point_id); - if (entry) { - entry.value.textContent = formatValue(data); - entry.quality.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`; - entry.quality.textContent = (data.quality || "unknown").toUpperCase(); - entry.time.textContent = data.timestamp || "--"; - } - - // ops view signal pill - const opsEntry = state.opsPointEls.get(data.point_id); - if (opsEntry) { - const { pillEl, syncBtns } = opsEntry; - state.opsSignalCache.set(data.point_id, { quality: data.quality, value_text: data.value_text }); - const role = pillEl.dataset.opsRole; - import("./ops.js").then(({ sigPillClass }) => { - pillEl.className = sigPillClass(role, data.quality, data.value_text); - syncBtns?.(); - }); - } - - if (state.chartPointId === data.point_id) { - appendChartPoint(data); - } - return; - } - - if (payload.type === "EventCreated" || payload.type === "event_created") { - prependEvent(payload.data); - } - - if (payload.type === "UnitRuntimeChanged") { - const runtime = payload.data; - state.runtimes.set(runtime.unit_id, runtime); - renderUnits(); - // lazy import to avoid circular dep (ops.js -> logs.js -> ops.js) - import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => { - renderOpsUnits(); - syncEquipmentButtonsForUnit(runtime.unit_id); - }); - return; - } - } catch { - // ignore malformed messages - } - }; - - ws.onclose = () => { - setWsStatus(false); - window.setTimeout(startPointSocket, _reconnectDelay); - _reconnectDelay = Math.min(_reconnectDelay * 2, 30000); - }; - - ws.onerror = () => setWsStatus(false); -} diff --git a/web/feeder/js/ops.js b/web/feeder/js/ops.js deleted file mode 100644 index ec468d2..0000000 --- a/web/feeder/js/ops.js +++ /dev/null @@ -1,232 +0,0 @@ -import { apiFetch } from "./api.js"; -import { dom } from "./dom.js"; -import { state } from "./state.js"; -import { loadUnits } from "./units.js"; - -const SIGNAL_ROLES = ["rem", "run", "flt"]; -const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" }; - -function isSignalOn(quality, valueText) { - if (!quality || quality.toLowerCase() !== "good") return false; - const v = String(valueText ?? "").trim().toLowerCase(); - return v === "1" || v === "true" || v === "on"; -} - -export function sigPillClass(role, quality, valueText) { - if (!quality || quality.toLowerCase() !== "good") return "sig-pill sig-warn"; - const on = isSignalOn(quality, valueText); - if (!on) return "sig-pill"; - return role === "flt" ? "sig-pill sig-fault" : "sig-pill sig-on"; -} - -function runtimeBadge(runtime) { - if (!runtime) return 'OFFLINE'; - if (runtime.comm_locked) return 'COMM ERR'; - if (runtime.fault_locked) return 'FAULT'; - const labels = { stopped: "STOPPED", running: "RUNNING", distributor_running: "DIST RUN", fault_locked: "FAULT", comm_locked: "COMM ERR" }; - const cls = { stopped: "", running: "online", distributor_running: "online", fault_locked: "danger", comm_locked: "offline" }; - return `${labels[runtime.state] ?? runtime.state}`; -} - -export function renderOpsUnits() { - if (!dom.opsUnitList) return; - dom.opsUnitList.innerHTML = ""; - - if (!state.units.length) { - dom.opsUnitList.innerHTML = '
暂无控制单元
'; - return; - } - - state.units.forEach((unit) => { - const runtime = state.runtimes.get(unit.id); - const item = document.createElement("div"); - item.className = `ops-unit-item${state.selectedOpsUnitId === unit.id ? " selected" : ""}`; - item.innerHTML = ` -
${unit.code} / ${unit.name}
-
- ${runtimeBadge(runtime)} - ${unit.enabled ? "EN" : "DIS"} - ${runtime ? `Acc ${Math.floor(runtime.display_acc_sec / 1000)}s` : ""} -
-
- `; - item.addEventListener("click", () => selectOpsUnit(unit.id)); - - const actions = item.querySelector(".ops-unit-item-actions"); - - const isAutoOn = runtime?.auto_enabled; - const startBlocked = !isAutoOn && (runtime?.fault_locked || runtime?.manual_ack_required || runtime?.rem_local); - const autoBtn = document.createElement("button"); - autoBtn.className = isAutoOn ? "danger" : "secondary"; - autoBtn.textContent = isAutoOn ? "停止自动" : "启动自动"; - autoBtn.disabled = startBlocked; - autoBtn.title = startBlocked - ? (runtime?.fault_locked ? "设备故障中,无法启动自动控制" - : runtime?.rem_local ? "设备处于本地模式(REM关),无法启动自动控制" - : "需人工确认故障后才可启动自动控制") - : (isAutoOn ? "停止自动控制" : "启动自动控制"); - autoBtn.addEventListener("click", (e) => { - e.stopPropagation(); - apiFetch(`/api/control/unit/${unit.id}/${isAutoOn ? "stop-auto" : "start-auto"}`, { method: "POST" }) - .then(() => loadUnits()).catch(() => {}); - }); - actions.append(autoBtn); - - if (runtime?.manual_ack_required) { - const ackBtn = document.createElement("button"); - ackBtn.className = "danger"; - ackBtn.textContent = "故障确认"; - ackBtn.title = "人工确认解除故障锁定"; - ackBtn.addEventListener("click", (e) => { - e.stopPropagation(); - apiFetch(`/api/control/unit/${unit.id}/ack-fault`, { method: "POST" }) - .then(() => loadUnits()).catch(() => {}); - }); - actions.append(ackBtn); - } - - dom.opsUnitList.appendChild(item); - }); -} - -function selectOpsUnit(unitId) { - state.selectedOpsUnitId = unitId === state.selectedOpsUnitId ? null : unitId; - renderOpsUnits(); - state.opsPointEls.clear(); - - if (!state.selectedOpsUnitId) { - renderOpsEquipments(state.units.flatMap((u) => u.equipments || [])); - return; - } - - const unit = state.unitMap.get(unitId); - renderOpsEquipments(unit ? (unit.equipments || []) : []); -} - -export function loadAllEquipmentCards() { - if (!dom.opsEquipmentArea) return; - state.opsPointEls.clear(); - renderOpsEquipments(state.units.flatMap((u) => u.equipments || [])); -} - -function renderOpsEquipments(equipments) { - dom.opsEquipmentArea.innerHTML = ""; - state.opsUnitSyncFns.clear(); - - if (!equipments.length) { - dom.opsEquipmentArea.innerHTML = '
该单元下暂无设备
'; - return; - } - - equipments.forEach((eq) => { - const card = document.createElement("div"); - card.className = "ops-eq-card"; - - const roleMap = {}; - (eq.role_points || []).forEach((p) => { roleMap[p.signal_role] = p; }); - - // Signal pills — one pill per bound role, text label inside - const signalRowsHtml = SIGNAL_ROLES.map((role) => { - const point = roleMap[role]; - if (!point) return ""; - return `${ROLE_LABELS[role] || role}`; - }).join(""); - - const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor"; - const unitId = eq.unit_id ?? null; - - card.innerHTML = ` -
- ${eq.code} - ${eq.kind || "--"} -
-
${signalRowsHtml || '无绑定信号'}
- ${canControl ? `
` : ""} - `; - - let syncBtns = null; - - if (canControl) { - const actions = card.querySelector(".ops-eq-card-actions"); - const remPointId = roleMap["rem"]?.point_id ?? null; - const fltPointId = roleMap["flt"]?.point_id ?? null; - - const startBtn = document.createElement("button"); - startBtn.className = "secondary"; - startBtn.textContent = "启动"; - startBtn.addEventListener("click", () => - apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {}) - ); - const stopBtn = document.createElement("button"); - stopBtn.className = "danger"; - stopBtn.textContent = "停止"; - stopBtn.addEventListener("click", () => - apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {}) - ); - actions.append(startBtn, stopBtn); - - syncBtns = function () { - const autoOn = !!(unitId && state.runtimes.get(unitId)?.auto_enabled); - const remSig = remPointId ? state.opsSignalCache.get(remPointId) : null; - const fltSig = fltPointId ? state.opsSignalCache.get(fltPointId) : null; - const remOk = !remPointId || isSignalOn(remSig?.quality, remSig?.value_text); - const fltActive = !!(fltPointId && isSignalOn(fltSig?.quality, fltSig?.value_text)); - const disabled = autoOn || !remOk || fltActive; - const title = autoOn ? "自动控制运行中,请先停止自动" - : !remOk ? "设备未切换至远程模式" - : fltActive ? "设备故障中" - : ""; - startBtn.disabled = disabled; - stopBtn.disabled = disabled; - startBtn.title = title; - stopBtn.title = title; - }; - } - - dom.opsEquipmentArea.appendChild(card); - - // Register pills for WS updates; seed signal cache from initial point_monitor data - SIGNAL_ROLES.forEach((role) => { - const point = roleMap[role]; - if (!point) return; - const pillEl = card.querySelector(`[data-ops-dot="${point.point_id}"]`); - if (!pillEl) return; - if (point.point_monitor) { - const m = point.point_monitor; - state.opsSignalCache.set(point.point_id, { quality: m.quality, value_text: m.value_text }); - pillEl.className = sigPillClass(role, m.quality, m.value_text); - } - const isSyncRole = canControl && (role === "rem" || role === "flt"); - state.opsPointEls.set(point.point_id, { pillEl, syncBtns: isSyncRole ? syncBtns : null }); - }); - - if (canControl) { - syncBtns(); - if (unitId) { - if (!state.opsUnitSyncFns.has(unitId)) state.opsUnitSyncFns.set(unitId, new Set()); - state.opsUnitSyncFns.get(unitId).add(syncBtns); - } - } - }); -} - -export function startOps() { - renderOpsUnits(); - - dom.batchStartAutoBtn?.addEventListener("click", () => { - apiFetch("/api/control/unit/batch-start-auto", { method: "POST" }) - .then(() => loadUnits()) - .catch(() => {}); - }); - - dom.batchStopAutoBtn?.addEventListener("click", () => { - apiFetch("/api/control/unit/batch-stop-auto", { method: "POST" }) - .then(() => loadUnits()) - .catch(() => {}); - }); -} - -/** Called by WS handler when a unit's runtime changes — re-evaluates all equipment button states. */ -export function syncEquipmentButtonsForUnit(unitId) { - state.opsUnitSyncFns.get(unitId)?.forEach((fn) => fn()); -} diff --git a/web/feeder/js/points.js b/web/feeder/js/points.js deleted file mode 100644 index e09a9ac..0000000 --- a/web/feeder/js/points.js +++ /dev/null @@ -1,363 +0,0 @@ -import { apiFetch } from "./api.js"; -import { openChart } from "./chart.js"; -import { dom } from "./dom.js"; -import { - loadEquipments, - renderBatchBindingDefaults, - renderBindingEquipmentOptions, -} from "./equipment.js"; -import { renderRoleOptions } from "./roles.js"; -import { state } from "./state.js"; - -function updatePointSourceNodeCount() { - const count = dom.nodeTree.querySelectorAll("details").length; - dom.pointSourceNodeCount.textContent = `节点: ${count}`; -} - -export function formatValue(monitor) { - if (!monitor) { - return "--"; - } - if (monitor.value_text) { - return monitor.value_text; - } - if (monitor.value === null || monitor.value === undefined) { - return "--"; - } - return typeof monitor.value === "string" ? monitor.value : JSON.stringify(monitor.value); -} - -export function renderSelectedNodes() { - dom.selectedCount.textContent = `已选中 ${state.selectedNodeIds.size} 个节点`; -} - -export function updateSelectedPointSummary() { - const count = state.selectedPointIds.size; - dom.selectedPointCount.textContent = `已选中 ${count} 个点位`; - dom.batchBindingSummary.textContent = `已选中 ${count} 个点位`; - dom.openBatchBindingBtn.disabled = count === 0; -} - -export function updatePointFilterSummary() { - const filters = []; - if (state.selectedEquipmentId) { - const equipment = state.equipmentMap.get(state.selectedEquipmentId); - filters.push(`设备:${equipment?.name || equipment?.code || "未知"}`); - } - if (state.selectedSourceId) { - const source = state.sources.find((item) => item.id === state.selectedSourceId); - filters.push(`数据源:${source?.name || "未知"}`); - } - - dom.pointFilterSummary.textContent = filters.length - ? `当前筛选: ${filters.join(" / ")}` - : "当前筛选: 全部点位"; -} - -export function clearSelectedPoints() { - state.selectedPointIds.clear(); - dom.toggleAllPoints.checked = false; - dom.pointList - .querySelectorAll('input[data-point-select="true"]') - .forEach((input) => (input.checked = false)); - updateSelectedPointSummary(); -} - -function renderNode(node) { - const details = document.createElement("details"); - const summary = document.createElement("summary"); - - if (node.children?.length) { - summary.classList.add("has-children"); - } - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = state.selectedNodeIds.has(node.id); - checkbox.addEventListener("change", () => { - if (checkbox.checked) { - state.selectedNodeIds.add(node.id); - } else { - state.selectedNodeIds.delete(node.id); - } - renderSelectedNodes(); - }); - - const label = document.createElement("span"); - label.className = "node-label"; - label.textContent = `${node.display_name || node.browse_name} (${node.node_class})`; - - summary.append(checkbox, label); - details.appendChild(summary); - - (node.children || []).forEach((child) => { - details.appendChild(renderNode(child)); - }); - - return details; -} - -export function openPointCreateModal() { - dom.pointModal.classList.remove("hidden"); - if (dom.pointSourceSelect) { - dom.pointSourceSelect.value = state.selectedSourceId || ""; - } - dom.nodeTree.innerHTML = '
选择数据源并加载节点
'; - dom.pointSourceNodeCount.textContent = "节点: 0"; - state.selectedNodeIds.clear(); - renderSelectedNodes(); -} - -export async function loadTree() { - const sourceId = dom.pointSourceSelect.value || state.selectedSourceId; - if (!sourceId) { - dom.nodeTree.innerHTML = '
请选择数据源
'; - dom.pointSourceNodeCount.textContent = "节点: 0"; - return; - } - - state.selectedSourceId = sourceId; - const data = await apiFetch(`/api/source/${sourceId}/node-tree`); - dom.nodeTree.innerHTML = ""; - (data || []).forEach((node) => dom.nodeTree.appendChild(renderNode(node))); - updatePointSourceNodeCount(); -} - -export async function browseAndLoadTree() { - const sourceId = dom.pointSourceSelect.value || state.selectedSourceId; - if (!sourceId) { - throw new Error("请先选择数据源"); - } - - state.selectedSourceId = sourceId; - await apiFetch(`/api/source/${sourceId}/browse`, { method: "POST" }); - await loadTree(); -} - -export async function createPoints() { - if (!state.selectedNodeIds.size) { - return; - } - - await apiFetch("/api/point/batch", { - method: "POST", - body: JSON.stringify({ node_ids: Array.from(state.selectedNodeIds) }), - }); - - state.selectedNodeIds.clear(); - renderSelectedNodes(); - dom.pointModal.classList.add("hidden"); - await loadPoints(); -} - -function setPointSelected(pointId, checked) { - if (checked) { - state.selectedPointIds.add(pointId); - } else { - state.selectedPointIds.delete(pointId); - } - updateSelectedPointSummary(); -} - -export async function loadPoints() { - const params = new URLSearchParams({ - page: String(state.pointsPage), - page_size: String(state.pointsPageSize), - }); - if (state.selectedSourceId) { - params.set("source_id", state.selectedSourceId); - } - if (state.selectedEquipmentId) { - params.set("equipment_id", state.selectedEquipmentId); - } - - const data = await apiFetch(`/api/point?${params.toString()}`); - const items = data.data || []; - state.pointsTotal = typeof data.total === "number" ? data.total : items.length; - state.pointEls.clear(); - dom.pointList.innerHTML = ""; - - if (!items.length) { - dom.pointList.innerHTML = '暂无点位'; - dom.pointsPageInfo.textContent = `${state.pointsPage} / 1`; - clearSelectedPoints(); - updatePointFilterSummary(); - return; - } - - items.forEach((item) => { - const point = item.point || item; - const monitor = item.point_monitor || null; - const equipment = point.equipment_id ? state.equipmentMap.get(point.equipment_id) : null; - const tr = document.createElement("tr"); - - tr.addEventListener("click", () => { - openChart(point.id, point.name).catch((error) => { - dom.statusText.textContent = error.message; - }); - }); - - tr.innerHTML = ` - - -
${point.name}
-
${point.node_id}
- - ${formatValue(monitor)} - ${(monitor?.quality || "unknown").toUpperCase()} - -
-
${equipment ? equipment.name : '未绑定'}
-
${point.signal_role || "--"}
-
- - ${monitor?.timestamp || "--"} - - `; - - const selectCell = tr.children[0]; - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.dataset.pointSelect = "true"; - checkbox.checked = state.selectedPointIds.has(point.id); - checkbox.addEventListener("click", (event) => event.stopPropagation()); - checkbox.addEventListener("change", () => setPointSelected(point.id, checkbox.checked)); - selectCell.appendChild(checkbox); - - const actionCell = tr.lastElementChild; - actionCell.className = "point-actions"; - const editBtn = document.createElement("button"); - editBtn.className = "secondary"; - editBtn.textContent = "编辑"; - editBtn.addEventListener("click", (event) => { - event.stopPropagation(); - openPointBinding(point); - }); - - const deleteBtn = document.createElement("button"); - deleteBtn.className = "danger"; - deleteBtn.textContent = "删除"; - deleteBtn.addEventListener("click", (event) => { - event.stopPropagation(); - deletePoint(point.id).catch((error) => { - dom.statusText.textContent = error.message; - }); - }); - - actionCell.append(editBtn, deleteBtn); - dom.pointList.appendChild(tr); - - state.pointEls.set(point.id, { - row: tr, - value: tr.querySelector(".point-value"), - quality: tr.querySelector(".badge"), - time: tr.querySelector("td:nth-child(6) .muted"), - }); - }); - - const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize)); - dom.pointsPageInfo.textContent = `${state.pointsPage} / ${totalPages}`; - const pageCheckboxes = dom.pointList.querySelectorAll('input[data-point-select="true"]'); - dom.toggleAllPoints.checked = - pageCheckboxes.length > 0 && Array.from(pageCheckboxes).every((input) => input.checked); - updateSelectedPointSummary(); - updatePointFilterSummary(); -} - -export function openPointBinding(point) { - dom.bindingPointId.value = point.id; - dom.bindingPointName.value = point.name || ""; - dom.bindingPointName.disabled = false; - const modalTitle = dom.pointBindingModal.querySelector("h3"); - if (modalTitle) { - modalTitle.textContent = "编辑点位"; - } - if (dom.clearPointBindingBtn) { - dom.clearPointBindingBtn.textContent = "清除设备"; - } - const saveButton = dom.pointBindingForm?.querySelector('button[type="submit"]'); - if (saveButton) { - saveButton.textContent = "保存"; - } - renderBindingEquipmentOptions(point.equipment_id || ""); - dom.bindingSignalRole.innerHTML = renderRoleOptions(point.signal_role || ""); - dom.pointBindingModal.classList.remove("hidden"); -} - -export async function savePointBinding(event) { - event.preventDefault(); - - await apiFetch(`/api/point/${dom.bindingPointId.value}`, { - method: "PUT", - body: JSON.stringify({ - name: dom.bindingPointName.value.trim() || null, - equipment_id: dom.bindingEquipmentId.value || null, - signal_role: dom.bindingSignalRole.value || null, - }), - }); - - dom.pointBindingModal.classList.add("hidden"); - await loadEquipments(); - await loadPoints(); -} - -export function openBatchBinding() { - if (!state.selectedPointIds.size) { - return; - } - renderBatchBindingDefaults(); - updateSelectedPointSummary(); - dom.batchBindingModal.classList.remove("hidden"); -} - -export async function saveBatchBinding(event) { - event.preventDefault(); - - if (!state.selectedPointIds.size) { - return; - } - - await apiFetch("/api/point/batch/set-equipment", { - method: "PUT", - body: JSON.stringify({ - point_ids: Array.from(state.selectedPointIds), - equipment_id: dom.batchBindingEquipmentId.value || null, - signal_role: dom.batchBindingSignalRole.value || null, - }), - }); - - dom.batchBindingModal.classList.add("hidden"); - clearSelectedPoints(); - await loadEquipments(); - await loadPoints(); -} - -export async function clearBatchBinding() { - if (!state.selectedPointIds.size) { - return; - } - - await apiFetch("/api/point/batch/set-equipment", { - method: "PUT", - body: JSON.stringify({ - point_ids: Array.from(state.selectedPointIds), - equipment_id: null, - signal_role: null, - }), - }); - - dom.batchBindingModal.classList.add("hidden"); - clearSelectedPoints(); - await loadEquipments(); - await loadPoints(); -} - -export async function deletePoint(pointId) { - if (!window.confirm("确认删除该点位?")) { - return; - } - - await apiFetch(`/api/point/${pointId}`, { method: "DELETE" }); - state.selectedPointIds.delete(pointId); - await loadPoints(); -} diff --git a/web/feeder/js/roles.js b/web/feeder/js/roles.js deleted file mode 100644 index e2425c5..0000000 --- a/web/feeder/js/roles.js +++ /dev/null @@ -1,29 +0,0 @@ -export const SIGNAL_ROLE_OPTIONS = [ - { value: "", label: "未设置" }, - { value: "rem", label: "REM 远程使能" }, - { value: "run", label: "RUN 运行" }, - { value: "flt", label: "FLT 故障" }, - { value: "ii", label: "II 电流" }, - { value: "start_cmd", label: "启动命令" }, - { value: "stop_cmd", label: "停止命令" }, -]; - -export const EQUIPMENT_KIND_OPTIONS = [ - { value: "", label: "未设置" }, - { value: "coal_feeder", label: "投煤器" }, - { value: "distributor", label: "布料机" }, -]; - -export function renderRoleOptions(selected = "") { - return SIGNAL_ROLE_OPTIONS.map((item) => { - const isSelected = item.value === selected ? "selected" : ""; - return ``; - }).join(""); -} - -export function renderEquipmentKindOptions(selected = "") { - return EQUIPMENT_KIND_OPTIONS.map((item) => { - const isSelected = item.value === selected ? "selected" : ""; - return ``; - }).join(""); -} diff --git a/web/feeder/js/sources.js b/web/feeder/js/sources.js deleted file mode 100644 index 15bd75d..0000000 --- a/web/feeder/js/sources.js +++ /dev/null @@ -1,138 +0,0 @@ -import { apiFetch } from "./api.js"; -import { dom } from "./dom.js"; -import { loadPoints, updatePointFilterSummary } from "./points.js"; -import { state } from "./state.js"; - -function renderPointSourceOptions() { - if (!dom.pointSourceSelect) { - return; - } - - const options = ['']; - state.sources.forEach((source) => { - const selected = source.id === state.selectedSourceId ? "selected" : ""; - options.push(``); - }); - dom.pointSourceSelect.innerHTML = options.join(""); -} - -export function renderSources() { - dom.sourceList.innerHTML = ""; - - state.sources.forEach((source) => { - const card = document.createElement("div"); - card.className = `list-item source-card ${state.selectedSourceId === source.id ? "selected" : ""}`; - card.innerHTML = ` -
- ${source.name} - ${source.is_connected ? "ONLINE" : "OFFLINE"} -
-
${source.endpoint}
-
- `; - - card.addEventListener("click", () => { - selectSource(source.id).catch((error) => { - dom.statusText.textContent = error.message; - }); - }); - - const actionRow = card.querySelector(".source-card-actions"); - - const editBtn = document.createElement("button"); - editBtn.className = "secondary"; - editBtn.textContent = "编辑"; - editBtn.addEventListener("click", (event) => { - event.stopPropagation(); - dom.sourceId.value = source.id; - dom.sourceName.value = source.name || ""; - dom.sourceEndpoint.value = source.endpoint || ""; - dom.sourceEnabled.checked = !!source.enabled; - dom.sourceModal.classList.remove("hidden"); - }); - - const reconnectBtn = document.createElement("button"); - reconnectBtn.className = "secondary"; - reconnectBtn.textContent = "重连"; - reconnectBtn.addEventListener("click", (event) => { - event.stopPropagation(); - reconnectSource(source.id, source.name).catch((error) => { - dom.statusText.textContent = error.message; - }); - }); - - const deleteBtn = document.createElement("button"); - deleteBtn.className = "danger"; - deleteBtn.textContent = "删除"; - deleteBtn.addEventListener("click", (event) => { - event.stopPropagation(); - deleteSource(source.id).catch((error) => { - dom.statusText.textContent = error.message; - }); - }); - - actionRow.append(editBtn, reconnectBtn, deleteBtn); - card.appendChild(actionRow); - dom.sourceList.appendChild(card); - }); - - renderPointSourceOptions(); -} - -export async function loadSources() { - state.sources = await apiFetch("/api/source"); - if (state.selectedSourceId && !state.sources.some((item) => item.id === state.selectedSourceId)) { - state.selectedSourceId = null; - } - renderSources(); - updatePointFilterSummary(); -} - -export async function selectSource(sourceId) { - state.selectedSourceId = state.selectedSourceId === sourceId ? null : sourceId; - state.selectedNodeIds.clear(); - state.pointsPage = 1; - renderSources(); - updatePointFilterSummary(); - await loadPoints(); -} - -export async function saveSource(event) { - event.preventDefault(); - - const payload = { - name: dom.sourceName.value.trim(), - endpoint: dom.sourceEndpoint.value.trim(), - enabled: dom.sourceEnabled.checked, - }; - - const id = dom.sourceId.value; - await apiFetch(id ? `/api/source/${id}` : "/api/source", { - method: id ? "PUT" : "POST", - body: JSON.stringify(payload), - }); - - dom.sourceModal.classList.add("hidden"); - dom.sourceForm.reset(); - await loadSources(); -} - -export async function reconnectSource(sourceId, name) { - dom.statusText.textContent = `正在重连 ${name || "数据源"}...`; - await apiFetch(`/api/source/${sourceId}/reconnect`, { method: "POST" }); - await loadSources(); - dom.statusText.textContent = "就绪"; -} - -export async function deleteSource(sourceId) { - if (!window.confirm("确认删除该数据源?")) { - return; - } - - await apiFetch(`/api/source/${sourceId}`, { method: "DELETE" }); - if (state.selectedSourceId === sourceId) { - state.selectedSourceId = null; - } - await loadSources(); - await loadPoints(); -} diff --git a/web/feeder/js/state.js b/web/feeder/js/state.js deleted file mode 100644 index fad3909..0000000 --- a/web/feeder/js/state.js +++ /dev/null @@ -1,29 +0,0 @@ -export const state = { - units: [], - unitMap: new Map(), - selectedUnitId: null, - sources: [], - events: [], - equipments: [], - equipmentMap: new Map(), - selectedEquipmentId: null, - selectedSourceId: null, - selectedNodeIds: new Set(), - selectedPointIds: new Set(), - pointsPage: 1, - pointsPageSize: 100, - pointsTotal: 0, - pointEls: new Map(), - chartPointId: null, - chartPointName: "", - chartData: [], - pointSocket: null, - docDrawerSource: null, // null | "api" | "readme" - runtimes: new Map(), // unit_id -> UnitRuntime - activeView: "ops", // "ops" | "config" - opsPointEls: new Map(), // point_id -> { pillEl, syncBtns? } - opsSignalCache: new Map(), // point_id -> { quality, value_text } - opsUnitSyncFns: new Map(), // unit_id -> Set - logSource: null, - selectedOpsUnitId: null, -}; diff --git a/web/feeder/js/units.js b/web/feeder/js/units.js deleted file mode 100644 index dcd8708..0000000 --- a/web/feeder/js/units.js +++ /dev/null @@ -1,324 +0,0 @@ -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"; - -function equipmentOf(item) { - return item && item.equipment ? item.equipment : item; -} - -function equipmentCount(unitId) { - return state.equipments.filter((item) => { - const equipment = equipmentOf(item); - return equipment.unit_id === unitId; - }).length; -} - -function boundEquipments(unitId) { - return state.equipments - .map(equipmentOf) - .filter((e) => e.unit_id === unitId); -} - -export function renderUnitOptions(selected = "", target = dom.equipmentUnitId, includeEmpty = true) { - if (!target) { - return; - } - - const options = []; - if (includeEmpty) { - options.push(''); - } - - state.units.forEach((unit) => { - const isSelected = unit.id === selected ? "selected" : ""; - options.push(``); - }); - - target.innerHTML = options.join(""); -} - -export function resetUnitForm() { - dom.unitForm.reset(); - dom.unitId.value = ""; - dom.unitEnabled.checked = true; - dom.unitManualAck.checked = true; - dom.unitRunTimeSec.value = "10"; - dom.unitStopTimeSec.value = "10"; - dom.unitAccTimeSec.value = "20"; - dom.unitBlTimeSec.value = "10"; -} - -function openUnitModal() { - dom.unitModal.classList.remove("hidden"); -} - -export function closeUnitModal() { - dom.unitModal.classList.add("hidden"); -} - -export function openCreateUnitModal() { - resetUnitForm(); - openUnitModal(); -} - -function openEditUnitModal(unit) { - dom.unitId.value = unit.id || ""; - dom.unitCode.value = unit.code || ""; - dom.unitName.value = unit.name || ""; - dom.unitDescription.value = unit.description || ""; - dom.unitEnabled.checked = !!unit.enabled; - dom.unitRunTimeSec.value = String(unit.run_time_sec ?? 0); - dom.unitStopTimeSec.value = String(unit.stop_time_sec ?? 0); - dom.unitAccTimeSec.value = String(unit.acc_time_sec ?? 0); - dom.unitBlTimeSec.value = String(unit.bl_time_sec ?? 0); - dom.unitManualAck.checked = !!unit.require_manual_ack_after_fault; - openUnitModal(); -} - -async function selectUnit(unitId) { - state.selectedUnitId = state.selectedUnitId === unitId ? null : unitId; - renderUnits(); - renderEquipments(); - await loadEvents(); -} - -function runtimeBadge(runtime) { - if (!runtime) return 'OFFLINE'; - if (runtime.comm_locked) return 'COMM ERR'; - if (runtime.fault_locked) return 'FAULT'; - const stateLabels = { - stopped: 'STOPPED', - running: 'RUNNING', - distributor_running: 'DIST RUN', - fault_locked: 'FAULT', - comm_locked: 'COMM ERR', - }; - const stateCls = { - stopped: '', - running: 'online', - distributor_running: 'online', - fault_locked: 'danger', - comm_locked: 'offline', - }; - const label = stateLabels[runtime.state] ?? runtime.state; - const cls = stateCls[runtime.state] ?? ''; - return `${label}`; -} - -function buildUnitCard(unit, mode) { - const card = document.createElement("div"); - const selected = mode === "interactive" && state.selectedUnitId === unit.id; - card.className = `list-item unit-card ${selected ? "selected" : ""}`; - const runtime = state.runtimes.get(unit.id); - - const bound = boundEquipments(unit.id); - const equipTags = bound.length - ? bound.map((e) => `${e.code}`).join("") - : '无设备'; - - card.innerHTML = ` -
- ${unit.code} - ${runtimeBadge(runtime)} - ${unit.enabled ? "EN" : "DIS"} -
-
${unit.name}
-
设备 ${bound.length} 台 | 累计 ${runtime ? Math.floor(runtime.display_acc_sec / 1000) : 0}s
-
运行 ${unit.run_time_sec}s / 停止 ${unit.stop_time_sec}s / 累计 ${unit.acc_time_sec}s / 间隔 ${unit.bl_time_sec}s
- ${mode === "config" ? `
${equipTags}
` : ""} -
- `; - - if (mode === "interactive") { - card.addEventListener("click", () => { - selectUnit(unit.id).catch((error) => { - dom.statusText.textContent = error.message; - }); - }); - } - - const actions = card.querySelector(".unit-card-actions"); - - const editBtn = document.createElement("button"); - editBtn.className = "secondary"; - editBtn.textContent = "编辑"; - editBtn.addEventListener("click", (event) => { - event.stopPropagation(); - openEditUnitModal(unit); - }); - - const deleteBtn = document.createElement("button"); - deleteBtn.className = "danger"; - deleteBtn.textContent = "删除"; - deleteBtn.addEventListener("click", (event) => { - event.stopPropagation(); - deleteUnit(unit.id).catch((error) => { - dom.statusText.textContent = error.message; - }); - }); - - actions.append(editBtn, deleteBtn); - - if (mode === "config") { - const selectEquipBtn = document.createElement("button"); - selectEquipBtn.className = "secondary"; - selectEquipBtn.textContent = "选择设备"; - selectEquipBtn.addEventListener("click", (e) => { - e.stopPropagation(); - openUnitEquipmentModal(unit); - }); - actions.append(selectEquipBtn); - } - - return card; -} - -function renderToContainer(container, mode) { - if (!container) return; - container.innerHTML = ""; - - if (!state.units.length) { - container.innerHTML = '
暂无控制单元
'; - return; - } - - state.units.forEach((unit) => { - container.appendChild(buildUnitCard(unit, mode)); - }); -} - -export function renderUnits() { - renderToContainer(dom.unitList, "interactive"); - renderToContainer(dom.unitConfigList, "config"); -} - -export async function loadUnits() { - const response = await apiFetch("/api/unit?page=1&page_size=-1"); - state.units = response.data || []; - state.unitMap = new Map(state.units.map((unit) => [unit.id, unit])); - - if (state.selectedUnitId && !state.unitMap.has(state.selectedUnitId)) { - state.selectedUnitId = null; - } - - state.units.forEach((unit) => { - if (unit.runtime) state.runtimes.set(unit.id, unit.runtime); - }); - - renderUnits(); - renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId); - document.dispatchEvent(new Event("units-loaded")); -} - -export async function saveUnit(event) { - event.preventDefault(); - - const payload = { - code: dom.unitCode.value.trim(), - name: dom.unitName.value.trim(), - description: dom.unitDescription.value.trim() || null, - enabled: dom.unitEnabled.checked, - run_time_sec: Number(dom.unitRunTimeSec.value || 0), - stop_time_sec: Number(dom.unitStopTimeSec.value || 0), - acc_time_sec: Number(dom.unitAccTimeSec.value || 0), - bl_time_sec: Number(dom.unitBlTimeSec.value || 0), - require_manual_ack_after_fault: dom.unitManualAck.checked, - }; - - const id = dom.unitId.value; - await apiFetch(id ? `/api/unit/${id}` : "/api/unit", { - method: id ? "PUT" : "POST", - body: JSON.stringify(payload), - }); - - closeUnitModal(); - await loadUnits(); - renderEquipments(); - await loadEvents(); -} - -export async function deleteUnit(unitId) { - if (!window.confirm("确认删除该单元?")) { - return; - } - - await apiFetch(`/api/unit/${unitId}`, { method: "DELETE" }); - if (state.selectedUnitId === unitId) { - state.selectedUnitId = null; - } - closeUnitModal(); - await loadUnits(); - renderEquipments(); - await loadEvents(); -} - -// ── Unit Equipment Selection Modal ── - -let _unitEquipmentTargetId = null; -const _unitEquipmentSelected = new Set(); - -function openUnitEquipmentModal(unit) { - _unitEquipmentTargetId = unit.id; - _unitEquipmentSelected.clear(); - - const allEquipments = state.equipments.map(equipmentOf); - const bound = new Set(boundEquipments(unit.id).map((e) => e.id)); - bound.forEach((id) => _unitEquipmentSelected.add(id)); - - dom.unitEquipmentList.innerHTML = ""; - dom.unitEquipmentList.className = "unit-equip-grid"; - allEquipments.forEach((e) => { - const item = document.createElement("label"); - item.className = "unit-equip-item"; - const checked = bound.has(e.id) ? "checked" : ""; - item.innerHTML = `${e.code}`; - item.title = e.name; - item.querySelector("input").addEventListener("change", (ev) => { - if (ev.target.checked) _unitEquipmentSelected.add(e.id); - else _unitEquipmentSelected.delete(e.id); - }); - dom.unitEquipmentList.appendChild(item); - }); - - dom.unitEquipmentModal.classList.remove("hidden"); -} - -function closeUnitEquipmentModal() { - dom.unitEquipmentModal.classList.add("hidden"); - _unitEquipmentTargetId = null; -} - -async function confirmUnitEquipment() { - if (!_unitEquipmentTargetId) return; - - const previouslyBound = new Set(boundEquipments(_unitEquipmentTargetId).map((e) => e.id)); - - const toBind = [..._unitEquipmentSelected].filter((id) => !previouslyBound.has(id)); - const toUnbind = [...previouslyBound].filter((id) => !_unitEquipmentSelected.has(id)); - - if (toBind.length > 0) { - await apiFetch("/api/equipment/batch/set-unit", { - method: "PUT", - body: JSON.stringify({ equipment_ids: toBind, unit_id: _unitEquipmentTargetId }), - }); - } - - if (toUnbind.length > 0) { - await apiFetch("/api/equipment/batch/set-unit", { - method: "PUT", - body: JSON.stringify({ equipment_ids: toUnbind, unit_id: null }), - }); - } - - closeUnitEquipmentModal(); - await loadEquipments(); - await loadUnits(); -} - -export function bindUnitEquipmentModalEvents() { - dom.closeUnitEquipmentModalBtn.addEventListener("click", closeUnitEquipmentModal); - dom.cancelUnitEquipmentBtn.addEventListener("click", closeUnitEquipmentModal); - dom.confirmUnitEquipmentBtn.addEventListener("click", () => withStatus(confirmUnitEquipment())); -} diff --git a/web/ops/html/topbar.html b/web/ops/html/topbar.html deleted file mode 100644 index 13ab23e..0000000 --- a/web/ops/html/topbar.html +++ /dev/null @@ -1,9 +0,0 @@ -
-
运转系统
-
-
- - 连接中… -
-
-
diff --git a/web/ops/index.html b/web/ops/index.html deleted file mode 100644 index 852278d..0000000 --- a/web/ops/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - 运转系统 - - - -
- -
-
运转系统页面开发中
-
- - - - diff --git a/web/ops/js/app.js b/web/ops/js/app.js deleted file mode 100644 index 74140f4..0000000 --- a/web/ops/js/app.js +++ /dev/null @@ -1,5 +0,0 @@ -function bootstrap() { - console.log("Operation system app initialized"); -} - -bootstrap(); diff --git a/web/ops/js/index.js b/web/ops/js/index.js deleted file mode 100644 index 727497c..0000000 --- a/web/ops/js/index.js +++ /dev/null @@ -1,20 +0,0 @@ -async function loadPartial(slot) { - const response = await fetch(slot.dataset.partial); - if (!response.ok) { - throw new Error(`Failed to load partial: ${slot.dataset.partial}`); - } - - const html = await response.text(); - slot.insertAdjacentHTML("beforebegin", html); - slot.remove(); -} - -async function bootstrapPage() { - const slots = Array.from(document.querySelectorAll("[data-partial]")); - await Promise.all(slots.map((slot) => loadPartial(slot))); - await import("./app.js"); -} - -bootstrapPage().catch((error) => { - document.body.innerHTML = `
${error.message || String(error)}
`; -});