fix(web): restore all web UI files accidentally deleted
The web/core, web/feeder, and web/ops directories were mistakenly
committed as deletions. Restore from f8757a7.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6102ed712f
commit
368faf290a
|
|
@ -0,0 +1,15 @@
|
|||
<div class="drawer-backdrop hidden" id="apiDocDrawer">
|
||||
<aside class="drawer api-drawer" role="dialog" aria-modal="true" aria-labelledby="apiDocTitle">
|
||||
<div class="drawer-head">
|
||||
<h3 id="apiDocTitle">API.md</h3>
|
||||
<button type="button" class="secondary" id="closeApiDoc">关闭</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<aside class="doc-toc">
|
||||
<div class="doc-toc-title">目录</div>
|
||||
<div class="doc-toc-list" id="apiDocToc">加载中...</div>
|
||||
</aside>
|
||||
<div class="markdown-doc" id="apiDocContent">加载中...</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<section class="panel bottom-right">
|
||||
<div class="panel-head">
|
||||
<h2 id="chartTitle">点位曲线</h2>
|
||||
<button class="secondary" id="refreshChart">刷新</button>
|
||||
</div>
|
||||
<div class="chart-panel">
|
||||
<div class="muted" id="chartSummary">点击上方点位表中的一行查看曲线</div>
|
||||
<canvas id="chartCanvas" class="chart-canvas" width="820" height="320"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<section class="panel top-left">
|
||||
<div class="panel-head">
|
||||
<h2>设备</h2>
|
||||
<button type="button" id="newEquipmentBtn">+ 新增</button>
|
||||
</div>
|
||||
<div class="toolbar equipment-toolbar">
|
||||
<input id="equipmentKeyword" placeholder="搜索编码或名称" />
|
||||
<button type="button" class="secondary" id="refreshEquipmentBtn">刷新</button>
|
||||
</div>
|
||||
<div class="list equipment-list" id="equipmentList"></div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<section class="panel bottom-mid">
|
||||
<div class="panel-head">
|
||||
<h2>实时日志</h2>
|
||||
</div>
|
||||
<div class="log" id="logView"></div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<section class="panel ops-bottom">
|
||||
<div class="panel-head">
|
||||
<h2>系统事件</h2>
|
||||
<button type="button" class="secondary" id="refreshEventBtn">刷新</button>
|
||||
</div>
|
||||
<div class="list event-list" id="eventList"></div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
<div class="modal hidden" id="equipmentModal">
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>设备配置</h3>
|
||||
<button class="secondary" id="closeEquipmentModal">X</button>
|
||||
</div>
|
||||
<form id="equipmentForm" class="form">
|
||||
<input type="hidden" id="equipmentId" />
|
||||
<label>
|
||||
编码
|
||||
<input id="equipmentCode" required />
|
||||
</label>
|
||||
<label>
|
||||
名称
|
||||
<input id="equipmentName" required />
|
||||
</label>
|
||||
<label>
|
||||
类型
|
||||
<select id="equipmentKind"></select>
|
||||
</label>
|
||||
<label>
|
||||
说明
|
||||
<input id="equipmentDescription" />
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" id="equipmentReset">清空</button>
|
||||
<button type="submit" id="equipmentSubmit">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden" id="pointModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-head">
|
||||
<h3>选择节点创建点位</h3>
|
||||
<button class="secondary" id="closeModal">X</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<select id="pointSourceSelect"></select>
|
||||
<div class="muted" id="pointSourceNodeCount">节点: 0</div>
|
||||
<button id="browseNodes">加载节点</button>
|
||||
<button class="secondary" id="refreshTree">刷新树</button>
|
||||
</div>
|
||||
<div class="tree" id="nodeTree"></div>
|
||||
<div class="modal-foot">
|
||||
<div class="muted" id="selectedCount">已选中 0 个节点</div>
|
||||
<button id="createPoints">创建设备点位</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden" id="sourceModal">
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>Source 配置</h3>
|
||||
<button class="secondary" id="closeSourceModal">X</button>
|
||||
</div>
|
||||
<form id="sourceForm" class="form">
|
||||
<input type="hidden" id="sourceId" />
|
||||
<label>
|
||||
名称
|
||||
<input id="sourceName" required />
|
||||
</label>
|
||||
<label>
|
||||
Endpoint
|
||||
<input id="sourceEndpoint" placeholder="opc.tcp://host:port" required />
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" id="sourceEnabled" checked />
|
||||
<span>启用</span>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" id="sourceReset">清空</button>
|
||||
<button type="submit" id="sourceSubmit">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden" id="pointBindingModal">
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>绑定点位</h3>
|
||||
<button class="secondary" id="closePointBindingModal">X</button>
|
||||
</div>
|
||||
<form id="pointBindingForm" class="form">
|
||||
<input type="hidden" id="bindingPointId" />
|
||||
<label>
|
||||
点位
|
||||
<input id="bindingPointName" disabled />
|
||||
</label>
|
||||
<label>
|
||||
设备
|
||||
<select id="bindingEquipmentId"></select>
|
||||
</label>
|
||||
<label>
|
||||
角色模板
|
||||
<select id="bindingSignalRole"></select>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" id="clearPointBinding">清空绑定</button>
|
||||
<button type="submit" id="savePointBinding">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden" id="batchBindingModal">
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>批量绑定点位</h3>
|
||||
<button class="secondary" id="closeBatchBindingModal">X</button>
|
||||
</div>
|
||||
<form id="batchBindingForm" class="form">
|
||||
<div class="muted" id="batchBindingSummary">已选中 0 个点位</div>
|
||||
<label>
|
||||
设备
|
||||
<select id="batchBindingEquipmentId"></select>
|
||||
</label>
|
||||
<label>
|
||||
角色模板
|
||||
<select id="batchBindingSignalRole"></select>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" id="clearBatchBinding">清空设备和角色</button>
|
||||
<button type="submit" id="saveBatchBinding">批量保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<section class="panel top-right">
|
||||
<div class="panel-head">
|
||||
<h2>点位</h2>
|
||||
<div class="pager">
|
||||
<button class="secondary" id="prevPoints" title="上一页">‹</button>
|
||||
<span id="pointsPageInfo">1 / 1</span>
|
||||
<button class="secondary" id="nextPoints" title="下一页">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar point-batch-toolbar">
|
||||
<label class="check-row compact-check">
|
||||
<input type="checkbox" id="toggleAllPoints" />
|
||||
<span>本页全选</span>
|
||||
</label>
|
||||
<div class="muted" id="pointFilterSummary">当前筛选: 全部点位</div>
|
||||
<div class="muted" id="selectedPointCount">已选中 0 个点位</div>
|
||||
<button type="button" class="secondary" id="openPointModal">选入节点</button>
|
||||
<button type="button" class="secondary" id="openBatchBinding">批量绑定设备</button>
|
||||
<button type="button" class="secondary" id="clearSelectedPoints">清空选择</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:6%"></th>
|
||||
<th style="width:22%">名称</th>
|
||||
<th style="width:16%">值</th>
|
||||
<th style="width:10%">质量</th>
|
||||
<th style="width:18%">设备/角色</th>
|
||||
<th style="width:21%">更新时间</th>
|
||||
<th style="width:120px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pointList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<section class="panel bottom-left">
|
||||
<div class="panel-head">
|
||||
<h2>数据源</h2>
|
||||
<button type="button" id="openSourceForm">+ 新增</button>
|
||||
</div>
|
||||
<div class="source-panels" id="sourceList"></div>
|
||||
</section>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,135 @@
|
|||
<div class="modal hidden" id="equipmentModal">
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>设备配置</h3>
|
||||
<button class="secondary" id="closeEquipmentModal">X</button>
|
||||
</div>
|
||||
<form id="equipmentForm" class="form">
|
||||
<input type="hidden" id="equipmentId" />
|
||||
<label>
|
||||
所属单元
|
||||
<select id="equipmentUnitId"></select>
|
||||
</label>
|
||||
<label>
|
||||
编码
|
||||
<input id="equipmentCode" required />
|
||||
</label>
|
||||
<label>
|
||||
名称
|
||||
<input id="equipmentName" required />
|
||||
</label>
|
||||
<label>
|
||||
类型
|
||||
<select id="equipmentKind"></select>
|
||||
</label>
|
||||
<label>
|
||||
说明
|
||||
<input id="equipmentDescription" />
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" id="equipmentReset">清空</button>
|
||||
<button type="submit" id="equipmentSubmit">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden" id="pointModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-head">
|
||||
<h3>选择节点创建点位</h3>
|
||||
<button class="secondary" id="closeModal">X</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<select id="pointSourceSelect"></select>
|
||||
<div class="muted" id="pointSourceNodeCount">节点: 0</div>
|
||||
<button id="browseNodes">加载节点</button>
|
||||
<button class="secondary" id="refreshTree">刷新树</button>
|
||||
</div>
|
||||
<div class="tree" id="nodeTree"></div>
|
||||
<div class="modal-foot">
|
||||
<div class="muted" id="selectedCount">已选中 0 个节点</div>
|
||||
<button id="createPoints">创建设备点位</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden" id="sourceModal">
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>Source 配置</h3>
|
||||
<button class="secondary" id="closeSourceModal">X</button>
|
||||
</div>
|
||||
<form id="sourceForm" class="form">
|
||||
<input type="hidden" id="sourceId" />
|
||||
<label>
|
||||
名称
|
||||
<input id="sourceName" required />
|
||||
</label>
|
||||
<label>
|
||||
Endpoint
|
||||
<input id="sourceEndpoint" placeholder="opc.tcp://host:port" required />
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" id="sourceEnabled" checked />
|
||||
<span>启用</span>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" id="sourceReset">清空</button>
|
||||
<button type="submit" id="sourceSubmit">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden" id="pointBindingModal">
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>绑定点位</h3>
|
||||
<button class="secondary" id="closePointBindingModal">X</button>
|
||||
</div>
|
||||
<form id="pointBindingForm" class="form">
|
||||
<input type="hidden" id="bindingPointId" />
|
||||
<label>
|
||||
点位
|
||||
<input id="bindingPointName" disabled />
|
||||
</label>
|
||||
<label>
|
||||
设备
|
||||
<select id="bindingEquipmentId"></select>
|
||||
</label>
|
||||
<label>
|
||||
角色模板
|
||||
<select id="bindingSignalRole"></select>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" id="clearPointBinding">清空绑定</button>
|
||||
<button type="submit" id="savePointBinding">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden" id="batchBindingModal">
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>批量绑定点位</h3>
|
||||
<button class="secondary" id="closeBatchBindingModal">X</button>
|
||||
</div>
|
||||
<form id="batchBindingForm" class="form">
|
||||
<div class="muted" id="batchBindingSummary">已选中 0 个点位</div>
|
||||
<label>
|
||||
设备
|
||||
<select id="batchBindingEquipmentId"></select>
|
||||
</label>
|
||||
<label>
|
||||
角色模板
|
||||
<select id="batchBindingSignalRole"></select>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" id="clearBatchBinding">清空设备和角色</button>
|
||||
<button type="submit" id="saveBatchBinding">批量保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<section class="panel ops-main">
|
||||
<div class="ops-layout">
|
||||
<aside class="ops-unit-sidebar">
|
||||
<div class="panel-head">
|
||||
<h2>控制单元</h2>
|
||||
<div class="ops-batch-actions">
|
||||
<button type="button" class="secondary" id="batchStartAutoBtn" title="启动所有未锁定单元的自动控制">全部启动</button>
|
||||
<button type="button" class="danger" id="batchStopAutoBtn" title="停止所有单元的自动控制">全部停止</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list ops-unit-list" id="opsUnitList"></div>
|
||||
</aside>
|
||||
<div class="ops-equipment-area" id="opsEquipmentArea">
|
||||
<div class="muted ops-placeholder">← 选择控制单元</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<header class="topbar">
|
||||
<div class="title">投煤器布料机控制系统</div>
|
||||
<div class="tab-bar">
|
||||
<button type="button" class="tab-btn active" id="tabOps">运维</button>
|
||||
<button type="button" class="tab-btn" id="tabAppConfig">应用配置</button>
|
||||
<button type="button" class="tab-btn" id="tabConfig">平台配置</button>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button type="button" class="secondary" id="openReadmeDoc">README.md</button>
|
||||
<button type="button" class="secondary" id="openApiDoc">API.md</button>
|
||||
<div class="status" id="statusText">
|
||||
<span class="ws-dot" id="wsDot"></span>
|
||||
<span id="wsLabel">连接中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<div class="modal hidden" id="unitModal">
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>控制单元配置</h3>
|
||||
<button class="secondary" id="closeUnitModal">X</button>
|
||||
</div>
|
||||
<form id="unitForm" class="form">
|
||||
<input type="hidden" id="unitId" />
|
||||
<label>
|
||||
编码
|
||||
<input id="unitCode" required />
|
||||
</label>
|
||||
<label>
|
||||
名称
|
||||
<input id="unitName" required />
|
||||
</label>
|
||||
<label>
|
||||
说明
|
||||
<input id="unitDescription" />
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" id="unitEnabled" checked />
|
||||
<span>启用</span>
|
||||
</label>
|
||||
<label>
|
||||
投煤运行时间(秒)
|
||||
<input id="unitRunTimeSec" type="number" min="0" value="0" />
|
||||
</label>
|
||||
<label>
|
||||
投煤停止时间(秒)
|
||||
<input id="unitStopTimeSec" type="number" min="0" value="0" />
|
||||
</label>
|
||||
<label>
|
||||
投煤累计阈值(秒)
|
||||
<input id="unitAccTimeSec" type="number" min="0" value="0" />
|
||||
</label>
|
||||
<label>
|
||||
布料机运行时间(秒)
|
||||
<input id="unitBlTimeSec" type="number" min="0" value="0" />
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" id="unitManualAck" checked />
|
||||
<span>故障恢复后需人工确认</span>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" id="unitReset">清空</button>
|
||||
<button type="submit" id="unitSubmit">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<section class="panel app-config-main">
|
||||
<div class="panel-head">
|
||||
<h2>控制单元配置</h2>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="secondary" id="refreshUnitBtn2">刷新</button>
|
||||
<button type="button" id="newUnitBtn2">+ 新增</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list unit-config-list" id="unitConfigList"></div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PLC Control</title>
|
||||
<link rel="stylesheet" href="/ui/styles.css?v=20260325f" />
|
||||
</head>
|
||||
<body>
|
||||
<div data-partial="/ui/html/topbar.html"></div>
|
||||
|
||||
<main class="grid-ops">
|
||||
<div data-partial="/ui/html/ops-panel.html"></div>
|
||||
<div data-partial="/ui/html/equipment-panel.html"></div>
|
||||
<div data-partial="/ui/html/points-panel.html"></div>
|
||||
<div data-partial="/ui/html/source-panel.html"></div>
|
||||
<div data-partial="/ui/html/log-stream-panel.html"></div>
|
||||
<div data-partial="/ui/html/chart-panel.html"></div>
|
||||
<div data-partial="/ui/html/unit-panel.html"></div>
|
||||
<div data-partial="/ui/html/logs-panel.html"></div>
|
||||
</main>
|
||||
|
||||
<div data-partial="/ui/html/modals.html"></div>
|
||||
<div data-partial="/ui/html/unit-modal.html"></div>
|
||||
|
||||
<div class="modal hidden" id="unitEquipmentModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-head">
|
||||
<h3>选择设备</h3>
|
||||
<button class="secondary" id="closeUnitEquipmentModal">X</button>
|
||||
</div>
|
||||
<div id="unitEquipmentList" style="max-height:400px;overflow:auto"></div>
|
||||
<div class="form-actions" style="padding:10px">
|
||||
<button type="button" class="secondary" id="cancelUnitEquipment">取消</button>
|
||||
<button type="button" id="confirmUnitEquipment">确认绑定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-partial="/ui/html/api-doc-drawer.html"></div>
|
||||
|
||||
<script type="module" src="/ui/js/index.js?v=20260325f"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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 = `
|
||||
<span class="toast-icon">${ICONS[level] ?? "i"}</span>
|
||||
<div class="toast-body">
|
||||
<div class="toast-title">${title}</div>
|
||||
${message ? `<div class="toast-message">${message}</div>` : ""}
|
||||
</div>`;
|
||||
|
||||
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 || "请求失败");
|
||||
});
|
||||
}
|
||||
|
|
@ -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 = '<div class="muted">点击"加载节点"获取节点树</div>';
|
||||
dom.pointSourceNodeCount.textContent = "节点: 0";
|
||||
});
|
||||
dom.browseNodesBtn.addEventListener("click", () => withStatus(browseAndLoadTree()));
|
||||
dom.refreshTreeBtn.addEventListener("click", () => withStatus(loadTree()));
|
||||
dom.createPointsBtn.addEventListener("click", () => withStatus(createPoints()));
|
||||
dom.closeModalBtn.addEventListener("click", () => dom.pointModal.classList.add("hidden"));
|
||||
|
||||
dom.openSourceFormBtn.addEventListener("click", () => {
|
||||
dom.sourceForm.reset();
|
||||
dom.sourceId.value = "";
|
||||
dom.sourceModal.classList.remove("hidden");
|
||||
});
|
||||
dom.closeSourceModalBtn.addEventListener("click", () => dom.sourceModal.classList.add("hidden"));
|
||||
|
||||
dom.clearPointBindingBtn.addEventListener("click", () => withStatus(clearPointBinding()));
|
||||
dom.closePointBindingModalBtn.addEventListener("click", () => {
|
||||
dom.pointBindingModal.classList.add("hidden");
|
||||
});
|
||||
|
||||
dom.openBatchBindingBtn.addEventListener("click", openBatchBinding);
|
||||
dom.clearSelectedPointsBtn.addEventListener("click", clearSelectedPoints);
|
||||
dom.closeBatchBindingModalBtn.addEventListener("click", () => {
|
||||
dom.batchBindingModal.classList.add("hidden");
|
||||
});
|
||||
dom.clearBatchBindingBtn.addEventListener("click", () => withStatus(clearBatchBinding()));
|
||||
|
||||
dom.toggleAllPoints.addEventListener("change", () => {
|
||||
const checked = dom.toggleAllPoints.checked;
|
||||
dom.pointList.querySelectorAll('input[data-point-select="true"]').forEach((input) => {
|
||||
input.checked = checked;
|
||||
input.dispatchEvent(new Event("change"));
|
||||
});
|
||||
});
|
||||
|
||||
dom.openReadmeDocBtn.addEventListener("click", () => withStatus(openReadmeDrawer()));
|
||||
dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer()));
|
||||
dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer);
|
||||
dom.refreshEventBtn.addEventListener("click", () => withStatus(loadEvents()));
|
||||
|
||||
dom.refreshChartBtn.addEventListener("click", () => {
|
||||
if (!state.chartPointId) {
|
||||
return;
|
||||
}
|
||||
withStatus(openChart(state.chartPointId, state.chartPointName));
|
||||
});
|
||||
|
||||
dom.prevPointsBtn.addEventListener("click", () => {
|
||||
if (state.pointsPage > 1) {
|
||||
state.pointsPage -= 1;
|
||||
withStatus(loadPoints());
|
||||
}
|
||||
});
|
||||
|
||||
dom.nextPointsBtn.addEventListener("click", () => {
|
||||
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
||||
if (state.pointsPage < totalPages) {
|
||||
state.pointsPage += 1;
|
||||
withStatus(loadPoints());
|
||||
}
|
||||
});
|
||||
|
||||
dom.equipmentKeyword.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
withStatus(loadEquipments());
|
||||
}
|
||||
});
|
||||
|
||||
dom.tabOps.addEventListener("click", () => switchView("ops"));
|
||||
dom.tabAppConfig.addEventListener("click", () => switchView("app-config"));
|
||||
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();
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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(`<p>${escapeHtml(paragraph.join(" "))}</p>`);
|
||||
paragraph = [];
|
||||
};
|
||||
|
||||
const flushCode = () => {
|
||||
if (!codeBuffer.length) {
|
||||
return;
|
||||
}
|
||||
blocks.push(`<pre><code>${escapeHtml(codeBuffer.join("\n"))}</code></pre>`);
|
||||
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(`<h${level} id="${id}">${escapeHtml(textValue)}</h${level}>`);
|
||||
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 || `<p>${emptyMessage}</p>`;
|
||||
dom.apiDocToc.innerHTML = headings.length
|
||||
? headings
|
||||
.map(
|
||||
(item) =>
|
||||
`<a class="doc-toc-item level-${item.level}" href="#${item.id}">${escapeHtml(item.text)}</a>`,
|
||||
)
|
||||
.join("")
|
||||
: "<div class=\"muted\">未解析到标题</div>";
|
||||
|
||||
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");
|
||||
}
|
||||
|
|
@ -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"),
|
||||
};
|
||||
|
|
@ -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 = ['<option value="">未绑定单元</option>'];
|
||||
state.units.forEach((unit) => {
|
||||
const isSelected = unit.id === selected ? "selected" : "";
|
||||
options.push(`<option value="${unit.id}" ${isSelected}>${unit.code} / ${unit.name}</option>`);
|
||||
});
|
||||
target.innerHTML = options.join("");
|
||||
}
|
||||
|
||||
export function renderBindingEquipmentOptions(selected = "", target = dom.bindingEquipmentId) {
|
||||
const options = ['<option value="">未绑定</option>'];
|
||||
filteredEquipments().forEach((item) => {
|
||||
const equipment = equipmentOf(item);
|
||||
const isSelected = equipment.id === selected ? "selected" : "";
|
||||
options.push(
|
||||
`<option value="${equipment.id}" ${isSelected}>${equipment.code} / ${equipment.name}</option>`,
|
||||
);
|
||||
});
|
||||
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 = '<div class="list-item"><div class="muted">暂无设备</div></div>';
|
||||
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 = `
|
||||
<div class="row">
|
||||
<strong>${equipment.code}</strong>
|
||||
<span class="badge">${item.point_count ?? 0} pts</span>
|
||||
</div>
|
||||
<div>${equipment.name}</div>
|
||||
<div class="muted">${equipment.kind || "未分类"}</div>
|
||||
<div class="muted">单元: ${currentUnitLabel(equipment.unit_id)}</div>
|
||||
<div class="row equipment-card-actions"></div>
|
||||
`;
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
@ -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 = `<span class="badge event-badge level-${level}">${level.toUpperCase()}</span><span class="muted event-time">${formatTime(item.created_at)}</span><span class="event-type">${item.event_type}</span><span class="event-message">${item.message}</span>`;
|
||||
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 = '<div class="list-item"><div class="muted">暂无事件</div></div>';
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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 = `<pre>${error.message || String(error)}</pre>`;
|
||||
});
|
||||
|
|
@ -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 = [
|
||||
`<span class="level">${escapeHtml(levelRaw || "LOG")}</span>`,
|
||||
parsed.timestamp ? `<span class="muted"> ${escapeHtml(parsed.timestamp)}</span>` : "",
|
||||
parsed.target ? `<span class="muted"> ${escapeHtml(parsed.target)}</span>` : "",
|
||||
`<span class="message">${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}</span>`,
|
||||
].join("");
|
||||
}
|
||||
dom.logView.appendChild(div);
|
||||
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
|
||||
}
|
||||
|
||||
function appendLogDivider(text) {
|
||||
if (!dom.logView) return;
|
||||
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
|
||||
const div = document.createElement("div");
|
||||
div.className = "log-line muted";
|
||||
div.textContent = text;
|
||||
dom.logView.appendChild(div);
|
||||
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
|
||||
}
|
||||
|
||||
export function startLogs() {
|
||||
if (state.logSource) return;
|
||||
let currentLogFile = null;
|
||||
state.logSource = new EventSource("/api/logs/stream");
|
||||
state.logSource.addEventListener("log", (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.reset && data.file && data.file !== currentLogFile) {
|
||||
appendLogDivider(`[log switched to ${data.file}]`);
|
||||
}
|
||||
currentLogFile = data.file || currentLogFile;
|
||||
(data.lines || []).forEach(appendLog);
|
||||
});
|
||||
state.logSource.addEventListener("error", () => appendLog("[log stream error]"));
|
||||
}
|
||||
|
||||
export function stopLogs() {
|
||||
if (state.logSource) {
|
||||
state.logSource.close();
|
||||
state.logSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -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 '<span class="badge offline">OFFLINE</span>';
|
||||
if (runtime.comm_locked) return '<span class="badge offline">COMM ERR</span>';
|
||||
if (runtime.fault_locked) return '<span class="badge danger">FAULT</span>';
|
||||
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 `<span class="badge ${cls[runtime.state] ?? ""}">${labels[runtime.state] ?? runtime.state}</span>`;
|
||||
}
|
||||
|
||||
export function renderOpsUnits() {
|
||||
if (!dom.opsUnitList) return;
|
||||
dom.opsUnitList.innerHTML = "";
|
||||
|
||||
if (!state.units.length) {
|
||||
dom.opsUnitList.innerHTML = '<div class="muted" style="padding:12px">暂无控制单元</div>';
|
||||
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 = `
|
||||
<div class="ops-unit-item-name">${unit.code} / ${unit.name}</div>
|
||||
<div class="ops-unit-item-meta">
|
||||
${runtimeBadge(runtime)}
|
||||
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
|
||||
${runtime ? `<span class="muted">Acc ${Math.floor(runtime.display_acc_sec / 1000)}s</span>` : ""}
|
||||
</div>
|
||||
<div class="ops-unit-item-actions"></div>
|
||||
`;
|
||||
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 = '<div class="muted ops-placeholder">该单元下暂无设备</div>';
|
||||
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 `<span class="sig-pill sig-warn" data-ops-dot="${point.point_id}" data-ops-role="${role}">${ROLE_LABELS[role] || role}</span>`;
|
||||
}).join("");
|
||||
|
||||
const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor";
|
||||
const unitId = eq.unit_id ?? null;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="ops-eq-card-head">
|
||||
<strong title="${eq.name}">${eq.code}</strong>
|
||||
<span class="badge">${eq.kind || "--"}</span>
|
||||
</div>
|
||||
<div class="ops-signal-rows">${signalRowsHtml || '<span class="muted" style="font-size:11px;padding:2px 0">无绑定信号</span>'}</div>
|
||||
${canControl ? `<div class="ops-eq-card-actions" data-unit-id="${unitId || ""}"></div>` : ""}
|
||||
`;
|
||||
|
||||
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());
|
||||
}
|
||||
|
|
@ -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 = '<div class="muted">选择数据源并加载节点</div>';
|
||||
dom.pointSourceNodeCount.textContent = "节点: 0";
|
||||
state.selectedNodeIds.clear();
|
||||
renderSelectedNodes();
|
||||
}
|
||||
|
||||
export async function loadTree() {
|
||||
const sourceId = dom.pointSourceSelect.value || state.selectedSourceId;
|
||||
if (!sourceId) {
|
||||
dom.nodeTree.innerHTML = '<div class="muted">请选择数据源</div>';
|
||||
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 = '<tr><td colspan="7" class="empty-state">暂无点位</td></tr>';
|
||||
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 = `
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="point-name">${point.name}</div>
|
||||
<div class="point-id">${point.node_id}</div>
|
||||
</td>
|
||||
<td><span class="point-value">${formatValue(monitor)}</span></td>
|
||||
<td><span class="badge quality-${(monitor?.quality || "unknown").toLowerCase()}">${(monitor?.quality || "unknown").toUpperCase()}</span></td>
|
||||
<td>
|
||||
<div class="point-meta">
|
||||
<div>${equipment ? equipment.name : '<span class="muted">未绑定</span>'}</div>
|
||||
<div class="point-role">${point.signal_role || "--"}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="muted">${monitor?.timestamp || "--"}</span></td>
|
||||
<td></td>
|
||||
`;
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
@ -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 `<option value="${item.value}" ${isSelected}>${item.label}</option>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
export function renderEquipmentKindOptions(selected = "") {
|
||||
return EQUIPMENT_KIND_OPTIONS.map((item) => {
|
||||
const isSelected = item.value === selected ? "selected" : "";
|
||||
return `<option value="${item.value}" ${isSelected}>${item.label}</option>`;
|
||||
}).join("");
|
||||
}
|
||||
|
|
@ -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 = ['<option value="">选择数据源</option>'];
|
||||
state.sources.forEach((source) => {
|
||||
const selected = source.id === state.selectedSourceId ? "selected" : "";
|
||||
options.push(`<option value="${source.id}" ${selected}>${source.name}</option>`);
|
||||
});
|
||||
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 = `
|
||||
<div class="row">
|
||||
<strong>${source.name}</strong>
|
||||
<span class="badge ${source.is_connected ? "" : "offline"}">${source.is_connected ? "ONLINE" : "OFFLINE"}</span>
|
||||
</div>
|
||||
<div class="muted">${source.endpoint}</div>
|
||||
<div class="row source-card-actions"></div>
|
||||
`;
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
@ -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<syncBtns fn>
|
||||
logSource: null,
|
||||
selectedOpsUnitId: null,
|
||||
};
|
||||
|
|
@ -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('<option value="">未绑定单元</option>');
|
||||
}
|
||||
|
||||
state.units.forEach((unit) => {
|
||||
const isSelected = unit.id === selected ? "selected" : "";
|
||||
options.push(`<option value="${unit.id}" ${isSelected}>${unit.code} / ${unit.name}</option>`);
|
||||
});
|
||||
|
||||
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 '<span class="badge offline">OFFLINE</span>';
|
||||
if (runtime.comm_locked) return '<span class="badge offline">COMM ERR</span>';
|
||||
if (runtime.fault_locked) return '<span class="badge danger">FAULT</span>';
|
||||
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 `<span class="badge ${cls}">${label}</span>`;
|
||||
}
|
||||
|
||||
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) => `<span class="badge">${e.code}</span>`).join("")
|
||||
: '<span class="muted">无设备</span>';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="row">
|
||||
<strong>${unit.code}</strong>
|
||||
${runtimeBadge(runtime)}
|
||||
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
|
||||
</div>
|
||||
<div>${unit.name}</div>
|
||||
<div class="muted">设备 ${bound.length} 台 | 累计 ${runtime ? Math.floor(runtime.display_acc_sec / 1000) : 0}s</div>
|
||||
<div class="muted">运行 ${unit.run_time_sec}s / 停止 ${unit.stop_time_sec}s / 累计 ${unit.acc_time_sec}s / 间隔 ${unit.bl_time_sec}s</div>
|
||||
${mode === "config" ? `<div class="unit-equipment-tags">${equipTags}</div>` : ""}
|
||||
<div class="row unit-card-actions"></div>
|
||||
`;
|
||||
|
||||
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 = '<div class="list-item"><div class="muted">暂无控制单元</div></div>';
|
||||
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 = `<input type="checkbox" ${checked} /><span>${e.code}</span>`;
|
||||
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()));
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<header class="topbar">
|
||||
<div class="title">运转系统</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="status" id="statusText">
|
||||
<span class="ws-dot" id="wsDot"></span>
|
||||
<span id="wsLabel">连接中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>运转系统</title>
|
||||
<link rel="stylesheet" href="/ui/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div data-partial="/ui/html/topbar.html"></div>
|
||||
|
||||
<main>
|
||||
<div class="muted" style="padding:2rem;text-align:center">运转系统页面开发中</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/ui/js/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
function bootstrap() {
|
||||
console.log("Operation system app initialized");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -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 = `<pre>${error.message || String(error)}</pre>`;
|
||||
});
|
||||
Loading…
Reference in New Issue