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:
caoqianming 2026-04-21 08:43:02 +08:00
parent 6102ed712f
commit 368faf290a
34 changed files with 4319 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
<section class="panel bottom-mid">
<div class="panel-head">
<h2>实时日志</h2>
</div>
<div class="log" id="logView"></div>
</section>

View File

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

131
web/core/html/modals.html Normal file
View File

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

View File

@ -0,0 +1,37 @@
<section class="panel top-right">
<div class="panel-head">
<h2>点位</h2>
<div class="pager">
<button class="secondary" id="prevPoints" title="上一页">&lsaquo;</button>
<span id="pointsPageInfo">1 / 1</span>
<button class="secondary" id="nextPoints" title="下一页">&rsaquo;</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>

View File

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

1402
web/core/styles.css Normal file

File diff suppressed because it is too large Load Diff

135
web/feeder/html/modals.html Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

43
web/feeder/index.html Normal file
View File

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

87
web/feeder/js/api.js Normal file
View File

@ -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 || "请求失败");
});
}

210
web/feeder/js/app.js Normal file
View File

@ -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();

183
web/feeder/js/chart.js Normal file
View File

@ -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}`;
}

137
web/feeder/js/docs.js Normal file
View File

@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
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");
}

110
web/feeder/js/dom.js Normal file
View File

@ -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"),
};

245
web/feeder/js/equipment.js Normal file
View File

@ -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();
}

86
web/feeder/js/events.js Normal file
View File

@ -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();
}
});

20
web/feeder/js/index.js Normal file
View File

@ -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>`;
});

176
web/feeder/js/logs.js Normal file
View File

@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
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);
}

232
web/feeder/js/ops.js Normal file
View File

@ -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());
}

363
web/feeder/js/points.js Normal file
View File

@ -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();
}

29
web/feeder/js/roles.js Normal file
View File

@ -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("");
}

138
web/feeder/js/sources.js Normal file
View File

@ -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();
}

29
web/feeder/js/state.js Normal file
View File

@ -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,
};

324
web/feeder/js/units.js Normal file
View File

@ -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()));
}

9
web/ops/html/topbar.html Normal file
View File

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

18
web/ops/index.html Normal file
View File

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

5
web/ops/js/app.js Normal file
View File

@ -0,0 +1,5 @@
function bootstrap() {
console.log("Operation system app initialized");
}
bootstrap();

20
web/ops/js/index.js Normal file
View File

@ -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>`;
});