diff --git a/src/handler/point.rs b/src/handler/point.rs index 77851e9..5de31ad 100644 --- a/src/handler/point.rs +++ b/src/handler/point.rs @@ -24,6 +24,7 @@ use crate::{ #[derive(Deserialize, Validate)] pub struct GetPointListQuery { pub source_id: Option, + pub equipment_id: Option, #[serde(flatten)] pub pagination: PaginationParams, } @@ -58,12 +59,13 @@ pub async fn get_point_list( let pool = &state.pool; // 获取总数 - let total = crate::service::get_points_count(pool, query.source_id).await?; + let total = crate::service::get_points_count(pool, query.source_id, query.equipment_id).await?; // 获取分页数据 let points = crate::service::get_points_paginated( pool, query.source_id, + query.equipment_id, query.pagination.page_size, query.pagination.offset(), ) @@ -161,6 +163,7 @@ pub struct BatchSetPointTagsReq { pub struct BatchSetPointEquipmentReq { pub point_ids: Vec, pub equipment_id: Option, + pub signal_role: Option, } /// Update point metadata (name/description/unit only). @@ -344,12 +347,20 @@ pub async fn batch_set_point_equipment( return Err(ApiErr::NotFound("No valid points found".to_string(), None)); } - let result = - sqlx::query(r#"UPDATE point SET equipment_id = $1, updated_at = NOW() WHERE id = ANY($2)"#) - .bind(payload.equipment_id) - .bind(&existing_points) - .execute(pool) - .await?; + let result = sqlx::query( + r#" + UPDATE point + SET equipment_id = $1, + signal_role = $2, + updated_at = NOW() + WHERE id = ANY($3) + "#, + ) + .bind(payload.equipment_id) + .bind(payload.signal_role.as_deref()) + .bind(&existing_points) + .execute(pool) + .await?; Ok(Json(serde_json::json!({ "ok_msg": "Point equipment updated successfully", diff --git a/src/service/point.rs b/src/service/point.rs index 1818e7e..f3e7de1 100644 --- a/src/service/point.rs +++ b/src/service/point.rs @@ -102,9 +102,25 @@ pub async fn get_points_with_ids( pub async fn get_points_count( pool: &PgPool, source_id: Option, + equipment_id: Option, ) -> Result { - match source_id { - Some(source_id) => { + match (source_id, equipment_id) { + (Some(source_id), Some(equipment_id)) => { + sqlx::query_scalar::<_, i64>( + r#" + SELECT COUNT(*) + FROM point p + INNER JOIN node n ON p.node_id = n.id + WHERE n.source_id = $1 + AND p.equipment_id = $2 + "#, + ) + .bind(source_id) + .bind(equipment_id) + .fetch_one(pool) + .await + } + (Some(source_id), None) => { sqlx::query_scalar::<_, i64>( r#" SELECT COUNT(*) @@ -117,7 +133,13 @@ pub async fn get_points_count( .fetch_one(pool) .await } - None => sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM point"#) + (None, Some(equipment_id)) => { + sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM point WHERE equipment_id = $1"#) + .bind(equipment_id) + .fetch_one(pool) + .await + } + (None, None) => sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM point"#) .fetch_one(pool) .await, } @@ -126,11 +148,50 @@ pub async fn get_points_count( pub async fn get_points_paginated( pool: &PgPool, source_id: Option, + equipment_id: Option, page_size: i32, offset: u32, ) -> Result, sqlx::Error> { - match source_id { - Some(source_id) => { + match (source_id, equipment_id) { + (Some(source_id), Some(equipment_id)) => { + if page_size == 0 { + Ok(vec![]) + } else if page_size == -1 { + sqlx::query_as::<_, Point>( + r#" + SELECT p.* + FROM point p + INNER JOIN node n ON p.node_id = n.id + WHERE n.source_id = $1 + AND p.equipment_id = $2 + ORDER BY p.created_at + "#, + ) + .bind(source_id) + .bind(equipment_id) + .fetch_all(pool) + .await + } else { + sqlx::query_as::<_, Point>( + r#" + SELECT p.* + FROM point p + INNER JOIN node n ON p.node_id = n.id + WHERE n.source_id = $1 + AND p.equipment_id = $2 + ORDER BY p.created_at + LIMIT $3 OFFSET $4 + "#, + ) + .bind(source_id) + .bind(equipment_id) + .bind(page_size as i64) + .bind(offset as i64) + .fetch_all(pool) + .await + } + } + (Some(source_id), None) => { if page_size == 0 { Ok(vec![]) } else if page_size == -1 { @@ -164,7 +225,37 @@ pub async fn get_points_paginated( .await } } - None => { + (None, Some(equipment_id)) => { + if page_size == 0 { + Ok(vec![]) + } else if page_size == -1 { + sqlx::query_as::<_, Point>( + r#" + SELECT * FROM point + WHERE equipment_id = $1 + ORDER BY created_at + "#, + ) + .bind(equipment_id) + .fetch_all(pool) + .await + } else { + sqlx::query_as::<_, Point>( + r#" + SELECT * FROM point + WHERE equipment_id = $1 + ORDER BY created_at + LIMIT $2 OFFSET $3 + "#, + ) + .bind(equipment_id) + .bind(page_size as i64) + .bind(offset as i64) + .fetch_all(pool) + .await + } + } + (None, None) => { if page_size == 0 { Ok(vec![]) } else if page_size == -1 { diff --git a/web/index.html b/web/index.html index 4b0eed6..911f67a 100644 --- a/web/index.html +++ b/web/index.html @@ -4,13 +4,13 @@ PLC Control - +
PLC Control
- +
Ready
@@ -19,10 +19,14 @@
-

数据源

- +

设备

+
-
+
+ + +
+
@@ -34,15 +38,27 @@ +
+ +
当前筛选: 全部点位
+
已选中 0 个点位
+ + + +
- - + + + - + @@ -52,6 +68,14 @@
+
+

数据源

+ +
+
+
+ +

实时日志

@@ -70,6 +94,38 @@
+ +
名称名称 质量 设备/角色更新时间更新时间
- - - - - - - - -
点位角色操作
-
- -
- - +
+
已选中 0 个点位
+ + +
+ + +
+
+ - + diff --git a/web/js/app.js b/web/js/app.js index 85eedd4..d602063 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -3,29 +3,51 @@ import { openChart, renderChart } from "./chart.js"; import { dom } from "./dom.js"; import { closeApiDocDrawer, openApiDocDrawer } from "./docs.js"; import { + clearEquipmentFilter, clearPointBinding, - closeEquipmentDrawer, + closeEquipmentModal, loadEquipments, - openEquipmentDrawer, + openCreateEquipmentModal, resetEquipmentForm, saveEquipment, } from "./equipment.js"; import { startLogs, startPointSocket } from "./logs.js"; -import { createPoints, loadPoints, loadTree, renderSelectedNodes, savePointBinding } from "./points.js"; +import { + clearBatchBinding, + clearSelectedPoints, + createPoints, + loadPoints, + loadTree, + openBatchBinding, + openPointCreateModal, + renderSelectedNodes, + saveBatchBinding, + savePointBinding, + updatePointFilterSummary, + updateSelectedPointSummary, +} from "./points.js"; import { state } from "./state.js"; -import { browseNodes, loadSources, saveSource } from "./sources.js"; +import { loadSources, saveSource } from "./sources.js"; function bindEvents() { 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.sourceResetBtn.addEventListener("click", () => dom.sourceForm.reset()); dom.equipmentResetBtn.addEventListener("click", resetEquipmentForm); dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments())); - dom.newEquipmentBtn.addEventListener("click", resetEquipmentForm); + dom.newEquipmentBtn.addEventListener("click", openCreateEquipmentModal); + dom.closeEquipmentModalBtn.addEventListener("click", closeEquipmentModal); + dom.clearEquipmentFilterBtn.addEventListener("click", () => withStatus(clearEquipmentFilter())); - dom.browseNodesBtn.addEventListener("click", () => withStatus(browseNodes())); + dom.openPointModalBtn.addEventListener("click", openPointCreateModal); + dom.pointSourceSelect.addEventListener("change", () => { + dom.nodeTree.innerHTML = '
Click "Load Nodes" to fetch node tree
'; + dom.pointSourceNodeCount.textContent = "Nodes: 0"; + }); + dom.browseNodesBtn.addEventListener("click", () => withStatus(loadTree())); dom.refreshTreeBtn.addEventListener("click", () => withStatus(loadTree())); dom.createPointsBtn.addEventListener("click", () => withStatus(createPoints())); dom.closeModalBtn.addEventListener("click", () => dom.pointModal.classList.add("hidden")); @@ -42,8 +64,20 @@ function bindEvents() { dom.pointBindingModal.classList.add("hidden"); }); - dom.openEquipmentDrawerBtn.addEventListener("click", openEquipmentDrawer); - dom.closeEquipmentDrawerBtn.addEventListener("click", closeEquipmentDrawer); + 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.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer())); dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer); @@ -81,6 +115,8 @@ function bindEvents() { async function bootstrap() { bindEvents(); renderSelectedNodes(); + updateSelectedPointSummary(); + updatePointFilterSummary(); renderChart(); startLogs(); startPointSocket(); diff --git a/web/js/chart.js b/web/js/chart.js index 049d65d..8b0faae 100644 --- a/web/js/chart.js +++ b/web/js/chart.js @@ -2,23 +2,54 @@ import { apiFetch } from "./api.js"; import { dom } from "./dom.js"; import { state } from "./state.js"; +function normalizeChartItem(item) { + return { + timestamp: item?.timestamp || "", + valueNumber: typeof item?.value_number === "number" ? item.value_number : null, + valueText: item?.value_text || "", + }; +} + export async function openChart(pointId, pointName) { state.chartPointId = pointId; - state.chartPointName = pointName || "点位"; - dom.chartTitle.textContent = `${state.chartPointName} 曲线`; + state.chartPointName = pointName || "Point"; + dom.chartTitle.textContent = `${state.chartPointName} Chart`; const items = await apiFetch(`/api/point/${pointId}/history?limit=120`); state.chartData = (items || []) - .map((item) => ({ - timestamp: item.timestamp || "", - valueNumber: typeof item.value_number === "number" ? item.value_number : null, - valueText: item.value_text || "", - })) + .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; @@ -29,7 +60,7 @@ export function renderChart() { ctx.fillStyle = "#94a3b8"; ctx.font = "14px Segoe UI"; ctx.fillText("Click a point row to view its chart", 24, 40); - dom.chartSummary.textContent = "点击上方点位表中的一行查看曲线"; + dom.chartSummary.textContent = "Click a point row to view its chart"; return; } @@ -62,5 +93,5 @@ export function renderChart() { ctx.stroke(); const latest = state.chartData[state.chartData.length - 1]; - dom.chartSummary.textContent = `最近 ${state.chartData.length} 个点,当前值 ${latest.valueText || latest.valueNumber}`; + dom.chartSummary.textContent = `Latest ${state.chartData.length} points, current value ${latest.valueText || latest.valueNumber}`; } diff --git a/web/js/dom.js b/web/js/dom.js index 5cf2ea4..e6f3a8c 100644 --- a/web/js/dom.js +++ b/web/js/dom.js @@ -7,14 +7,21 @@ export const dom = { pointList: byId("pointList"), pointsPageInfo: byId("pointsPageInfo"), selectedCount: byId("selectedCount"), + selectedPointCount: byId("selectedPointCount"), + pointFilterSummary: byId("pointFilterSummary"), + clearEquipmentFilterBtn: byId("clearEquipmentFilter"), + pointSourceSelect: byId("pointSourceSelect"), + pointSourceNodeCount: byId("pointSourceNodeCount"), + openPointModalBtn: byId("openPointModal"), logView: byId("logView"), chartCanvas: byId("chartCanvas"), chartTitle: byId("chartTitle"), chartSummary: byId("chartSummary"), pointModal: byId("pointModal"), sourceModal: byId("sourceModal"), + equipmentModal: byId("equipmentModal"), pointBindingModal: byId("pointBindingModal"), - equipmentDrawer: byId("equipmentDrawer"), + batchBindingModal: byId("batchBindingModal"), apiDocDrawer: byId("apiDocDrawer"), sourceForm: byId("sourceForm"), sourceId: byId("sourceId"), @@ -31,16 +38,18 @@ export const dom = { equipmentResetBtn: byId("equipmentReset"), equipmentKeyword: byId("equipmentKeyword"), equipmentList: byId("equipmentList"), - equipmentPointList: byId("equipmentPointList"), + closeEquipmentModalBtn: byId("closeEquipmentModal"), 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"), - openEquipmentDrawerBtn: byId("openEquipmentDrawer"), - closeEquipmentDrawerBtn: byId("closeEquipmentDrawer"), openApiDocBtn: byId("openApiDoc"), closeApiDocBtn: byId("closeApiDoc"), refreshChartBtn: byId("refreshChart"), @@ -56,4 +65,9 @@ export const dom = { closeSourceModalBtn: byId("closeSourceModal"), clearPointBindingBtn: byId("clearPointBinding"), closePointBindingModalBtn: byId("closePointBindingModal"), + toggleAllPoints: byId("toggleAllPoints"), + openBatchBindingBtn: byId("openBatchBinding"), + clearSelectedPointsBtn: byId("clearSelectedPoints"), + closeBatchBindingModalBtn: byId("closeBatchBindingModal"), + clearBatchBindingBtn: byId("clearBatchBinding"), }; diff --git a/web/js/equipment.js b/web/js/equipment.js index f54003f..269beb3 100644 --- a/web/js/equipment.js +++ b/web/js/equipment.js @@ -1,14 +1,15 @@ import { apiFetch } from "./api.js"; import { dom } from "./dom.js"; -import { loadPoints, openPointBinding } from "./points.js"; +import { 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; } -export function renderEquipmentOptions(selected = "") { - const options = ['']; +export function renderBindingEquipmentOptions(selected = "", target = dom.bindingEquipmentId) { + const options = ['']; state.equipments.forEach((item) => { const equipment = equipmentOf(item); const isSelected = equipment.id === selected ? "selected" : ""; @@ -16,67 +17,113 @@ export function renderEquipmentOptions(selected = "") { ``, ); }); - dom.bindingEquipmentId.innerHTML = options.join(""); + target.innerHTML = options.join(""); +} + +export function renderBatchBindingDefaults() { + renderBindingEquipmentOptions("", dom.batchBindingEquipmentId); + dom.batchBindingSignalRole.innerHTML = renderRoleOptions(""); } export function resetEquipmentForm() { - state.selectedEquipmentId = null; dom.equipmentForm.reset(); dom.equipmentId.value = ""; - dom.equipmentPointList.innerHTML = - '请选择设备'; +} + +function openEquipmentModal() { + dom.equipmentModal.classList.remove("hidden"); +} + +export function closeEquipmentModal() { + dom.equipmentModal.classList.add("hidden"); +} + +export function openCreateEquipmentModal() { + resetEquipmentForm(); + openEquipmentModal(); +} + +function openEditEquipmentModal(equipment) { + dom.equipmentId.value = equipment.id || ""; + dom.equipmentCode.value = equipment.code || ""; + dom.equipmentName.value = equipment.name || ""; + dom.equipmentKind.value = 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 activeEquipment = state.selectedEquipmentId + ? state.equipmentMap.get(state.selectedEquipmentId) || null + : null; + dom.clearEquipmentFilterBtn.textContent = activeEquipment + ? `设备筛选: ${activeEquipment.name}` + : "设备筛选: 全部"; if (!state.equipments.length) { - dom.equipmentList.innerHTML = '
暂无设备
'; - dom.equipmentPointList.innerHTML = - '暂无设备点位'; + dom.equipmentList.innerHTML = '
No equipment
'; return; } state.equipments.forEach((item) => { const equipment = equipmentOf(item); const box = document.createElement("div"); - box.className = `list-item ${state.selectedEquipmentId === equipment.id ? "selected" : ""}`; + box.className = `list-item equipment-card ${state.selectedEquipmentId === equipment.id ? "selected" : ""}`; box.innerHTML = `
${equipment.code} ${item.point_count ?? 0} pts
${equipment.name}
-
${equipment.kind || "未设置类型"}
+
${equipment.kind || "No type"}
+
`; box.addEventListener("click", () => { - state.selectedEquipmentId = equipment.id; - dom.equipmentId.value = equipment.id || ""; - dom.equipmentCode.value = equipment.code || ""; - dom.equipmentName.value = equipment.name || ""; - dom.equipmentKind.value = equipment.kind || ""; - dom.equipmentDescription.value = equipment.description || ""; - renderEquipments(); - loadEquipmentPoints(equipment.id).catch((error) => { + selectEquipment(equipment.id).catch((error) => { dom.statusText.textContent = error.message; }); }); - const actions = document.createElement("div"); - actions.className = "row"; + const actionRow = box.querySelector(".equipment-card-actions"); + + const editBtn = document.createElement("button"); + editBtn.className = "secondary"; + editBtn.textContent = "Edit"; + editBtn.addEventListener("click", (event) => { + event.stopPropagation(); + openEditEquipmentModal(equipment); + }); const deleteBtn = document.createElement("button"); deleteBtn.className = "danger"; - deleteBtn.textContent = "删除"; + deleteBtn.textContent = "Delete"; deleteBtn.addEventListener("click", (event) => { event.stopPropagation(); deleteEquipment(equipment.id).catch((error) => { dom.statusText.textContent = error.message; }); }); - actions.appendChild(deleteBtn); - box.appendChild(actions); + + actionRow.append(editBtn, deleteBtn); dom.equipmentList.appendChild(box); }); } @@ -94,54 +141,13 @@ export async function loadEquipments() { return [equipment.id, equipment]; }), ); - renderEquipmentOptions(); + renderBindingEquipmentOptions(); + renderBatchBindingDefaults(); + if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) { + state.selectedEquipmentId = null; + } renderEquipments(); - - if (state.selectedEquipmentId && state.equipmentMap.has(state.selectedEquipmentId)) { - await loadEquipmentPoints(state.selectedEquipmentId); - } -} - -export async function loadEquipmentPoints(equipmentId) { - const points = await apiFetch(`/api/equipment/${equipmentId}/points`); - dom.equipmentPointList.innerHTML = ""; - - if (!points.length) { - dom.equipmentPointList.innerHTML = - '该设备下暂无点位'; - return; - } - - points.forEach((point) => { - const tr = document.createElement("tr"); - tr.innerHTML = ` - -
${point.name}
-
${point.node_id}
- - ${point.signal_role || "--"} - - `; - - const actionCell = tr.lastElementChild; - - const editBtn = document.createElement("button"); - editBtn.className = "secondary"; - editBtn.textContent = "编辑绑定"; - editBtn.addEventListener("click", () => openPointBinding(point)); - - const clearBtn = document.createElement("button"); - clearBtn.className = "secondary"; - clearBtn.textContent = "解绑"; - clearBtn.addEventListener("click", () => { - clearPointBinding(point.id).catch((error) => { - dom.statusText.textContent = error.message; - }); - }); - - actionCell.append(editBtn, clearBtn); - dom.equipmentPointList.appendChild(tr); - }); + updatePointFilterSummary(); } export async function saveEquipment(event) { @@ -155,37 +161,37 @@ export async function saveEquipment(event) { }; const id = dom.equipmentId.value; - await apiFetch(id ? `/api/equipment/${id}` : "/api/equipment", { + 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("确认删除该设备?")) { + if (!window.confirm("Delete this equipment?")) { return; } await apiFetch(`/api/equipment/${equipmentId}`, { method: "DELETE" }); + if (state.selectedEquipmentId === equipmentId) { + state.selectedEquipmentId = null; + } resetEquipmentForm(); + closeEquipmentModal(); + clearSelectedPoints(); await loadEquipments(); await loadPoints(); } -export function openEquipmentDrawer() { - dom.equipmentDrawer.classList.remove("hidden"); - loadEquipments().catch((error) => { - dom.statusText.textContent = error.message; - }); -} - -export function closeEquipmentDrawer() { - dom.equipmentDrawer.classList.add("hidden"); -} - export async function clearPointBinding(pointId = dom.bindingPointId.value) { await apiFetch(`/api/point/${pointId}`, { method: "PUT", diff --git a/web/js/logs.js b/web/js/logs.js index f79a962..78f6adf 100644 --- a/web/js/logs.js +++ b/web/js/logs.js @@ -1,13 +1,54 @@ +import { appendChartPoint } from "./chart.js"; import { dom } from "./dom.js"; import { formatValue } from "./points.js"; import { state } from "./state.js"; +function escapeHtml(text) { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function parseLogLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return null; + } + + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + export function appendLog(line) { + const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10; const div = document.createElement("div"); - div.className = "log-line"; - div.textContent = line; + 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 = [ + `${escapeHtml(levelRaw || "LOG")}`, + parsed.timestamp ? ` ${escapeHtml(parsed.timestamp)}` : "", + parsed.target ? ` ${escapeHtml(parsed.target)}` : "", + `${escapeHtml( + parsed.fields?.message || parsed.message || parsed.msg || line, + )}`, + ].join(""); + } + dom.logView.appendChild(div); - dom.logView.scrollTop = dom.logView.scrollHeight; + if (atBottom) { + dom.logView.scrollTop = dom.logView.scrollHeight; + } } export function startLogs() { @@ -20,6 +61,9 @@ export function startLogs() { const data = JSON.parse(event.data); (data.lines || []).forEach(appendLog); }); + state.logSource.addEventListener("error", () => { + appendLog("[log stream error]"); + }); } export function startPointSocket() { @@ -36,14 +80,16 @@ export function startPointSocket() { const data = payload.data; const entry = state.pointEls.get(data.point_id); - if (!entry) { - return; + 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 || "--"; } - 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 || "--"; + if (state.chartPointId === data.point_id) { + appendChartPoint(data); + } } catch { // ignore malformed messages } diff --git a/web/js/points.js b/web/js/points.js index 2ba667c..426afff 100644 --- a/web/js/points.js +++ b/web/js/points.js @@ -1,9 +1,19 @@ import { apiFetch } from "./api.js"; import { openChart } from "./chart.js"; import { dom } from "./dom.js"; -import { loadEquipments, renderEquipmentOptions } from "./equipment.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, summary").length; + dom.pointSourceNodeCount.textContent = `Nodes: ${count}`; +} + export function formatValue(monitor) { if (!monitor) { return "--"; @@ -18,7 +28,39 @@ export function formatValue(monitor) { } export function renderSelectedNodes() { - dom.selectedCount.textContent = `已选中 ${state.selectedNodeIds.size} 个节点`; + dom.selectedCount.textContent = `Selected ${state.selectedNodeIds.size} nodes`; +} + +export function updateSelectedPointSummary() { + const count = state.selectedPointIds.size; + dom.selectedPointCount.textContent = `Selected ${count} points`; + dom.batchBindingSummary.textContent = `Selected ${count} points`; + dom.openBatchBindingBtn.disabled = count === 0; +} + +export function updatePointFilterSummary() { + const filters = []; + if (state.selectedEquipmentId) { + const equipment = state.equipmentMap.get(state.selectedEquipmentId); + filters.push(`Equipment:${equipment?.name || equipment?.code || "Unknown"}`); + } + if (state.selectedSourceId) { + const source = state.sources.find((item) => item.id === state.selectedSourceId); + filters.push(`Source:${source?.name || "Unknown"}`); + } + + dom.pointFilterSummary.textContent = filters.length + ? `Current filter: ${filters.join(" / ")}` + : "Current filter: All points"; +} + +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) { @@ -55,15 +97,30 @@ function renderNode(node) { return details; } +export function openPointCreateModal() { + dom.pointModal.classList.remove("hidden"); + if (dom.pointSourceSelect) { + dom.pointSourceSelect.value = state.selectedSourceId || ""; + } + dom.nodeTree.innerHTML = '
Select a source and load nodes
'; + dom.pointSourceNodeCount.textContent = "Nodes: 0"; + state.selectedNodeIds.clear(); + renderSelectedNodes(); +} + export async function loadTree() { - if (!state.selectedSourceId) { - dom.nodeTree.innerHTML = '
请选择数据源
'; + const sourceId = dom.pointSourceSelect.value || state.selectedSourceId; + if (!sourceId) { + dom.nodeTree.innerHTML = '
Select a source
'; + dom.pointSourceNodeCount.textContent = "Nodes: 0"; return; } - const data = await apiFetch(`/api/source/${state.selectedSourceId}/node-tree`); + 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 createPoints() { @@ -82,20 +139,38 @@ export async function createPoints() { await loadPoints(); } -export async function loadPoints() { - const sourceQuery = state.selectedSourceId ? `&source_id=${state.selectedSourceId}` : ""; - const data = await apiFetch( - `/api/point?page=${state.pointsPage}&page_size=${state.pointsPageSize}${sourceQuery}`, - ); +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 = '暂无点位'; + dom.pointList.innerHTML = 'No points'; dom.pointsPageInfo.textContent = `${state.pointsPage} / 1`; + clearSelectedPoints(); + updatePointFilterSummary(); return; } @@ -112,6 +187,7 @@ export async function loadPoints() { }); tr.innerHTML = ` +
${point.name}
${point.node_id}
@@ -120,7 +196,7 @@ export async function loadPoints() { ${(monitor?.quality || "unknown").toUpperCase()}
-
${equipment ? equipment.name : '未绑定'}
+
${equipment ? equipment.name : 'Unbound'}
${point.signal_role || "--"}
@@ -128,11 +204,19 @@ export async function loadPoints() { `; - const actionCell = tr.lastElementChild; + 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; const bindBtn = document.createElement("button"); bindBtn.className = "secondary"; - bindBtn.textContent = "绑定"; + bindBtn.textContent = "Bind"; bindBtn.addEventListener("click", (event) => { event.stopPropagation(); openPointBinding(point); @@ -140,7 +224,7 @@ export async function loadPoints() { const deleteBtn = document.createElement("button"); deleteBtn.className = "danger"; - deleteBtn.textContent = "删除"; + deleteBtn.textContent = "Delete"; deleteBtn.addEventListener("click", (event) => { event.stopPropagation(); deletePoint(point.id).catch((error) => { @@ -155,19 +239,24 @@ export async function loadPoints() { row: tr, value: tr.querySelector(".point-value"), quality: tr.querySelector(".badge"), - time: tr.querySelector("td:nth-child(5) .muted"), + 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.bindingSignalRole.value = point.signal_role || ""; - renderEquipmentOptions(point.equipment_id || ""); + renderBindingEquipmentOptions(point.equipment_id || ""); + dom.bindingSignalRole.innerHTML = renderRoleOptions(point.signal_role || ""); dom.pointBindingModal.classList.remove("hidden"); } @@ -178,7 +267,7 @@ export async function savePointBinding(event) { method: "PUT", body: JSON.stringify({ equipment_id: dom.bindingEquipmentId.value || null, - signal_role: dom.bindingSignalRole.value.trim() || null, + signal_role: dom.bindingSignalRole.value || null, }), }); @@ -187,11 +276,63 @@ export async function savePointBinding(event) { 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("确认删除该点位?")) { + if (!window.confirm("Delete this point?")) { return; } await apiFetch(`/api/point/${pointId}`, { method: "DELETE" }); + state.selectedPointIds.delete(pointId); await loadPoints(); } diff --git a/web/js/roles.js b/web/js/roles.js new file mode 100644 index 0000000..bb2aeb8 --- /dev/null +++ b/web/js/roles.js @@ -0,0 +1,24 @@ +export const SIGNAL_ROLE_OPTIONS = [ + { value: "", label: "Unset" }, + { value: "remote_status", label: "Remote Status" }, + { value: "run_status", label: "Run Status" }, + { value: "fault_status", label: "Fault Status" }, + { value: "ready_status", label: "Ready Status" }, + { value: "alarm_status", label: "Alarm Status" }, + { value: "interlock_status", label: "Interlock Status" }, + { value: "auto_enable", label: "Auto Enable" }, + { value: "mode_auto", label: "Auto Mode" }, + { value: "mode_manual", label: "Manual Mode" }, + { value: "start_cmd", label: "Start Command" }, + { value: "stop_cmd", label: "Stop Command" }, + { value: "reset_cmd", label: "Reset Command" }, + { value: "runtime_value", label: "Runtime Value" }, + { value: "counter_value", label: "Counter Value" }, +]; + +export function renderRoleOptions(selected = "") { + return SIGNAL_ROLE_OPTIONS.map((item) => { + const isSelected = item.value === selected ? "selected" : ""; + return ``; + }).join(""); +} diff --git a/web/js/sources.js b/web/js/sources.js index 0725130..2450b84 100644 --- a/web/js/sources.js +++ b/web/js/sources.js @@ -1,48 +1,47 @@ import { apiFetch } from "./api.js"; import { dom } from "./dom.js"; -import { loadPoints, loadTree, renderSelectedNodes } from "./points.js"; +import { loadPoints, updatePointFilterSummary } from "./points.js"; import { state } from "./state.js"; +function renderPointSourceOptions() { + if (!dom.pointSourceSelect) { + return; + } + + const options = ['']; + state.sources.forEach((source) => { + const selected = source.id === state.selectedSourceId ? "selected" : ""; + options.push(``); + }); + dom.pointSourceSelect.innerHTML = options.join(""); +} + export function renderSources() { dom.sourceList.innerHTML = ""; state.sources.forEach((source) => { - const item = document.createElement("div"); - item.className = `list-item ${state.selectedSourceId === source.id ? "selected" : ""}`; - item.innerHTML = ` + const card = document.createElement("div"); + card.className = `list-item source-card ${state.selectedSourceId === source.id ? "selected" : ""}`; + card.innerHTML = `
${source.name} - ${source.is_connected ? "在线" : "离线"} + ${source.is_connected ? "ONLINE" : "OFFLINE"}
${source.endpoint}
+
`; - item.addEventListener("click", () => { + card.addEventListener("click", () => { selectSource(source.id).catch((error) => { dom.statusText.textContent = error.message; }); }); - const actions = document.createElement("div"); - actions.className = "row"; - - const selectBtn = document.createElement("button"); - selectBtn.textContent = "选入点位"; - selectBtn.addEventListener("click", (event) => { - event.stopPropagation(); - selectSource(source.id) - .then(() => { - dom.pointModal.classList.remove("hidden"); - return loadTree(); - }) - .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.textContent = "Edit"; editBtn.addEventListener("click", (event) => { event.stopPropagation(); dom.sourceId.value = source.id; @@ -54,7 +53,7 @@ export function renderSources() { const reconnectBtn = document.createElement("button"); reconnectBtn.className = "secondary"; - reconnectBtn.textContent = "重连"; + reconnectBtn.textContent = "Reconnect"; reconnectBtn.addEventListener("click", (event) => { event.stopPropagation(); reconnectSource(source.id, source.name).catch((error) => { @@ -64,7 +63,7 @@ export function renderSources() { const deleteBtn = document.createElement("button"); deleteBtn.className = "danger"; - deleteBtn.textContent = "删除"; + deleteBtn.textContent = "Delete"; deleteBtn.addEventListener("click", (event) => { event.stopPropagation(); deleteSource(source.id).catch((error) => { @@ -72,25 +71,30 @@ export function renderSources() { }); }); - actions.append(selectBtn, editBtn, reconnectBtn, deleteBtn); - item.appendChild(actions); - dom.sourceList.appendChild(item); + 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 = sourceId; + state.selectedSourceId = state.selectedSourceId === sourceId ? null : sourceId; state.selectedNodeIds.clear(); state.pointsPage = 1; renderSources(); - renderSelectedNodes(); + updatePointFilterSummary(); await loadPoints(); - await loadTree(); } export async function saveSource(event) { @@ -113,27 +117,22 @@ export async function saveSource(event) { await loadSources(); } -export async function deleteSource(sourceId) { - if (!window.confirm("确认删除该 Source?")) { - return; - } - - await apiFetch(`/api/source/${sourceId}`, { method: "DELETE" }); - await loadSources(); -} - export async function reconnectSource(sourceId, name) { - dom.statusText.textContent = `正在重连 ${name || "Source"}...`; + dom.statusText.textContent = `Reconnecting ${name || "Source"}...`; await apiFetch(`/api/source/${sourceId}/reconnect`, { method: "POST" }); await loadSources(); dom.statusText.textContent = "Ready"; } -export async function browseNodes() { - if (!state.selectedSourceId) { - throw new Error("请先选择数据源"); +export async function deleteSource(sourceId) { + if (!window.confirm("Delete this source?")) { + return; } - await apiFetch(`/api/source/${state.selectedSourceId}/browse`, { method: "POST" }); - await loadTree(); + await apiFetch(`/api/source/${sourceId}`, { method: "DELETE" }); + if (state.selectedSourceId === sourceId) { + state.selectedSourceId = null; + } + await loadSources(); + await loadPoints(); } diff --git a/web/js/state.js b/web/js/state.js index 7d57bf7..6d01832 100644 --- a/web/js/state.js +++ b/web/js/state.js @@ -5,6 +5,7 @@ export const state = { selectedEquipmentId: null, selectedSourceId: null, selectedNodeIds: new Set(), + selectedPointIds: new Set(), pointsPage: 1, pointsPageSize: 100, pointsTotal: 0, diff --git a/web/styles.css b/web/styles.css index 4fecdc2..a3d0141 100644 --- a/web/styles.css +++ b/web/styles.css @@ -81,7 +81,7 @@ body { .grid { display: grid; - grid-template-columns: 340px minmax(0, 3fr) minmax(0, 2fr); + grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr); grid-template-rows: 1fr 380px; gap: 1px; height: calc(100vh - var(--topbar-h)); @@ -89,18 +89,9 @@ body { .panel.top-left { grid-column: 1; grid-row: 1; } .panel.top-right { grid-column: 2 / 4; grid-row: 1; } - -.panel.bottom-left { - grid-column: 1 / 3; - grid-row: 2; - min-height: 0; -} - -.panel.bottom-right { - grid-column: 3; - grid-row: 2; - min-height: 0; -} +.panel.bottom-left { grid-column: 1; grid-row: 2; min-height: 0; } +.panel.bottom-middle { grid-column: 2; grid-row: 2; min-height: 0; } +.panel.bottom-right { grid-column: 3; grid-row: 2; min-height: 0; } .panel { background: var(--surface); @@ -213,6 +204,28 @@ button.danger:hover { background: var(--danger-hover); } gap: 6px; } +.equipment-list { + padding-top: 0; +} + +.equipment-card, +.source-card { + background: var(--surface); +} + +.equipment-card-actions, +.source-card-actions { + padding-top: 4px; +} + +.source-panels { + display: grid; + grid-template-columns: 1fr; + gap: 6px; + padding: 8px; + overflow: auto; +} + .list-item button { padding: 2px 8px; font-size: 11px; @@ -353,6 +366,34 @@ button.danger:hover { background: var(--danger-hover); } flex-shrink: 0; } +.point-batch-toolbar { + align-items: center; + justify-content: flex-end; + padding: 8px 12px; + border-bottom: 1px solid var(--border-light); + flex-wrap: wrap; +} + +.compact-check { + margin-right: auto; +} + +.compact-check input { + margin: 0; +} + +.equipment-toolbar { + padding: 8px 12px 0; +} + +.equipment-toolbar input { + flex: 1; + min-width: 0; + padding: 7px 10px; + border: 1px solid var(--border); + background: var(--surface); +} + /* ── Form ─────────────────────────────────────────── */ .form { @@ -660,6 +701,10 @@ button.danger:hover { background: var(--danger-hover); } flex-direction: column; } +.equipment-role-hint { + padding: 8px 14px 0; +} + .compact-table td, .compact-table th { padding-top: 6px; @@ -677,6 +722,13 @@ button.danger:hover { background: var(--danger-hover); } color: var(--text-3); } +.inline-select { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--border); + background: var(--surface); +} + .doc-toc { border-right: 1px solid var(--border-light); background: var(--surface-2); @@ -872,14 +924,15 @@ button.danger:hover { background: var(--danger-hover); } @media (max-width: 900px) { .grid { grid-template-columns: 1fr; - grid-template-rows: auto 1fr auto auto; + grid-template-rows: auto auto auto auto; height: auto; } body { height: auto; overflow: auto; } .panel.top-left { min-height: 200px; } .panel.top-right { min-height: 300px; } .panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; } - .panel.bottom-right { grid-column: 1; grid-row: 4; min-height: 320px; } + .panel.bottom-middle { grid-column: 1; grid-row: 4; min-height: 200px; } + .panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; } .drawer { width: 100vw; } .drawer-body { grid-template-columns: 1fr; } .equipment-layout { grid-template-columns: 1fr; }