-
数据源
-
+
-
diff --git a/web/js/app.js b/web/js/app.js
index 05ba772..7c41cb5 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -2,9 +2,12 @@ import { withStatus } from "./api.js";
import { openChart, renderChart } from "./chart.js";
import { dom } from "./dom.js";
import { closeApiDocDrawer, openApiDocDrawer } from "./docs.js";
+import { loadEvents } from "./events.js";
import {
+ applyBatchEquipmentUnit,
clearEquipmentFilter,
clearPointBinding,
+ clearSelectedEquipments,
closeEquipmentModal,
loadEquipments,
openCreateEquipmentModal,
@@ -29,19 +32,28 @@ import {
} from "./points.js";
import { state } from "./state.js";
import { loadSources, saveSource } from "./sources.js";
+import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js";
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);
+ 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.equipmentResetBtn.addEventListener("click", resetEquipmentForm);
dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments()));
dom.newEquipmentBtn.addEventListener("click", openCreateEquipmentModal);
dom.closeEquipmentModalBtn.addEventListener("click", closeEquipmentModal);
dom.clearEquipmentFilterBtn.addEventListener("click", () => withStatus(clearEquipmentFilter()));
+ dom.applyEquipmentUnitBtn.addEventListener("click", () => withStatus(applyBatchEquipmentUnit()));
+ dom.clearEquipmentSelectionBtn.addEventListener("click", clearSelectedEquipments);
dom.openPointModalBtn.addEventListener("click", openPointCreateModal);
dom.pointSourceSelect.addEventListener("change", () => {
@@ -82,6 +94,7 @@ function bindEvents() {
dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer()));
dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer);
+ dom.refreshEventBtn.addEventListener("click", () => withStatus(loadEvents()));
dom.refreshChartBtn.addEventListener("click", () => {
if (!state.chartPointId) {
@@ -111,6 +124,10 @@ function bindEvents() {
withStatus(loadEquipments());
}
});
+
+ document.addEventListener("equipments-updated", () => {
+ renderUnits();
+ });
}
async function bootstrap() {
@@ -122,8 +139,10 @@ async function bootstrap() {
startLogs();
startPointSocket();
+ await withStatus(loadUnits());
await withStatus(loadSources());
await withStatus(loadEquipments());
+ await withStatus(loadEvents());
await withStatus(loadPoints());
}
diff --git a/web/js/dom.js b/web/js/dom.js
index e6f3a8c..32292de 100644
--- a/web/js/dom.js
+++ b/web/js/dom.js
@@ -3,6 +3,8 @@ const byId = (id) => document.getElementById(id);
export const dom = {
statusText: byId("statusText"),
sourceList: byId("sourceList"),
+ unitList: byId("unitList"),
+ eventList: byId("eventList"),
nodeTree: byId("nodeTree"),
pointList: byId("pointList"),
pointsPageInfo: byId("pointsPageInfo"),
@@ -18,11 +20,24 @@ export const dom = {
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"),
@@ -31,14 +46,23 @@ export const dom = {
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"),
+ equipmentBatchUnitId: byId("equipmentBatchUnitId"),
+ selectedEquipmentSummary: byId("selectedEquipmentSummary"),
equipmentList: byId("equipmentList"),
+ refreshUnitBtn: byId("refreshUnitBtn"),
+ newUnitBtn: byId("newUnitBtn"),
+ closeUnitModalBtn: byId("closeUnitModal"),
closeEquipmentModalBtn: byId("closeEquipmentModal"),
+ refreshEventBtn: byId("refreshEventBtn"),
+ applyEquipmentUnitBtn: byId("applyEquipmentUnitBtn"),
+ clearEquipmentSelectionBtn: byId("clearEquipmentSelectionBtn"),
pointBindingForm: byId("pointBindingForm"),
bindingPointId: byId("bindingPointId"),
bindingPointName: byId("bindingPointName"),
diff --git a/web/js/equipment.js b/web/js/equipment.js
index 269beb3..d9b8ea9 100644
--- a/web/js/equipment.js
+++ b/web/js/equipment.js
@@ -8,9 +8,61 @@ function equipmentOf(item) {
return item && item.equipment ? item.equipment : item;
}
+function currentUnitLabel(unitId) {
+ if (!unitId) {
+ return "未绑定单元";
+ }
+ const unit = state.unitMap.get(unitId);
+ return unit ? `${unit.code} / ${unit.name}` : "未知单元";
+}
+
+function filteredEquipments() {
+ if (!state.selectedUnitId) {
+ return state.equipments;
+ }
+
+ return state.equipments.filter((item) => {
+ const equipment = equipmentOf(item);
+ return equipment.unit_id === state.selectedUnitId;
+ });
+}
+
+function renderEquipmentUnitOptions(selected = "", target = dom.equipmentUnitId) {
+ if (!target) {
+ return;
+ }
+
+ const options = ['
'];
+ state.units.forEach((unit) => {
+ const isSelected = unit.id === selected ? "selected" : "";
+ options.push(`
`);
+ });
+ target.innerHTML = options.join("");
+}
+
+function renderBatchUnitOptions(selected = "") {
+ if (!dom.equipmentBatchUnitId) {
+ return;
+ }
+
+ const options = ['
'];
+ state.units.forEach((unit) => {
+ const isSelected = unit.id === selected ? "selected" : "";
+ options.push(`
`);
+ });
+ dom.equipmentBatchUnitId.innerHTML = options.join("");
+}
+
+function updateSelectedEquipmentSummary() {
+ if (!dom.selectedEquipmentSummary) {
+ return;
+ }
+ dom.selectedEquipmentSummary.textContent = `已选 ${state.selectedEquipmentIds.size} 台设备`;
+}
+
export function renderBindingEquipmentOptions(selected = "", target = dom.bindingEquipmentId) {
const options = ['
'];
- state.equipments.forEach((item) => {
+ filteredEquipments().forEach((item) => {
const equipment = equipmentOf(item);
const isSelected = equipment.id === selected ? "selected" : "";
options.push(
@@ -28,6 +80,7 @@ export function renderBatchBindingDefaults() {
export function resetEquipmentForm() {
dom.equipmentForm.reset();
dom.equipmentId.value = "";
+ renderEquipmentUnitOptions("");
}
function openEquipmentModal() {
@@ -45,6 +98,7 @@ export function openCreateEquipmentModal() {
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.value = equipment.kind || "";
@@ -61,6 +115,21 @@ async function selectEquipment(equipmentId) {
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() {
state.selectedEquipmentId = null;
state.pointsPage = 1;
@@ -71,29 +140,37 @@ export function clearEquipmentFilter() {
export function renderEquipments() {
dom.equipmentList.innerHTML = "";
+ updateSelectedEquipmentSummary();
+
const activeEquipment = state.selectedEquipmentId
? state.equipmentMap.get(state.selectedEquipmentId) || null
: null;
dom.clearEquipmentFilterBtn.textContent = activeEquipment
- ? `设备筛选: ${activeEquipment.name}`
- : "设备筛选: 全部";
+ ? `设备筛选 ${activeEquipment.name}`
+ : "设备筛选 全部";
- if (!state.equipments.length) {
+ const items = filteredEquipments();
+ if (!items.length) {
dom.equipmentList.innerHTML = '
';
return;
}
- state.equipments.forEach((item) => {
+ items.forEach((item) => {
const equipment = equipmentOf(item);
const box = document.createElement("div");
box.className = `list-item equipment-card ${state.selectedEquipmentId === equipment.id ? "selected" : ""}`;
box.innerHTML = `
+
${equipment.code}
${item.point_count ?? 0} pts
${equipment.name}
${equipment.kind || "No type"}
+
单元: ${currentUnitLabel(equipment.unit_id)}
`;
@@ -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 editBtn = document.createElement("button");
@@ -141,6 +226,15 @@ export async function loadEquipments() {
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();
renderBatchBindingDefaults();
if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) {
@@ -148,12 +242,15 @@ export async function loadEquipments() {
}
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,
@@ -176,6 +273,25 @@ export async function saveEquipment(event) {
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) {
if (!window.confirm("Delete this equipment?")) {
return;
@@ -185,6 +301,7 @@ export async function deleteEquipment(equipmentId) {
if (state.selectedEquipmentId === equipmentId) {
state.selectedEquipmentId = null;
}
+ state.selectedEquipmentIds.delete(equipmentId);
resetEquipmentForm();
closeEquipmentModal();
clearSelectedPoints();
diff --git a/web/js/events.js b/web/js/events.js
new file mode 100644
index 0000000..c3b6b38
--- /dev/null
+++ b/web/js/events.js
@@ -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 = '
';
+ return;
+ }
+
+ state.events.forEach((item) => {
+ const row = document.createElement("div");
+ row.className = "list-item event-card";
+ row.innerHTML = `
+
+ ${item.event_type}
+ ${(item.level || "info").toUpperCase()}
+
+
${item.message}
+
${formatTime(item.created_at)}
+ `;
+ 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();
+}
diff --git a/web/js/state.js b/web/js/state.js
index 6d01832..de6b559 100644
--- a/web/js/state.js
+++ b/web/js/state.js
@@ -1,8 +1,13 @@
export const state = {
+ units: [],
+ unitMap: new Map(),
+ selectedUnitId: null,
sources: [],
+ events: [],
equipments: [],
equipmentMap: new Map(),
selectedEquipmentId: null,
+ selectedEquipmentIds: new Set(),
selectedSourceId: null,
selectedNodeIds: new Set(),
selectedPointIds: new Set(),
diff --git a/web/js/units.js b/web/js/units.js
new file mode 100644
index 0000000..105da74
--- /dev/null
+++ b/web/js/units.js
@@ -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('
');
+ }
+
+ state.units.forEach((unit) => {
+ const isSelected = unit.id === selected ? "selected" : "";
+ options.push(`
`);
+ });
+
+ target.innerHTML = options.join("");
+}
+
+export function resetUnitForm() {
+ dom.unitForm.reset();
+ dom.unitId.value = "";
+ dom.unitEnabled.checked = true;
+ dom.unitManualAck.checked = true;
+ dom.unitRunTimeSec.value = "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 = '
';
+ 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 = `
+
+ ${unit.code}
+ ${unit.enabled ? "ENABLED" : "DISABLED"}
+
+
${unit.name}
+
设备 ${equipmentCount(unit.id)} 台
+
Run ${unit.run_time_sec}s / Stop ${unit.stop_time_sec}s / Acc ${unit.acc_time_sec}s / BL ${unit.bl_time_sec}s
+
+ `;
+
+ 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();
+}
diff --git a/web/styles.css b/web/styles.css
index d40ccbe..41abbf4 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -101,6 +101,24 @@ body {
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-head {
@@ -408,6 +426,25 @@ button.danger:hover { background: var(--danger-hover); }
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 {
@@ -623,6 +660,43 @@ button.danger:hover { background: var(--danger-hover); }
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 {
position: fixed;
inset: 0;