diff --git a/web/html/equipment-panel.html b/web/html/equipment-panel.html index 7601075..8210d4e 100644 --- a/web/html/equipment-panel.html +++ b/web/html/equipment-panel.html @@ -7,5 +7,11 @@ +
+
已选 0 台设备
+ + + +
diff --git a/web/html/logs-panel.html b/web/html/logs-panel.html index ec3db03..578dfa0 100644 --- a/web/html/logs-panel.html +++ b/web/html/logs-panel.html @@ -1,6 +1,18 @@
-
-

实时日志

+
+
+
+

系统事件

+ +
+
+
+ +
+
+

实时日志

+
+
+
-
diff --git a/web/html/modals.html b/web/html/modals.html index 5a2c345..196c417 100644 --- a/web/html/modals.html +++ b/web/html/modals.html @@ -1,3 +1,55 @@ + + diff --git a/web/html/source-panel.html b/web/html/source-panel.html index 2ea65a6..90f9fe6 100644 --- a/web/html/source-panel.html +++ b/web/html/source-panel.html @@ -1,7 +1,22 @@
-
-

数据源

- +
+
+
+

控制单元

+
+ + +
+
+
+
+ +
+
+

数据源

+ +
+
+
-
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 = '
No equipment
'; 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;