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 @@ +
+
+

点位

+
+ + 1 / 1 + +
+
+
+ +
当前筛选: 全部点位
+
已选中 0 个点位
+ + + +
+
+ + + + + + + + + + + + + +
名称质量设备/角色更新时间
+
+
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()} + +
+
${equipment ? equipment.name : '未绑定'}
+
${point.signal_role || "--"}
+
+ + ${monitor?.timestamp || "--"} + + `; + + const selectCell = tr.children[0]; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.dataset.pointSelect = "true"; + checkbox.checked = state.selectedPointIds.has(point.id); + checkbox.addEventListener("click", (event) => event.stopPropagation()); + checkbox.addEventListener("change", () => setPointSelected(point.id, checkbox.checked)); + selectCell.appendChild(checkbox); + + const actionCell = tr.lastElementChild; + actionCell.className = "point-actions"; + const editBtn = document.createElement("button"); + editBtn.className = "secondary"; + editBtn.textContent = "编辑"; + editBtn.addEventListener("click", (event) => { + event.stopPropagation(); + openPointBinding(point); + }); + + const deleteBtn = document.createElement("button"); + deleteBtn.className = "danger"; + deleteBtn.textContent = "删除"; + deleteBtn.addEventListener("click", (event) => { + event.stopPropagation(); + deletePoint(point.id).catch((error) => { + dom.statusText.textContent = error.message; + }); + }); + + actionCell.append(editBtn, deleteBtn); + dom.pointList.appendChild(tr); + + state.pointEls.set(point.id, { + row: tr, + value: tr.querySelector(".point-value"), + quality: tr.querySelector(".badge"), + time: tr.querySelector("td:nth-child(6) .muted"), + }); + }); + + const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize)); + dom.pointsPageInfo.textContent = `${state.pointsPage} / ${totalPages}`; + const pageCheckboxes = dom.pointList.querySelectorAll('input[data-point-select="true"]'); + dom.toggleAllPoints.checked = + pageCheckboxes.length > 0 && Array.from(pageCheckboxes).every((input) => input.checked); + updateSelectedPointSummary(); + updatePointFilterSummary(); +} + +export function openPointBinding(point) { + dom.bindingPointId.value = point.id; + dom.bindingPointName.value = point.name || ""; + dom.bindingPointName.disabled = false; + const modalTitle = dom.pointBindingModal.querySelector("h3"); + if (modalTitle) { + modalTitle.textContent = "编辑点位"; + } + if (dom.clearPointBindingBtn) { + dom.clearPointBindingBtn.textContent = "清除设备"; + } + const saveButton = dom.pointBindingForm?.querySelector('button[type="submit"]'); + if (saveButton) { + saveButton.textContent = "保存"; + } + renderBindingEquipmentOptions(point.equipment_id || ""); + dom.bindingSignalRole.innerHTML = renderRoleOptions(point.signal_role || ""); + dom.pointBindingModal.classList.remove("hidden"); +} + +export async function savePointBinding(event) { + event.preventDefault(); + + await apiFetch(`/api/point/${dom.bindingPointId.value}`, { + method: "PUT", + body: JSON.stringify({ + name: dom.bindingPointName.value.trim() || null, + equipment_id: dom.bindingEquipmentId.value || null, + signal_role: dom.bindingSignalRole.value || null, + }), + }); + + dom.pointBindingModal.classList.add("hidden"); + await loadEquipments(); + await loadPoints(); +} + +export function openBatchBinding() { + if (!state.selectedPointIds.size) { + return; + } + renderBatchBindingDefaults(); + updateSelectedPointSummary(); + dom.batchBindingModal.classList.remove("hidden"); +} + +export async function saveBatchBinding(event) { + event.preventDefault(); + + if (!state.selectedPointIds.size) { + return; + } + + await apiFetch("/api/point/batch/set-equipment", { + method: "PUT", + body: JSON.stringify({ + point_ids: Array.from(state.selectedPointIds), + equipment_id: dom.batchBindingEquipmentId.value || null, + signal_role: dom.batchBindingSignalRole.value || null, + }), + }); + + dom.batchBindingModal.classList.add("hidden"); + clearSelectedPoints(); + await loadEquipments(); + await loadPoints(); +} + +export async function clearBatchBinding() { + if (!state.selectedPointIds.size) { + return; + } + + await apiFetch("/api/point/batch/set-equipment", { + method: "PUT", + body: JSON.stringify({ + point_ids: Array.from(state.selectedPointIds), + equipment_id: null, + signal_role: null, + }), + }); + + dom.batchBindingModal.classList.add("hidden"); + clearSelectedPoints(); + await loadEquipments(); + await loadPoints(); +} + +export async function deletePoint(pointId) { + if (!window.confirm("确认删除该点位?")) { + return; + } + + await apiFetch(`/api/point/${pointId}`, { method: "DELETE" }); + state.selectedPointIds.delete(pointId); + await loadPoints(); +} diff --git a/web/feeder/js/roles.js b/web/feeder/js/roles.js 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)}
`; +});