) -> 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 @@
-
-
-
-
-
-
选择节点创建点位
- X
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
-
-
-
点位
-
-
-
-
-
-
-
-
- 名称
- 值
- 质量
- 设备/角色
- 更新时间
-
-
-
-
-
-
-
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 @@
-
-
-
-
-
-
选择节点创建点位
- X
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
-
- 投煤器布料机控制系统
-
- 运维
- 应用配置
- 平台配置
-
-
-
README.md
-
API.md
-
-
- 连接中…
-
-
-
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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
选择设备
- X
-
-
-
- 取消
- 确认绑定
-
-
-
-
-
-
-
-
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(`${unit.code} / ${unit.name} `);
- });
- 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(
- `${equipment.code} / ${equipment.name} `,
- );
- });
- 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()}
-
-
-
- ${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 `${item.label} `;
- }).join("");
-}
-
-export function renderEquipmentKindOptions(selected = "") {
- return EQUIPMENT_KIND_OPTIONS.map((item) => {
- const isSelected = item.value === selected ? "selected" : "";
- return `${item.label} `;
- }).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(`${source.name} `);
- });
- 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(`${unit.code} / ${unit.name} `);
- });
-
- 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)} `;
-});