feat(web): add unit and event management views

This commit is contained in:
caoqianming 2026-03-24 10:56:57 +08:00
parent 4e3d325437
commit 1f29eb3871
11 changed files with 574 additions and 13 deletions

View File

@ -7,5 +7,11 @@
<input id="equipmentKeyword" placeholder="搜索编码或名称" /> <input id="equipmentKeyword" placeholder="搜索编码或名称" />
<button type="button" class="secondary" id="refreshEquipmentBtn">刷新</button> <button type="button" class="secondary" id="refreshEquipmentBtn">刷新</button>
</div> </div>
<div class="toolbar equipment-batch-toolbar">
<div class="muted" id="selectedEquipmentSummary">已选 0 台设备</div>
<select id="equipmentBatchUnitId"></select>
<button type="button" class="secondary" id="clearEquipmentSelectionBtn">清空选择</button>
<button type="button" id="applyEquipmentUnitBtn">批量设单元</button>
</div>
<div class="list equipment-list" id="equipmentList"></div> <div class="list equipment-list" id="equipmentList"></div>
</section> </section>

View File

@ -1,6 +1,18 @@
<section class="panel bottom-middle"> <section class="panel bottom-middle">
<div class="panel-head"> <div class="stack-panel">
<h2>实时日志</h2> <div class="stack-section event-section">
<div class="panel-head">
<h2>系统事件</h2>
<button type="button" class="secondary" id="refreshEventBtn">刷新</button>
</div>
<div class="list event-list" id="eventList"></div>
</div>
<div class="stack-section stack-section-bordered log-section">
<div class="panel-head">
<h2>实时日志</h2>
</div>
<div class="log" id="logView"></div>
</div>
</div> </div>
<div class="log" id="logView"></div>
</section> </section>

View File

@ -1,3 +1,55 @@
<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>
<div class="modal hidden" id="equipmentModal"> <div class="modal hidden" id="equipmentModal">
<div class="modal-content modal-sm"> <div class="modal-content modal-sm">
<div class="modal-head"> <div class="modal-head">
@ -6,6 +58,10 @@
</div> </div>
<form id="equipmentForm" class="form"> <form id="equipmentForm" class="form">
<input type="hidden" id="equipmentId" /> <input type="hidden" id="equipmentId" />
<label>
所属单元
<select id="equipmentUnitId"></select>
</label>
<label> <label>
编码 编码
<input id="equipmentCode" required /> <input id="equipmentCode" required />
@ -45,7 +101,7 @@
<div class="tree" id="nodeTree"></div> <div class="tree" id="nodeTree"></div>
<div class="modal-foot"> <div class="modal-foot">
<div class="muted" id="selectedCount">已选中 0 个节点</div> <div class="muted" id="selectedCount">已选中 0 个节点</div>
<button id="createPoints">创建点位</button> <button id="createPoints">创建设备点位</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,22 @@
<section class="panel bottom-left"> <section class="panel bottom-left">
<div class="panel-head"> <div class="stack-panel">
<h2>数据源</h2> <div class="stack-section">
<button id="openSourceForm">+ 新增</button> <div class="panel-head">
<h2>控制单元</h2>
<div class="toolbar">
<button type="button" class="secondary" id="refreshUnitBtn">刷新</button>
<button type="button" id="newUnitBtn">+ 新增</button>
</div>
</div>
<div class="list unit-list" id="unitList"></div>
</div>
<div class="stack-section stack-section-bordered">
<div class="panel-head">
<h2>数据源</h2>
<button type="button" id="openSourceForm">+ 新增</button>
</div>
<div class="source-panels" id="sourceList"></div>
</div>
</div> </div>
<div class="source-panels" id="sourceList"></div>
</section> </section>

View File

@ -2,9 +2,12 @@ import { withStatus } from "./api.js";
import { openChart, renderChart } from "./chart.js"; import { openChart, renderChart } from "./chart.js";
import { dom } from "./dom.js"; import { dom } from "./dom.js";
import { closeApiDocDrawer, openApiDocDrawer } from "./docs.js"; import { closeApiDocDrawer, openApiDocDrawer } from "./docs.js";
import { loadEvents } from "./events.js";
import { import {
applyBatchEquipmentUnit,
clearEquipmentFilter, clearEquipmentFilter,
clearPointBinding, clearPointBinding,
clearSelectedEquipments,
closeEquipmentModal, closeEquipmentModal,
loadEquipments, loadEquipments,
openCreateEquipmentModal, openCreateEquipmentModal,
@ -29,19 +32,28 @@ import {
} from "./points.js"; } from "./points.js";
import { state } from "./state.js"; import { state } from "./state.js";
import { loadSources, saveSource } from "./sources.js"; import { loadSources, saveSource } from "./sources.js";
import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js";
function bindEvents() { function bindEvents() {
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event))); dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
dom.equipmentForm.addEventListener("submit", (event) => withStatus(saveEquipment(event))); dom.equipmentForm.addEventListener("submit", (event) => withStatus(saveEquipment(event)));
dom.pointBindingForm.addEventListener("submit", (event) => withStatus(savePointBinding(event))); dom.pointBindingForm.addEventListener("submit", (event) => withStatus(savePointBinding(event)));
dom.batchBindingForm.addEventListener("submit", (event) => withStatus(saveBatchBinding(event))); dom.batchBindingForm.addEventListener("submit", (event) => withStatus(saveBatchBinding(event)));
dom.unitResetBtn.addEventListener("click", resetUnitForm);
dom.refreshUnitBtn.addEventListener("click", () => withStatus(loadUnits().then(loadEvents)));
dom.newUnitBtn.addEventListener("click", openCreateUnitModal);
dom.closeUnitModalBtn.addEventListener("click", closeUnitModal);
dom.sourceResetBtn.addEventListener("click", () => dom.sourceForm.reset()); dom.sourceResetBtn.addEventListener("click", () => dom.sourceForm.reset());
dom.equipmentResetBtn.addEventListener("click", resetEquipmentForm); dom.equipmentResetBtn.addEventListener("click", resetEquipmentForm);
dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments())); dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments()));
dom.newEquipmentBtn.addEventListener("click", openCreateEquipmentModal); dom.newEquipmentBtn.addEventListener("click", openCreateEquipmentModal);
dom.closeEquipmentModalBtn.addEventListener("click", closeEquipmentModal); dom.closeEquipmentModalBtn.addEventListener("click", closeEquipmentModal);
dom.clearEquipmentFilterBtn.addEventListener("click", () => withStatus(clearEquipmentFilter())); dom.clearEquipmentFilterBtn.addEventListener("click", () => withStatus(clearEquipmentFilter()));
dom.applyEquipmentUnitBtn.addEventListener("click", () => withStatus(applyBatchEquipmentUnit()));
dom.clearEquipmentSelectionBtn.addEventListener("click", clearSelectedEquipments);
dom.openPointModalBtn.addEventListener("click", openPointCreateModal); dom.openPointModalBtn.addEventListener("click", openPointCreateModal);
dom.pointSourceSelect.addEventListener("change", () => { dom.pointSourceSelect.addEventListener("change", () => {
@ -82,6 +94,7 @@ function bindEvents() {
dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer())); dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer()));
dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer); dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer);
dom.refreshEventBtn.addEventListener("click", () => withStatus(loadEvents()));
dom.refreshChartBtn.addEventListener("click", () => { dom.refreshChartBtn.addEventListener("click", () => {
if (!state.chartPointId) { if (!state.chartPointId) {
@ -111,6 +124,10 @@ function bindEvents() {
withStatus(loadEquipments()); withStatus(loadEquipments());
} }
}); });
document.addEventListener("equipments-updated", () => {
renderUnits();
});
} }
async function bootstrap() { async function bootstrap() {
@ -122,8 +139,10 @@ async function bootstrap() {
startLogs(); startLogs();
startPointSocket(); startPointSocket();
await withStatus(loadUnits());
await withStatus(loadSources()); await withStatus(loadSources());
await withStatus(loadEquipments()); await withStatus(loadEquipments());
await withStatus(loadEvents());
await withStatus(loadPoints()); await withStatus(loadPoints());
} }

View File

@ -3,6 +3,8 @@ const byId = (id) => document.getElementById(id);
export const dom = { export const dom = {
statusText: byId("statusText"), statusText: byId("statusText"),
sourceList: byId("sourceList"), sourceList: byId("sourceList"),
unitList: byId("unitList"),
eventList: byId("eventList"),
nodeTree: byId("nodeTree"), nodeTree: byId("nodeTree"),
pointList: byId("pointList"), pointList: byId("pointList"),
pointsPageInfo: byId("pointsPageInfo"), pointsPageInfo: byId("pointsPageInfo"),
@ -18,11 +20,24 @@ export const dom = {
chartTitle: byId("chartTitle"), chartTitle: byId("chartTitle"),
chartSummary: byId("chartSummary"), chartSummary: byId("chartSummary"),
pointModal: byId("pointModal"), pointModal: byId("pointModal"),
unitModal: byId("unitModal"),
sourceModal: byId("sourceModal"), sourceModal: byId("sourceModal"),
equipmentModal: byId("equipmentModal"), equipmentModal: byId("equipmentModal"),
pointBindingModal: byId("pointBindingModal"), pointBindingModal: byId("pointBindingModal"),
batchBindingModal: byId("batchBindingModal"), batchBindingModal: byId("batchBindingModal"),
apiDocDrawer: byId("apiDocDrawer"), 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"), sourceForm: byId("sourceForm"),
sourceId: byId("sourceId"), sourceId: byId("sourceId"),
sourceName: byId("sourceName"), sourceName: byId("sourceName"),
@ -31,14 +46,23 @@ export const dom = {
sourceResetBtn: byId("sourceReset"), sourceResetBtn: byId("sourceReset"),
equipmentForm: byId("equipmentForm"), equipmentForm: byId("equipmentForm"),
equipmentId: byId("equipmentId"), equipmentId: byId("equipmentId"),
equipmentUnitId: byId("equipmentUnitId"),
equipmentCode: byId("equipmentCode"), equipmentCode: byId("equipmentCode"),
equipmentName: byId("equipmentName"), equipmentName: byId("equipmentName"),
equipmentKind: byId("equipmentKind"), equipmentKind: byId("equipmentKind"),
equipmentDescription: byId("equipmentDescription"), equipmentDescription: byId("equipmentDescription"),
equipmentResetBtn: byId("equipmentReset"), equipmentResetBtn: byId("equipmentReset"),
equipmentKeyword: byId("equipmentKeyword"), equipmentKeyword: byId("equipmentKeyword"),
equipmentBatchUnitId: byId("equipmentBatchUnitId"),
selectedEquipmentSummary: byId("selectedEquipmentSummary"),
equipmentList: byId("equipmentList"), equipmentList: byId("equipmentList"),
refreshUnitBtn: byId("refreshUnitBtn"),
newUnitBtn: byId("newUnitBtn"),
closeUnitModalBtn: byId("closeUnitModal"),
closeEquipmentModalBtn: byId("closeEquipmentModal"), closeEquipmentModalBtn: byId("closeEquipmentModal"),
refreshEventBtn: byId("refreshEventBtn"),
applyEquipmentUnitBtn: byId("applyEquipmentUnitBtn"),
clearEquipmentSelectionBtn: byId("clearEquipmentSelectionBtn"),
pointBindingForm: byId("pointBindingForm"), pointBindingForm: byId("pointBindingForm"),
bindingPointId: byId("bindingPointId"), bindingPointId: byId("bindingPointId"),
bindingPointName: byId("bindingPointName"), bindingPointName: byId("bindingPointName"),

View File

@ -8,9 +8,61 @@ function equipmentOf(item) {
return item && item.equipment ? item.equipment : 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("");
}
function renderBatchUnitOptions(selected = "") {
if (!dom.equipmentBatchUnitId) {
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>`);
});
dom.equipmentBatchUnitId.innerHTML = options.join("");
}
function updateSelectedEquipmentSummary() {
if (!dom.selectedEquipmentSummary) {
return;
}
dom.selectedEquipmentSummary.textContent = `已选 ${state.selectedEquipmentIds.size} 台设备`;
}
export function renderBindingEquipmentOptions(selected = "", target = dom.bindingEquipmentId) { export function renderBindingEquipmentOptions(selected = "", target = dom.bindingEquipmentId) {
const options = ['<option value="">Unbound</option>']; const options = ['<option value="">Unbound</option>'];
state.equipments.forEach((item) => { filteredEquipments().forEach((item) => {
const equipment = equipmentOf(item); const equipment = equipmentOf(item);
const isSelected = equipment.id === selected ? "selected" : ""; const isSelected = equipment.id === selected ? "selected" : "";
options.push( options.push(
@ -28,6 +80,7 @@ export function renderBatchBindingDefaults() {
export function resetEquipmentForm() { export function resetEquipmentForm() {
dom.equipmentForm.reset(); dom.equipmentForm.reset();
dom.equipmentId.value = ""; dom.equipmentId.value = "";
renderEquipmentUnitOptions("");
} }
function openEquipmentModal() { function openEquipmentModal() {
@ -45,6 +98,7 @@ export function openCreateEquipmentModal() {
function openEditEquipmentModal(equipment) { function openEditEquipmentModal(equipment) {
dom.equipmentId.value = equipment.id || ""; dom.equipmentId.value = equipment.id || "";
dom.equipmentUnitId.value = equipment.unit_id || "";
dom.equipmentCode.value = equipment.code || ""; dom.equipmentCode.value = equipment.code || "";
dom.equipmentName.value = equipment.name || ""; dom.equipmentName.value = equipment.name || "";
dom.equipmentKind.value = equipment.kind || ""; dom.equipmentKind.value = equipment.kind || "";
@ -61,6 +115,21 @@ async function selectEquipment(equipmentId) {
await loadPoints(); await loadPoints();
} }
function toggleEquipmentSelection(equipmentId, checked) {
if (checked) {
state.selectedEquipmentIds.add(equipmentId);
} else {
state.selectedEquipmentIds.delete(equipmentId);
}
updateSelectedEquipmentSummary();
}
export function clearSelectedEquipments() {
state.selectedEquipmentIds.clear();
renderEquipments();
updateSelectedEquipmentSummary();
}
export function clearEquipmentFilter() { export function clearEquipmentFilter() {
state.selectedEquipmentId = null; state.selectedEquipmentId = null;
state.pointsPage = 1; state.pointsPage = 1;
@ -71,29 +140,37 @@ export function clearEquipmentFilter() {
export function renderEquipments() { export function renderEquipments() {
dom.equipmentList.innerHTML = ""; dom.equipmentList.innerHTML = "";
updateSelectedEquipmentSummary();
const activeEquipment = state.selectedEquipmentId const activeEquipment = state.selectedEquipmentId
? state.equipmentMap.get(state.selectedEquipmentId) || null ? state.equipmentMap.get(state.selectedEquipmentId) || null
: null; : null;
dom.clearEquipmentFilterBtn.textContent = activeEquipment dom.clearEquipmentFilterBtn.textContent = activeEquipment
? `设备筛选: ${activeEquipment.name}` ? `设备筛选 ${activeEquipment.name}`
: "设备筛选: 全部"; : "设备筛选 全部";
if (!state.equipments.length) { const items = filteredEquipments();
if (!items.length) {
dom.equipmentList.innerHTML = '<div class="list-item"><div class="muted">No equipment</div></div>'; dom.equipmentList.innerHTML = '<div class="list-item"><div class="muted">No equipment</div></div>';
return; return;
} }
state.equipments.forEach((item) => { items.forEach((item) => {
const equipment = equipmentOf(item); const equipment = equipmentOf(item);
const box = document.createElement("div"); const box = document.createElement("div");
box.className = `list-item equipment-card ${state.selectedEquipmentId === equipment.id ? "selected" : ""}`; box.className = `list-item equipment-card ${state.selectedEquipmentId === equipment.id ? "selected" : ""}`;
box.innerHTML = ` box.innerHTML = `
<label class="equipment-select-row">
<input type="checkbox" data-equipment-select="true" ${state.selectedEquipmentIds.has(equipment.id) ? "checked" : ""} />
<span class="muted">批量选择</span>
</label>
<div class="row"> <div class="row">
<strong>${equipment.code}</strong> <strong>${equipment.code}</strong>
<span class="badge">${item.point_count ?? 0} pts</span> <span class="badge">${item.point_count ?? 0} pts</span>
</div> </div>
<div>${equipment.name}</div> <div>${equipment.name}</div>
<div class="muted">${equipment.kind || "No type"}</div> <div class="muted">${equipment.kind || "No type"}</div>
<div class="muted">单元: ${currentUnitLabel(equipment.unit_id)}</div>
<div class="row equipment-card-actions"></div> <div class="row equipment-card-actions"></div>
`; `;
@ -103,6 +180,14 @@ export function renderEquipments() {
}); });
}); });
const checkbox = box.querySelector('input[data-equipment-select="true"]');
checkbox.addEventListener("click", (event) => {
event.stopPropagation();
});
checkbox.addEventListener("change", (event) => {
toggleEquipmentSelection(equipment.id, event.target.checked);
});
const actionRow = box.querySelector(".equipment-card-actions"); const actionRow = box.querySelector(".equipment-card-actions");
const editBtn = document.createElement("button"); const editBtn = document.createElement("button");
@ -141,6 +226,15 @@ export async function loadEquipments() {
return [equipment.id, equipment]; return [equipment.id, equipment];
}), }),
); );
state.selectedEquipmentIds.forEach((id) => {
if (!state.equipmentMap.has(id)) {
state.selectedEquipmentIds.delete(id);
}
});
renderEquipmentUnitOptions(dom.equipmentUnitId?.value || "");
renderBatchUnitOptions(dom.equipmentBatchUnitId?.value || "");
renderBindingEquipmentOptions(); renderBindingEquipmentOptions();
renderBatchBindingDefaults(); renderBatchBindingDefaults();
if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) { if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) {
@ -148,12 +242,15 @@ export async function loadEquipments() {
} }
renderEquipments(); renderEquipments();
updatePointFilterSummary(); updatePointFilterSummary();
document.dispatchEvent(new Event("equipments-updated"));
} }
export async function saveEquipment(event) { export async function saveEquipment(event) {
event.preventDefault(); event.preventDefault();
const unitId = dom.equipmentUnitId.value || null;
const payload = { const payload = {
unit_id: unitId,
code: dom.equipmentCode.value.trim(), code: dom.equipmentCode.value.trim(),
name: dom.equipmentName.value.trim(), name: dom.equipmentName.value.trim(),
kind: dom.equipmentKind.value.trim() || null, kind: dom.equipmentKind.value.trim() || null,
@ -176,6 +273,25 @@ export async function saveEquipment(event) {
await loadPoints(); await loadPoints();
} }
export async function applyBatchEquipmentUnit() {
if (!state.selectedEquipmentIds.size) {
throw new Error("请先选择设备");
}
const value = dom.equipmentBatchUnitId.value;
await apiFetch("/api/equipment/batch/set-unit", {
method: "PUT",
body: JSON.stringify({
equipment_ids: Array.from(state.selectedEquipmentIds),
unit_id: value || null,
}),
});
clearSelectedEquipments();
renderBatchUnitOptions("");
await loadEquipments();
}
export async function deleteEquipment(equipmentId) { export async function deleteEquipment(equipmentId) {
if (!window.confirm("Delete this equipment?")) { if (!window.confirm("Delete this equipment?")) {
return; return;
@ -185,6 +301,7 @@ export async function deleteEquipment(equipmentId) {
if (state.selectedEquipmentId === equipmentId) { if (state.selectedEquipmentId === equipmentId) {
state.selectedEquipmentId = null; state.selectedEquipmentId = null;
} }
state.selectedEquipmentIds.delete(equipmentId);
resetEquipmentForm(); resetEquipmentForm();
closeEquipmentModal(); closeEquipmentModal();
clearSelectedPoints(); clearSelectedPoints();

48
web/js/events.js Normal file
View File

@ -0,0 +1,48 @@
import { apiFetch } from "./api.js";
import { dom } from "./dom.js";
import { state } from "./state.js";
function formatTime(value) {
if (!value) {
return "--";
}
return value;
}
export function renderEvents() {
dom.eventList.innerHTML = "";
if (!state.events.length) {
dom.eventList.innerHTML = '<div class="list-item"><div class="muted">暂无事件</div></div>';
return;
}
state.events.forEach((item) => {
const row = document.createElement("div");
row.className = "list-item event-card";
row.innerHTML = `
<div class="row">
<strong>${item.event_type}</strong>
<span class="badge">${(item.level || "info").toUpperCase()}</span>
</div>
<div>${item.message}</div>
<div class="muted">${formatTime(item.created_at)}</div>
`;
dom.eventList.appendChild(row);
});
}
export async function loadEvents() {
const params = new URLSearchParams({
page: "1",
page_size: "20",
});
if (state.selectedUnitId) {
params.set("unit_id", state.selectedUnitId);
}
const response = await apiFetch(`/api/event?${params.toString()}`);
state.events = response.data || [];
renderEvents();
}

View File

@ -1,8 +1,13 @@
export const state = { export const state = {
units: [],
unitMap: new Map(),
selectedUnitId: null,
sources: [], sources: [],
events: [],
equipments: [], equipments: [],
equipmentMap: new Map(), equipmentMap: new Map(),
selectedEquipmentId: null, selectedEquipmentId: null,
selectedEquipmentIds: new Set(),
selectedSourceId: null, selectedSourceId: null,
selectedNodeIds: new Set(), selectedNodeIds: new Set(),
selectedPointIds: new Set(), selectedPointIds: new Set(),

185
web/js/units.js Normal file
View File

@ -0,0 +1,185 @@
import { apiFetch } from "./api.js";
import { dom } from "./dom.js";
import { loadEvents } from "./events.js";
import { renderEquipments } from "./equipment.js";
import { state } from "./state.js";
function equipmentCount(unitId) {
return state.equipments.filter((item) => {
const equipment = item.equipment || item;
return equipment.unit_id === unitId;
}).length;
}
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 = "0";
dom.unitStopTimeSec.value = "0";
dom.unitAccTimeSec.value = "0";
dom.unitBlTimeSec.value = "0";
}
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();
}
export function renderUnits() {
dom.unitList.innerHTML = "";
if (!state.units.length) {
dom.unitList.innerHTML = '<div class="list-item"><div class="muted">暂无控制单元</div></div>';
return;
}
state.units.forEach((unit) => {
const card = document.createElement("div");
const selected = state.selectedUnitId === unit.id;
card.className = `list-item unit-card ${selected ? "selected" : ""}`;
card.innerHTML = `
<div class="row">
<strong>${unit.code}</strong>
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "ENABLED" : "DISABLED"}</span>
</div>
<div>${unit.name}</div>
<div class="muted">设备 ${equipmentCount(unit.id)} </div>
<div class="muted">Run ${unit.run_time_sec}s / Stop ${unit.stop_time_sec}s / Acc ${unit.acc_time_sec}s / BL ${unit.bl_time_sec}s</div>
<div class="row unit-card-actions"></div>
`;
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 = "Edit";
editBtn.addEventListener("click", (event) => {
event.stopPropagation();
openEditUnitModal(unit);
});
const deleteBtn = document.createElement("button");
deleteBtn.className = "danger";
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", (event) => {
event.stopPropagation();
deleteUnit(unit.id).catch((error) => {
dom.statusText.textContent = error.message;
});
});
actions.append(editBtn, deleteBtn);
dom.unitList.appendChild(card);
});
}
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;
}
renderUnits();
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId);
}
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("Delete this unit?")) {
return;
}
await apiFetch(`/api/unit/${unitId}`, { method: "DELETE" });
if (state.selectedUnitId === unitId) {
state.selectedUnitId = null;
}
closeUnitModal();
await loadUnits();
renderEquipments();
await loadEvents();
}

View File

@ -101,6 +101,24 @@ body {
overflow: hidden; overflow: hidden;
} }
.stack-panel {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 auto;
}
.stack-section {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 0;
}
.stack-section-bordered {
border-top: 1px solid var(--border-light);
}
/* ── Panel Header ───────────────────────────────── */ /* ── Panel Header ───────────────────────────────── */
.panel-head { .panel-head {
@ -408,6 +426,25 @@ button.danger:hover { background: var(--danger-hover); }
background: var(--surface); background: var(--surface);
} }
.equipment-batch-toolbar {
padding: 8px 12px;
border-bottom: 1px solid var(--border-light);
align-items: center;
flex-wrap: wrap;
}
.equipment-batch-toolbar .muted {
min-width: 90px;
}
.equipment-batch-toolbar select {
flex: 1;
min-width: 0;
padding: 7px 10px;
border: 1px solid var(--border);
background: var(--surface);
}
/* ── Form ─────────────────────────────────────────── */ /* ── Form ─────────────────────────────────────────── */
.form { .form {
@ -623,6 +660,43 @@ button.danger:hover { background: var(--danger-hover); }
max-height: 50vh; max-height: 50vh;
} }
.unit-list,
.event-list {
padding-top: 6px;
}
.unit-card-actions {
padding-top: 4px;
}
.event-card {
cursor: default;
}
.event-card:hover {
background: var(--surface);
border-color: var(--border);
}
.event-section {
flex-basis: 42%;
}
.log-section {
flex-basis: 58%;
}
.equipment-select-row {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.equipment-select-row input {
margin: 0;
}
.drawer-backdrop { .drawer-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;