feat(web): reorganize equipment layout and point flows
This commit is contained in:
parent
06ace5e67d
commit
fec7b60d6b
|
|
@ -24,6 +24,7 @@ use crate::{
|
|||
#[derive(Deserialize, Validate)]
|
||||
pub struct GetPointListQuery {
|
||||
pub source_id: Option<Uuid>,
|
||||
pub equipment_id: Option<Uuid>,
|
||||
#[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<Uuid>,
|
||||
pub equipment_id: Option<Uuid>,
|
||||
pub signal_role: Option<String>,
|
||||
}
|
||||
|
||||
/// Update point metadata (name/description/unit only).
|
||||
|
|
@ -344,9 +347,17 @@ 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)"#)
|
||||
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?;
|
||||
|
|
|
|||
|
|
@ -102,9 +102,25 @@ pub async fn get_points_with_ids(
|
|||
pub async fn get_points_count(
|
||||
pool: &PgPool,
|
||||
source_id: Option<uuid::Uuid>,
|
||||
equipment_id: Option<uuid::Uuid>,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
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<uuid::Uuid>,
|
||||
equipment_id: Option<uuid::Uuid>,
|
||||
page_size: i32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<Point>, 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 {
|
||||
|
|
|
|||
151
web/index.html
151
web/index.html
|
|
@ -4,13 +4,13 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PLC Control</title>
|
||||
<link rel="stylesheet" href="/ui/styles.css?v=20260323b" />
|
||||
<link rel="stylesheet" href="/ui/styles.css?v=20260323e" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="title">PLC Control</div>
|
||||
<div class="topbar-actions">
|
||||
<button type="button" class="secondary" id="openEquipmentDrawer">设备</button>
|
||||
<button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button>
|
||||
<button type="button" class="secondary" id="openApiDoc">API.md</button>
|
||||
<div class="status" id="statusText">Ready</div>
|
||||
</div>
|
||||
|
|
@ -19,10 +19,14 @@
|
|||
<main class="grid">
|
||||
<section class="panel top-left">
|
||||
<div class="panel-head">
|
||||
<h2>数据源</h2>
|
||||
<button id="openSourceForm">+ 新增</button>
|
||||
<h2>设备</h2>
|
||||
<button type="button" id="newEquipmentBtn">+ 新增</button>
|
||||
</div>
|
||||
<div class="list" id="sourceList"></div>
|
||||
<div class="toolbar equipment-toolbar">
|
||||
<input id="equipmentKeyword" placeholder="搜索编码或名称" />
|
||||
<button type="button" class="secondary" id="refreshEquipmentBtn">刷新</button>
|
||||
</div>
|
||||
<div class="list equipment-list" id="equipmentList"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel top-right">
|
||||
|
|
@ -34,15 +38,27 @@
|
|||
<button class="secondary" id="nextPoints" title="下一页">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar point-batch-toolbar">
|
||||
<label class="check-row compact-check">
|
||||
<input type="checkbox" id="toggleAllPoints" />
|
||||
<span>本页全选</span>
|
||||
</label>
|
||||
<div class="muted" id="pointFilterSummary">当前筛选: 全部点位</div>
|
||||
<div class="muted" id="selectedPointCount">已选中 0 个点位</div>
|
||||
<button type="button" class="secondary" id="openPointModal">选入节点</button>
|
||||
<button type="button" class="secondary" id="openBatchBinding">批量绑定设备</button>
|
||||
<button type="button" class="secondary" id="clearSelectedPoints">清空选择</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:24%">名称</th>
|
||||
<th style="width:18%">值</th>
|
||||
<th style="width:6%"></th>
|
||||
<th style="width:22%">名称</th>
|
||||
<th style="width:16%">值</th>
|
||||
<th style="width:10%">质量</th>
|
||||
<th style="width:18%">设备/角色</th>
|
||||
<th style="width:23%">更新时间</th>
|
||||
<th style="width:21%">更新时间</th>
|
||||
<th style="width:7%"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -52,6 +68,14 @@
|
|||
</section>
|
||||
|
||||
<section class="panel bottom-left">
|
||||
<div class="panel-head">
|
||||
<h2>数据源</h2>
|
||||
<button id="openSourceForm">+ 新增</button>
|
||||
</div>
|
||||
<div class="source-panels" id="sourceList"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel bottom-middle">
|
||||
<div class="panel-head">
|
||||
<h2>实时日志</h2>
|
||||
</div>
|
||||
|
|
@ -70,6 +94,38 @@
|
|||
</section>
|
||||
</main>
|
||||
|
||||
<div class="modal hidden" id="equipmentModal">
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>设备配置</h3>
|
||||
<button class="secondary" id="closeEquipmentModal">X</button>
|
||||
</div>
|
||||
<form id="equipmentForm" class="form">
|
||||
<input type="hidden" id="equipmentId" />
|
||||
<label>
|
||||
编码
|
||||
<input id="equipmentCode" required />
|
||||
</label>
|
||||
<label>
|
||||
名称
|
||||
<input id="equipmentName" required />
|
||||
</label>
|
||||
<label>
|
||||
类型
|
||||
<input id="equipmentKind" placeholder="coal_feeder / distributor" />
|
||||
</label>
|
||||
<label>
|
||||
说明
|
||||
<input id="equipmentDescription" />
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" id="equipmentReset">清空</button>
|
||||
<button type="submit" id="equipmentSubmit">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden" id="pointModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-head">
|
||||
|
|
@ -77,7 +133,9 @@
|
|||
<button class="secondary" id="closeModal">X</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button id="browseNodes">浏览并同步节点</button>
|
||||
<select id="pointSourceSelect"></select>
|
||||
<div class="muted" id="pointSourceNodeCount">Nodes: 0</div>
|
||||
<button id="browseNodes">加载节点</button>
|
||||
<button class="secondary" id="refreshTree">刷新树</button>
|
||||
</div>
|
||||
<div class="tree" id="nodeTree"></div>
|
||||
|
|
@ -133,8 +191,8 @@
|
|||
<select id="bindingEquipmentId"></select>
|
||||
</label>
|
||||
<label>
|
||||
角色
|
||||
<input id="bindingSignalRole" placeholder="remote_status / run_status / fault_status" />
|
||||
角色模板
|
||||
<select id="bindingSignalRole"></select>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" id="clearPointBinding">清空绑定</button>
|
||||
|
|
@ -144,71 +202,28 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-backdrop hidden" id="equipmentDrawer">
|
||||
<aside class="drawer equipment-drawer" role="dialog" aria-modal="true" aria-labelledby="equipmentTitle">
|
||||
<div class="drawer-head">
|
||||
<h3 id="equipmentTitle">设备管理</h3>
|
||||
<button type="button" class="secondary" id="closeEquipmentDrawer">关闭</button>
|
||||
<div class="modal hidden" id="batchBindingModal">
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>批量绑定点位</h3>
|
||||
<button class="secondary" id="closeBatchBindingModal">X</button>
|
||||
</div>
|
||||
<div class="drawer-body equipment-layout">
|
||||
<section class="equipment-sidebar">
|
||||
<div class="panel-head">
|
||||
<h2>设备列表</h2>
|
||||
<button type="button" id="newEquipmentBtn">+ 新增</button>
|
||||
</div>
|
||||
<div class="toolbar equipment-toolbar">
|
||||
<input id="equipmentKeyword" placeholder="搜索编码或名称" />
|
||||
<button type="button" class="secondary" id="refreshEquipmentBtn">刷新</button>
|
||||
</div>
|
||||
<div class="list" id="equipmentList"></div>
|
||||
</section>
|
||||
<section class="equipment-content">
|
||||
<div class="panel-head">
|
||||
<h2>设备详情</h2>
|
||||
</div>
|
||||
<form id="equipmentForm" class="form equipment-form">
|
||||
<input type="hidden" id="equipmentId" />
|
||||
<form id="batchBindingForm" class="form">
|
||||
<div class="muted" id="batchBindingSummary">已选中 0 个点位</div>
|
||||
<label>
|
||||
编码
|
||||
<input id="equipmentCode" required />
|
||||
设备
|
||||
<select id="batchBindingEquipmentId"></select>
|
||||
</label>
|
||||
<label>
|
||||
名称
|
||||
<input id="equipmentName" required />
|
||||
</label>
|
||||
<label>
|
||||
类型
|
||||
<input id="equipmentKind" placeholder="coal_feeder / distributor" />
|
||||
</label>
|
||||
<label>
|
||||
说明
|
||||
<input id="equipmentDescription" />
|
||||
角色模板
|
||||
<select id="batchBindingSignalRole"></select>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" id="equipmentReset">清空</button>
|
||||
<button type="submit" id="equipmentSubmit">保存</button>
|
||||
<button type="button" class="secondary" id="clearBatchBinding">清空设备和角色</button>
|
||||
<button type="submit" id="saveBatchBinding">批量保存</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="equipment-points">
|
||||
<div class="panel-head">
|
||||
<h2>设备点位</h2>
|
||||
</div>
|
||||
<div class="table-wrap compact-table">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:42%">点位</th>
|
||||
<th style="width:23%">角色</th>
|
||||
<th style="width:35%">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="equipmentPointList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="drawer-backdrop hidden" id="apiDocDrawer">
|
||||
|
|
@ -227,6 +242,6 @@
|
|||
</aside>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/ui/js/app.js?v=20260323b"></script>
|
||||
<script type="module" src="/ui/js/app.js?v=20260323e"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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 = '<div class="muted">Click "Load Nodes" to fetch node tree</div>';
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = ['<option value="">未绑定</option>'];
|
||||
export function renderBindingEquipmentOptions(selected = "", target = dom.bindingEquipmentId) {
|
||||
const options = ['<option value="">Unbound</option>'];
|
||||
state.equipments.forEach((item) => {
|
||||
const equipment = equipmentOf(item);
|
||||
const isSelected = equipment.id === selected ? "selected" : "";
|
||||
|
|
@ -16,67 +17,113 @@ export function renderEquipmentOptions(selected = "") {
|
|||
`<option value="${equipment.id}" ${isSelected}>${equipment.code} / ${equipment.name}</option>`,
|
||||
);
|
||||
});
|
||||
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 =
|
||||
'<tr><td colspan="3" class="empty-state">请选择设备</td></tr>';
|
||||
}
|
||||
|
||||
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 = '<div class="list-item"><div class="muted">暂无设备</div></div>';
|
||||
dom.equipmentPointList.innerHTML =
|
||||
'<tr><td colspan="3" class="empty-state">暂无设备点位</td></tr>';
|
||||
dom.equipmentList.innerHTML = '<div class="list-item"><div class="muted">No equipment</div></div>';
|
||||
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 = `
|
||||
<div class="row">
|
||||
<strong>${equipment.code}</strong>
|
||||
<span class="badge">${item.point_count ?? 0} pts</span>
|
||||
</div>
|
||||
<div>${equipment.name}</div>
|
||||
<div class="muted">${equipment.kind || "未设置类型"}</div>
|
||||
<div class="muted">${equipment.kind || "No type"}</div>
|
||||
<div class="row equipment-card-actions"></div>
|
||||
`;
|
||||
|
||||
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 =
|
||||
'<tr><td colspan="3" class="empty-state">该设备下暂无点位</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
points.forEach((point) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<div class="point-name">${point.name}</div>
|
||||
<div class="point-id">${point.node_id}</div>
|
||||
</td>
|
||||
<td>${point.signal_role || "--"}</td>
|
||||
<td></td>
|
||||
`;
|
||||
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
const parsed = parseLogLine(line);
|
||||
|
||||
if (!parsed) {
|
||||
div.className = "log-line";
|
||||
div.textContent = line;
|
||||
} else {
|
||||
const levelRaw = (parsed.level || "").toString();
|
||||
const level = levelRaw.toLowerCase();
|
||||
div.className = `log-line${level ? ` level-${level}` : ""}`;
|
||||
div.innerHTML = [
|
||||
`<span class="level">${escapeHtml(levelRaw || "LOG")}</span>`,
|
||||
parsed.timestamp ? `<span class="muted"> ${escapeHtml(parsed.timestamp)}</span>` : "",
|
||||
parsed.target ? `<span class="muted"> ${escapeHtml(parsed.target)}</span>` : "",
|
||||
`<span class="message">${escapeHtml(
|
||||
parsed.fields?.message || parsed.message || parsed.msg || line,
|
||||
)}</span>`,
|
||||
].join("");
|
||||
}
|
||||
|
||||
dom.logView.appendChild(div);
|
||||
if (atBottom) {
|
||||
dom.logView.scrollTop = dom.logView.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
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 || "--";
|
||||
}
|
||||
|
||||
if (state.chartPointId === data.point_id) {
|
||||
appendChartPoint(data);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
|
|
|
|||
181
web/js/points.js
181
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 = '<div class="muted">Select a source and load nodes</div>';
|
||||
dom.pointSourceNodeCount.textContent = "Nodes: 0";
|
||||
state.selectedNodeIds.clear();
|
||||
renderSelectedNodes();
|
||||
}
|
||||
|
||||
export async function loadTree() {
|
||||
if (!state.selectedSourceId) {
|
||||
dom.nodeTree.innerHTML = '<div class="muted">请选择数据源</div>';
|
||||
const sourceId = dom.pointSourceSelect.value || state.selectedSourceId;
|
||||
if (!sourceId) {
|
||||
dom.nodeTree.innerHTML = '<div class="muted">Select a source</div>';
|
||||
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 = '<tr><td colspan="6" class="empty-state">暂无点位</td></tr>';
|
||||
dom.pointList.innerHTML = '<tr><td colspan="7" class="empty-state">No points</td></tr>';
|
||||
dom.pointsPageInfo.textContent = `${state.pointsPage} / 1`;
|
||||
clearSelectedPoints();
|
||||
updatePointFilterSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +187,7 @@ export async function loadPoints() {
|
|||
});
|
||||
|
||||
tr.innerHTML = `
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="point-name">${point.name}</div>
|
||||
<div class="point-id">${point.node_id}</div>
|
||||
|
|
@ -120,7 +196,7 @@ export async function loadPoints() {
|
|||
<td><span class="badge quality-${(monitor?.quality || "unknown").toLowerCase()}">${(monitor?.quality || "unknown").toUpperCase()}</span></td>
|
||||
<td>
|
||||
<div class="point-meta">
|
||||
<div>${equipment ? equipment.name : '<span class="muted">未绑定</span>'}</div>
|
||||
<div>${equipment ? equipment.name : '<span class="muted">Unbound</span>'}</div>
|
||||
<div class="point-role">${point.signal_role || "--"}</div>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -128,11 +204,19 @@ export async function loadPoints() {
|
|||
<td></td>
|
||||
`;
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `<option value="${item.value}" ${isSelected}>${item.label}</option>`;
|
||||
}).join("");
|
||||
}
|
||||
|
|
@ -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 = ['<option value="">Select source</option>'];
|
||||
state.sources.forEach((source) => {
|
||||
const selected = source.id === state.selectedSourceId ? "selected" : "";
|
||||
options.push(`<option value="${source.id}" ${selected}>${source.name}</option>`);
|
||||
});
|
||||
dom.pointSourceSelect.innerHTML = options.join("");
|
||||
}
|
||||
|
||||
export function renderSources() {
|
||||
dom.sourceList.innerHTML = "";
|
||||
|
||||
state.sources.forEach((source) => {
|
||||
const 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 = `
|
||||
<div class="row">
|
||||
<strong>${source.name}</strong>
|
||||
<span class="badge ${source.is_connected ? "" : "offline"}">${source.is_connected ? "在线" : "离线"}</span>
|
||||
<span class="badge ${source.is_connected ? "" : "offline"}">${source.is_connected ? "ONLINE" : "OFFLINE"}</span>
|
||||
</div>
|
||||
<div class="muted">${source.endpoint}</div>
|
||||
<div class="row source-card-actions"></div>
|
||||
`;
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const state = {
|
|||
selectedEquipmentId: null,
|
||||
selectedSourceId: null,
|
||||
selectedNodeIds: new Set(),
|
||||
selectedPointIds: new Set(),
|
||||
pointsPage: 1,
|
||||
pointsPageSize: 100,
|
||||
pointsTotal: 0,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue