diff --git a/web/core/html/api-doc-drawer.html b/web/core/html/api-doc-drawer.html
new file mode 100644
index 0000000..c85a51a
--- /dev/null
+++ b/web/core/html/api-doc-drawer.html
@@ -0,0 +1,15 @@
+
diff --git a/web/core/html/chart-panel.html b/web/core/html/chart-panel.html
new file mode 100644
index 0000000..6d4ba12
--- /dev/null
+++ b/web/core/html/chart-panel.html
@@ -0,0 +1,10 @@
+
diff --git a/web/core/html/equipment-panel.html b/web/core/html/equipment-panel.html
new file mode 100644
index 0000000..7601075
--- /dev/null
+++ b/web/core/html/equipment-panel.html
@@ -0,0 +1,11 @@
+
+
+
设备
+
+
+
+
+
+
+
+
diff --git a/web/core/html/log-stream-panel.html b/web/core/html/log-stream-panel.html
new file mode 100644
index 0000000..6a2e8a8
--- /dev/null
+++ b/web/core/html/log-stream-panel.html
@@ -0,0 +1,6 @@
+
diff --git a/web/core/html/logs-panel.html b/web/core/html/logs-panel.html
new file mode 100644
index 0000000..2d6d5f3
--- /dev/null
+++ b/web/core/html/logs-panel.html
@@ -0,0 +1,7 @@
+
diff --git a/web/core/html/modals.html b/web/core/html/modals.html
new file mode 100644
index 0000000..71c52c9
--- /dev/null
+++ b/web/core/html/modals.html
@@ -0,0 +1,131 @@
+
+
+
+
+
+
选择节点创建点位
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/core/html/points-panel.html b/web/core/html/points-panel.html
new file mode 100644
index 0000000..458022a
--- /dev/null
+++ b/web/core/html/points-panel.html
@@ -0,0 +1,37 @@
+
+
+
点位
+
+
+
+
+
+
+
+ |
+ 名称 |
+ 值 |
+ 质量 |
+ 设备/角色 |
+ 更新时间 |
+ |
+
+
+
+
+
+
diff --git a/web/core/html/source-panel.html b/web/core/html/source-panel.html
new file mode 100644
index 0000000..14b7f40
--- /dev/null
+++ b/web/core/html/source-panel.html
@@ -0,0 +1,7 @@
+
diff --git a/web/core/styles.css b/web/core/styles.css
new file mode 100644
index 0000000..afc59e4
--- /dev/null
+++ b/web/core/styles.css
@@ -0,0 +1,1402 @@
+: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
new file mode 100644
index 0000000..3f058c4
--- /dev/null
+++ b/web/feeder/html/modals.html
@@ -0,0 +1,135 @@
+
+
+
+
+
+
选择节点创建点位
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/feeder/html/ops-panel.html b/web/feeder/html/ops-panel.html
new file mode 100644
index 0000000..9385f09
--- /dev/null
+++ b/web/feeder/html/ops-panel.html
@@ -0,0 +1,17 @@
+
diff --git a/web/feeder/html/topbar.html b/web/feeder/html/topbar.html
new file mode 100644
index 0000000..b36defc
--- /dev/null
+++ b/web/feeder/html/topbar.html
@@ -0,0 +1,16 @@
+
+ 投煤器布料机控制系统
+
+
+
+
+
+
+
+
+
+
+ 连接中…
+
+
+
diff --git a/web/feeder/html/unit-modal.html b/web/feeder/html/unit-modal.html
new file mode 100644
index 0000000..674b8df
--- /dev/null
+++ b/web/feeder/html/unit-modal.html
@@ -0,0 +1,51 @@
+
diff --git a/web/feeder/html/unit-panel.html b/web/feeder/html/unit-panel.html
new file mode 100644
index 0000000..7b64ca9
--- /dev/null
+++ b/web/feeder/html/unit-panel.html
@@ -0,0 +1,10 @@
+
+
+
控制单元配置
+
+
+
+
+
+
+
diff --git a/web/feeder/index.html b/web/feeder/index.html
new file mode 100644
index 0000000..42c19ec
--- /dev/null
+++ b/web/feeder/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+ PLC Control
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
选择设备
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/feeder/js/api.js b/web/feeder/js/api.js
new file mode 100644
index 0000000..a946588
--- /dev/null
+++ b/web/feeder/js/api.js
@@ -0,0 +1,87 @@
+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
new file mode 100644
index 0000000..fb6d762
--- /dev/null
+++ b/web/feeder/js/app.js
@@ -0,0 +1,210 @@
+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
new file mode 100644
index 0000000..c19f8fe
--- /dev/null
+++ b/web/feeder/js/chart.js
@@ -0,0 +1,183 @@
+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
new file mode 100644
index 0000000..a0a4a80
--- /dev/null
+++ b/web/feeder/js/docs.js
@@ -0,0 +1,137 @@
+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
new file mode 100644
index 0000000..a110310
--- /dev/null
+++ b/web/feeder/js/dom.js
@@ -0,0 +1,110 @@
+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
new file mode 100644
index 0000000..4464868
--- /dev/null
+++ b/web/feeder/js/equipment.js
@@ -0,0 +1,245 @@
+import { apiFetch } from "./api.js";
+import { dom } from "./dom.js";
+import { renderEquipmentKindOptions, renderRoleOptions } from "./roles.js";
+import { clearSelectedPoints, loadPoints, updatePointFilterSummary } from "./points.js";
+import { state } from "./state.js";
+
+function equipmentOf(item) {
+ return item && item.equipment ? item.equipment : item;
+}
+
+function currentUnitLabel(unitId) {
+ if (!unitId) {
+ return "未绑定单元";
+ }
+ const unit = state.unitMap.get(unitId);
+ return unit ? `${unit.code} / ${unit.name}` : "未知单元";
+}
+
+function filteredEquipments() {
+ if (!state.selectedUnitId) {
+ return state.equipments;
+ }
+
+ return state.equipments.filter((item) => {
+ const equipment = equipmentOf(item);
+ return equipment.unit_id === state.selectedUnitId;
+ });
+}
+
+function renderEquipmentUnitOptions(selected = "", target = dom.equipmentUnitId) {
+ if (!target) {
+ return;
+ }
+
+ const options = [''];
+ state.units.forEach((unit) => {
+ const isSelected = unit.id === selected ? "selected" : "";
+ options.push(``);
+ });
+ target.innerHTML = options.join("");
+}
+
+export function renderBindingEquipmentOptions(selected = "", target = dom.bindingEquipmentId) {
+ const options = [''];
+ filteredEquipments().forEach((item) => {
+ const equipment = equipmentOf(item);
+ const isSelected = equipment.id === selected ? "selected" : "";
+ options.push(
+ ``,
+ );
+ });
+ target.innerHTML = options.join("");
+}
+
+export function renderBatchBindingDefaults() {
+ renderBindingEquipmentOptions("", dom.batchBindingEquipmentId);
+ dom.batchBindingSignalRole.innerHTML = renderRoleOptions("");
+}
+
+export function resetEquipmentForm() {
+ dom.equipmentForm.reset();
+ dom.equipmentId.value = "";
+ renderEquipmentUnitOptions("");
+ dom.equipmentKind.innerHTML = renderEquipmentKindOptions("");
+}
+
+function openEquipmentModal() {
+ dom.equipmentModal.classList.remove("hidden");
+}
+
+export function closeEquipmentModal() {
+ dom.equipmentModal.classList.add("hidden");
+}
+
+export function openCreateEquipmentModal() {
+ resetEquipmentForm();
+ if (state.selectedUnitId && dom.equipmentUnitId) {
+ dom.equipmentUnitId.value = state.selectedUnitId;
+ }
+ openEquipmentModal();
+}
+
+function openEditEquipmentModal(equipment) {
+ dom.equipmentId.value = equipment.id || "";
+ dom.equipmentUnitId.value = equipment.unit_id || "";
+ dom.equipmentCode.value = equipment.code || "";
+ dom.equipmentName.value = equipment.name || "";
+ dom.equipmentKind.innerHTML = renderEquipmentKindOptions(equipment.kind || "");
+ dom.equipmentDescription.value = equipment.description || "";
+ openEquipmentModal();
+}
+
+async function selectEquipment(equipmentId) {
+ state.selectedEquipmentId = state.selectedEquipmentId === equipmentId ? null : equipmentId;
+ state.pointsPage = 1;
+ clearSelectedPoints();
+ renderEquipments();
+ updatePointFilterSummary();
+ await loadPoints();
+}
+
+export function clearEquipmentFilter() {
+ state.selectedEquipmentId = null;
+ state.pointsPage = 1;
+ renderEquipments();
+ updatePointFilterSummary();
+ return loadPoints();
+}
+
+export function renderEquipments() {
+ dom.equipmentList.innerHTML = "";
+
+ const items = filteredEquipments();
+ if (!items.length) {
+ dom.equipmentList.innerHTML = '';
+ return;
+ }
+
+ items.forEach((item) => {
+ const equipment = equipmentOf(item);
+ const box = document.createElement("div");
+ box.className = `list-item equipment-card ${state.selectedEquipmentId === equipment.id ? "selected" : ""}`;
+ box.innerHTML = `
+
+ ${equipment.code}
+ ${item.point_count ?? 0} pts
+
+ ${equipment.name}
+ ${equipment.kind || "未分类"}
+ 单元: ${currentUnitLabel(equipment.unit_id)}
+
+ `;
+
+ box.addEventListener("click", () => {
+ selectEquipment(equipment.id).catch((error) => {
+ dom.statusText.textContent = error.message;
+ });
+ });
+
+ const actionRow = box.querySelector(".equipment-card-actions");
+
+ const editBtn = document.createElement("button");
+ editBtn.className = "secondary";
+ editBtn.textContent = "编辑";
+ editBtn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ openEditEquipmentModal(equipment);
+ });
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.className = "danger";
+ deleteBtn.textContent = "删除";
+ deleteBtn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ deleteEquipment(equipment.id).catch((error) => {
+ dom.statusText.textContent = error.message;
+ });
+ });
+
+ actionRow.append(editBtn, deleteBtn);
+
+ dom.equipmentList.appendChild(box);
+ });
+}
+
+export async function loadEquipments() {
+ const keyword = dom.equipmentKeyword.value.trim();
+ const query = keyword
+ ? `?page=1&page_size=-1&keyword=${encodeURIComponent(keyword)}`
+ : "?page=1&page_size=-1";
+ const data = await apiFetch(`/api/equipment${query}`);
+ state.equipments = data.data || [];
+ state.equipmentMap = new Map(
+ state.equipments.map((item) => {
+ const equipment = equipmentOf(item);
+ return [equipment.id, equipment];
+ }),
+ );
+
+ renderEquipmentUnitOptions(dom.equipmentUnitId?.value || "");
+ dom.equipmentKind.innerHTML = renderEquipmentKindOptions(dom.equipmentKind?.value || "");
+ renderBindingEquipmentOptions();
+ renderBatchBindingDefaults();
+ if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) {
+ state.selectedEquipmentId = null;
+ }
+ renderEquipments();
+ updatePointFilterSummary();
+ document.dispatchEvent(new Event("equipments-updated"));
+}
+
+export async function saveEquipment(event) {
+ event.preventDefault();
+
+ const unitId = dom.equipmentUnitId.value || null;
+ const payload = {
+ unit_id: unitId,
+ code: dom.equipmentCode.value.trim(),
+ name: dom.equipmentName.value.trim(),
+ kind: dom.equipmentKind.value.trim() || null,
+ description: dom.equipmentDescription.value.trim() || null,
+ };
+
+ const id = dom.equipmentId.value;
+ const result = await apiFetch(id ? `/api/equipment/${id}` : "/api/equipment", {
+ method: id ? "PUT" : "POST",
+ body: JSON.stringify(payload),
+ });
+
+ closeEquipmentModal();
+ await loadEquipments();
+ if (!id && result?.id) {
+ state.selectedEquipmentId = result.id;
+ }
+ renderEquipments();
+ updatePointFilterSummary();
+ await loadPoints();
+}
+
+export async function deleteEquipment(equipmentId) {
+ if (!window.confirm("确认删除该设备?")) {
+ return;
+ }
+
+ await apiFetch(`/api/equipment/${equipmentId}`, { method: "DELETE" });
+ if (state.selectedEquipmentId === equipmentId) {
+ state.selectedEquipmentId = null;
+ }
+ resetEquipmentForm();
+ closeEquipmentModal();
+ clearSelectedPoints();
+ await loadEquipments();
+ await loadPoints();
+}
+
+export async function clearPointBinding(pointId = dom.bindingPointId.value) {
+ await apiFetch(`/api/point/${pointId}`, {
+ method: "PUT",
+ body: JSON.stringify({ equipment_id: null, signal_role: null }),
+ });
+
+ dom.pointBindingModal.classList.add("hidden");
+ await loadEquipments();
+ await loadPoints();
+}
diff --git a/web/feeder/js/events.js b/web/feeder/js/events.js
new file mode 100644
index 0000000..94863de
--- /dev/null
+++ b/web/feeder/js/events.js
@@ -0,0 +1,86 @@
+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
new file mode 100644
index 0000000..727497c
--- /dev/null
+++ b/web/feeder/js/index.js
@@ -0,0 +1,20 @@
+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
new file mode 100644
index 0000000..2afd3cd
--- /dev/null
+++ b/web/feeder/js/logs.js
@@ -0,0 +1,176 @@
+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
new file mode 100644
index 0000000..ec468d2
--- /dev/null
+++ b/web/feeder/js/ops.js
@@ -0,0 +1,232 @@
+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
new file mode 100644
index 0000000..e09a9ac
--- /dev/null
+++ b/web/feeder/js/points.js
@@ -0,0 +1,363 @@
+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
new file mode 100644
index 0000000..e2425c5
--- /dev/null
+++ b/web/feeder/js/roles.js
@@ -0,0 +1,29 @@
+export const SIGNAL_ROLE_OPTIONS = [
+ { value: "", label: "未设置" },
+ { value: "rem", label: "REM 远程使能" },
+ { value: "run", label: "RUN 运行" },
+ { value: "flt", label: "FLT 故障" },
+ { value: "ii", label: "II 电流" },
+ { value: "start_cmd", label: "启动命令" },
+ { value: "stop_cmd", label: "停止命令" },
+];
+
+export const EQUIPMENT_KIND_OPTIONS = [
+ { value: "", label: "未设置" },
+ { value: "coal_feeder", label: "投煤器" },
+ { value: "distributor", label: "布料机" },
+];
+
+export function renderRoleOptions(selected = "") {
+ return SIGNAL_ROLE_OPTIONS.map((item) => {
+ const isSelected = item.value === selected ? "selected" : "";
+ return ``;
+ }).join("");
+}
+
+export function renderEquipmentKindOptions(selected = "") {
+ return EQUIPMENT_KIND_OPTIONS.map((item) => {
+ const isSelected = item.value === selected ? "selected" : "";
+ return ``;
+ }).join("");
+}
diff --git a/web/feeder/js/sources.js b/web/feeder/js/sources.js
new file mode 100644
index 0000000..15bd75d
--- /dev/null
+++ b/web/feeder/js/sources.js
@@ -0,0 +1,138 @@
+import { apiFetch } from "./api.js";
+import { dom } from "./dom.js";
+import { loadPoints, updatePointFilterSummary } from "./points.js";
+import { state } from "./state.js";
+
+function renderPointSourceOptions() {
+ if (!dom.pointSourceSelect) {
+ return;
+ }
+
+ const options = [''];
+ state.sources.forEach((source) => {
+ const selected = source.id === state.selectedSourceId ? "selected" : "";
+ options.push(``);
+ });
+ dom.pointSourceSelect.innerHTML = options.join("");
+}
+
+export function renderSources() {
+ dom.sourceList.innerHTML = "";
+
+ state.sources.forEach((source) => {
+ const card = document.createElement("div");
+ card.className = `list-item source-card ${state.selectedSourceId === source.id ? "selected" : ""}`;
+ card.innerHTML = `
+
+ ${source.name}
+ ${source.is_connected ? "ONLINE" : "OFFLINE"}
+
+ ${source.endpoint}
+
+ `;
+
+ card.addEventListener("click", () => {
+ selectSource(source.id).catch((error) => {
+ dom.statusText.textContent = error.message;
+ });
+ });
+
+ const actionRow = card.querySelector(".source-card-actions");
+
+ const editBtn = document.createElement("button");
+ editBtn.className = "secondary";
+ editBtn.textContent = "编辑";
+ editBtn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ dom.sourceId.value = source.id;
+ dom.sourceName.value = source.name || "";
+ dom.sourceEndpoint.value = source.endpoint || "";
+ dom.sourceEnabled.checked = !!source.enabled;
+ dom.sourceModal.classList.remove("hidden");
+ });
+
+ const reconnectBtn = document.createElement("button");
+ reconnectBtn.className = "secondary";
+ reconnectBtn.textContent = "重连";
+ reconnectBtn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ reconnectSource(source.id, source.name).catch((error) => {
+ dom.statusText.textContent = error.message;
+ });
+ });
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.className = "danger";
+ deleteBtn.textContent = "删除";
+ deleteBtn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ deleteSource(source.id).catch((error) => {
+ dom.statusText.textContent = error.message;
+ });
+ });
+
+ actionRow.append(editBtn, reconnectBtn, deleteBtn);
+ card.appendChild(actionRow);
+ dom.sourceList.appendChild(card);
+ });
+
+ renderPointSourceOptions();
+}
+
+export async function loadSources() {
+ state.sources = await apiFetch("/api/source");
+ if (state.selectedSourceId && !state.sources.some((item) => item.id === state.selectedSourceId)) {
+ state.selectedSourceId = null;
+ }
+ renderSources();
+ updatePointFilterSummary();
+}
+
+export async function selectSource(sourceId) {
+ state.selectedSourceId = state.selectedSourceId === sourceId ? null : sourceId;
+ state.selectedNodeIds.clear();
+ state.pointsPage = 1;
+ renderSources();
+ updatePointFilterSummary();
+ await loadPoints();
+}
+
+export async function saveSource(event) {
+ event.preventDefault();
+
+ const payload = {
+ name: dom.sourceName.value.trim(),
+ endpoint: dom.sourceEndpoint.value.trim(),
+ enabled: dom.sourceEnabled.checked,
+ };
+
+ const id = dom.sourceId.value;
+ await apiFetch(id ? `/api/source/${id}` : "/api/source", {
+ method: id ? "PUT" : "POST",
+ body: JSON.stringify(payload),
+ });
+
+ dom.sourceModal.classList.add("hidden");
+ dom.sourceForm.reset();
+ await loadSources();
+}
+
+export async function reconnectSource(sourceId, name) {
+ dom.statusText.textContent = `正在重连 ${name || "数据源"}...`;
+ await apiFetch(`/api/source/${sourceId}/reconnect`, { method: "POST" });
+ await loadSources();
+ dom.statusText.textContent = "就绪";
+}
+
+export async function deleteSource(sourceId) {
+ if (!window.confirm("确认删除该数据源?")) {
+ return;
+ }
+
+ await apiFetch(`/api/source/${sourceId}`, { method: "DELETE" });
+ if (state.selectedSourceId === sourceId) {
+ state.selectedSourceId = null;
+ }
+ await loadSources();
+ await loadPoints();
+}
diff --git a/web/feeder/js/state.js b/web/feeder/js/state.js
new file mode 100644
index 0000000..fad3909
--- /dev/null
+++ b/web/feeder/js/state.js
@@ -0,0 +1,29 @@
+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
new file mode 100644
index 0000000..dcd8708
--- /dev/null
+++ b/web/feeder/js/units.js
@@ -0,0 +1,324 @@
+import { apiFetch, withStatus } from "./api.js";
+import { dom } from "./dom.js";
+import { loadEvents } from "./events.js";
+import { loadEquipments, renderEquipments } from "./equipment.js";
+import { state } from "./state.js";
+
+function equipmentOf(item) {
+ return item && item.equipment ? item.equipment : item;
+}
+
+function equipmentCount(unitId) {
+ return state.equipments.filter((item) => {
+ const equipment = equipmentOf(item);
+ return equipment.unit_id === unitId;
+ }).length;
+}
+
+function boundEquipments(unitId) {
+ return state.equipments
+ .map(equipmentOf)
+ .filter((e) => e.unit_id === unitId);
+}
+
+export function renderUnitOptions(selected = "", target = dom.equipmentUnitId, includeEmpty = true) {
+ if (!target) {
+ return;
+ }
+
+ const options = [];
+ if (includeEmpty) {
+ options.push('');
+ }
+
+ state.units.forEach((unit) => {
+ const isSelected = unit.id === selected ? "selected" : "";
+ options.push(``);
+ });
+
+ target.innerHTML = options.join("");
+}
+
+export function resetUnitForm() {
+ dom.unitForm.reset();
+ dom.unitId.value = "";
+ dom.unitEnabled.checked = true;
+ dom.unitManualAck.checked = true;
+ dom.unitRunTimeSec.value = "10";
+ dom.unitStopTimeSec.value = "10";
+ dom.unitAccTimeSec.value = "20";
+ dom.unitBlTimeSec.value = "10";
+}
+
+function openUnitModal() {
+ dom.unitModal.classList.remove("hidden");
+}
+
+export function closeUnitModal() {
+ dom.unitModal.classList.add("hidden");
+}
+
+export function openCreateUnitModal() {
+ resetUnitForm();
+ openUnitModal();
+}
+
+function openEditUnitModal(unit) {
+ dom.unitId.value = unit.id || "";
+ dom.unitCode.value = unit.code || "";
+ dom.unitName.value = unit.name || "";
+ dom.unitDescription.value = unit.description || "";
+ dom.unitEnabled.checked = !!unit.enabled;
+ dom.unitRunTimeSec.value = String(unit.run_time_sec ?? 0);
+ dom.unitStopTimeSec.value = String(unit.stop_time_sec ?? 0);
+ dom.unitAccTimeSec.value = String(unit.acc_time_sec ?? 0);
+ dom.unitBlTimeSec.value = String(unit.bl_time_sec ?? 0);
+ dom.unitManualAck.checked = !!unit.require_manual_ack_after_fault;
+ openUnitModal();
+}
+
+async function selectUnit(unitId) {
+ state.selectedUnitId = state.selectedUnitId === unitId ? null : unitId;
+ renderUnits();
+ renderEquipments();
+ await loadEvents();
+}
+
+function runtimeBadge(runtime) {
+ if (!runtime) return 'OFFLINE';
+ if (runtime.comm_locked) return 'COMM ERR';
+ if (runtime.fault_locked) return 'FAULT';
+ const stateLabels = {
+ stopped: 'STOPPED',
+ running: 'RUNNING',
+ distributor_running: 'DIST RUN',
+ fault_locked: 'FAULT',
+ comm_locked: 'COMM ERR',
+ };
+ const stateCls = {
+ stopped: '',
+ running: 'online',
+ distributor_running: 'online',
+ fault_locked: 'danger',
+ comm_locked: 'offline',
+ };
+ const label = stateLabels[runtime.state] ?? runtime.state;
+ const cls = stateCls[runtime.state] ?? '';
+ return `${label}`;
+}
+
+function buildUnitCard(unit, mode) {
+ const card = document.createElement("div");
+ const selected = mode === "interactive" && state.selectedUnitId === unit.id;
+ card.className = `list-item unit-card ${selected ? "selected" : ""}`;
+ const runtime = state.runtimes.get(unit.id);
+
+ const bound = boundEquipments(unit.id);
+ const equipTags = bound.length
+ ? bound.map((e) => `${e.code}`).join("")
+ : '无设备';
+
+ card.innerHTML = `
+
+ ${unit.code}
+ ${runtimeBadge(runtime)}
+ ${unit.enabled ? "EN" : "DIS"}
+
+ ${unit.name}
+ 设备 ${bound.length} 台 | 累计 ${runtime ? Math.floor(runtime.display_acc_sec / 1000) : 0}s
+ 运行 ${unit.run_time_sec}s / 停止 ${unit.stop_time_sec}s / 累计 ${unit.acc_time_sec}s / 间隔 ${unit.bl_time_sec}s
+ ${mode === "config" ? `${equipTags}
` : ""}
+
+ `;
+
+ if (mode === "interactive") {
+ card.addEventListener("click", () => {
+ selectUnit(unit.id).catch((error) => {
+ dom.statusText.textContent = error.message;
+ });
+ });
+ }
+
+ const actions = card.querySelector(".unit-card-actions");
+
+ const editBtn = document.createElement("button");
+ editBtn.className = "secondary";
+ editBtn.textContent = "编辑";
+ editBtn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ openEditUnitModal(unit);
+ });
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.className = "danger";
+ deleteBtn.textContent = "删除";
+ deleteBtn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ deleteUnit(unit.id).catch((error) => {
+ dom.statusText.textContent = error.message;
+ });
+ });
+
+ actions.append(editBtn, deleteBtn);
+
+ if (mode === "config") {
+ const selectEquipBtn = document.createElement("button");
+ selectEquipBtn.className = "secondary";
+ selectEquipBtn.textContent = "选择设备";
+ selectEquipBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ openUnitEquipmentModal(unit);
+ });
+ actions.append(selectEquipBtn);
+ }
+
+ return card;
+}
+
+function renderToContainer(container, mode) {
+ if (!container) return;
+ container.innerHTML = "";
+
+ if (!state.units.length) {
+ container.innerHTML = '';
+ return;
+ }
+
+ state.units.forEach((unit) => {
+ container.appendChild(buildUnitCard(unit, mode));
+ });
+}
+
+export function renderUnits() {
+ renderToContainer(dom.unitList, "interactive");
+ renderToContainer(dom.unitConfigList, "config");
+}
+
+export async function loadUnits() {
+ const response = await apiFetch("/api/unit?page=1&page_size=-1");
+ state.units = response.data || [];
+ state.unitMap = new Map(state.units.map((unit) => [unit.id, unit]));
+
+ if (state.selectedUnitId && !state.unitMap.has(state.selectedUnitId)) {
+ state.selectedUnitId = null;
+ }
+
+ state.units.forEach((unit) => {
+ if (unit.runtime) state.runtimes.set(unit.id, unit.runtime);
+ });
+
+ renderUnits();
+ renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
+ document.dispatchEvent(new Event("units-loaded"));
+}
+
+export async function saveUnit(event) {
+ event.preventDefault();
+
+ const payload = {
+ code: dom.unitCode.value.trim(),
+ name: dom.unitName.value.trim(),
+ description: dom.unitDescription.value.trim() || null,
+ enabled: dom.unitEnabled.checked,
+ run_time_sec: Number(dom.unitRunTimeSec.value || 0),
+ stop_time_sec: Number(dom.unitStopTimeSec.value || 0),
+ acc_time_sec: Number(dom.unitAccTimeSec.value || 0),
+ bl_time_sec: Number(dom.unitBlTimeSec.value || 0),
+ require_manual_ack_after_fault: dom.unitManualAck.checked,
+ };
+
+ const id = dom.unitId.value;
+ await apiFetch(id ? `/api/unit/${id}` : "/api/unit", {
+ method: id ? "PUT" : "POST",
+ body: JSON.stringify(payload),
+ });
+
+ closeUnitModal();
+ await loadUnits();
+ renderEquipments();
+ await loadEvents();
+}
+
+export async function deleteUnit(unitId) {
+ if (!window.confirm("确认删除该单元?")) {
+ return;
+ }
+
+ await apiFetch(`/api/unit/${unitId}`, { method: "DELETE" });
+ if (state.selectedUnitId === unitId) {
+ state.selectedUnitId = null;
+ }
+ closeUnitModal();
+ await loadUnits();
+ renderEquipments();
+ await loadEvents();
+}
+
+// ── Unit Equipment Selection Modal ──
+
+let _unitEquipmentTargetId = null;
+const _unitEquipmentSelected = new Set();
+
+function openUnitEquipmentModal(unit) {
+ _unitEquipmentTargetId = unit.id;
+ _unitEquipmentSelected.clear();
+
+ const allEquipments = state.equipments.map(equipmentOf);
+ const bound = new Set(boundEquipments(unit.id).map((e) => e.id));
+ bound.forEach((id) => _unitEquipmentSelected.add(id));
+
+ dom.unitEquipmentList.innerHTML = "";
+ dom.unitEquipmentList.className = "unit-equip-grid";
+ allEquipments.forEach((e) => {
+ const item = document.createElement("label");
+ item.className = "unit-equip-item";
+ const checked = bound.has(e.id) ? "checked" : "";
+ item.innerHTML = `${e.code}`;
+ item.title = e.name;
+ item.querySelector("input").addEventListener("change", (ev) => {
+ if (ev.target.checked) _unitEquipmentSelected.add(e.id);
+ else _unitEquipmentSelected.delete(e.id);
+ });
+ dom.unitEquipmentList.appendChild(item);
+ });
+
+ dom.unitEquipmentModal.classList.remove("hidden");
+}
+
+function closeUnitEquipmentModal() {
+ dom.unitEquipmentModal.classList.add("hidden");
+ _unitEquipmentTargetId = null;
+}
+
+async function confirmUnitEquipment() {
+ if (!_unitEquipmentTargetId) return;
+
+ const previouslyBound = new Set(boundEquipments(_unitEquipmentTargetId).map((e) => e.id));
+
+ const toBind = [..._unitEquipmentSelected].filter((id) => !previouslyBound.has(id));
+ const toUnbind = [...previouslyBound].filter((id) => !_unitEquipmentSelected.has(id));
+
+ if (toBind.length > 0) {
+ await apiFetch("/api/equipment/batch/set-unit", {
+ method: "PUT",
+ body: JSON.stringify({ equipment_ids: toBind, unit_id: _unitEquipmentTargetId }),
+ });
+ }
+
+ if (toUnbind.length > 0) {
+ await apiFetch("/api/equipment/batch/set-unit", {
+ method: "PUT",
+ body: JSON.stringify({ equipment_ids: toUnbind, unit_id: null }),
+ });
+ }
+
+ closeUnitEquipmentModal();
+ await loadEquipments();
+ await loadUnits();
+}
+
+export function bindUnitEquipmentModalEvents() {
+ dom.closeUnitEquipmentModalBtn.addEventListener("click", closeUnitEquipmentModal);
+ dom.cancelUnitEquipmentBtn.addEventListener("click", closeUnitEquipmentModal);
+ dom.confirmUnitEquipmentBtn.addEventListener("click", () => withStatus(confirmUnitEquipment()));
+}
diff --git a/web/ops/html/topbar.html b/web/ops/html/topbar.html
new file mode 100644
index 0000000..13ab23e
--- /dev/null
+++ b/web/ops/html/topbar.html
@@ -0,0 +1,9 @@
+
diff --git a/web/ops/index.html b/web/ops/index.html
new file mode 100644
index 0000000..852278d
--- /dev/null
+++ b/web/ops/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ 运转系统
+
+
+
+
+
+
+ 运转系统页面开发中
+
+
+
+
+
diff --git a/web/ops/js/app.js b/web/ops/js/app.js
new file mode 100644
index 0000000..74140f4
--- /dev/null
+++ b/web/ops/js/app.js
@@ -0,0 +1,5 @@
+function bootstrap() {
+ console.log("Operation system app initialized");
+}
+
+bootstrap();
diff --git a/web/ops/js/index.js b/web/ops/js/index.js
new file mode 100644
index 0000000..727497c
--- /dev/null
+++ b/web/ops/js/index.js
@@ -0,0 +1,20 @@
+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)}`;
+});