feat(web): reorganize equipment layout and point flows

This commit is contained in:
caoqianming 2026-03-23 12:49:26 +08:00
parent 06ace5e67d
commit fec7b60d6b
13 changed files with 753 additions and 285 deletions

View File

@ -24,6 +24,7 @@ use crate::{
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
pub struct GetPointListQuery { pub struct GetPointListQuery {
pub source_id: Option<Uuid>, pub source_id: Option<Uuid>,
pub equipment_id: Option<Uuid>,
#[serde(flatten)] #[serde(flatten)]
pub pagination: PaginationParams, pub pagination: PaginationParams,
} }
@ -58,12 +59,13 @@ pub async fn get_point_list(
let pool = &state.pool; 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( let points = crate::service::get_points_paginated(
pool, pool,
query.source_id, query.source_id,
query.equipment_id,
query.pagination.page_size, query.pagination.page_size,
query.pagination.offset(), query.pagination.offset(),
) )
@ -161,6 +163,7 @@ pub struct BatchSetPointTagsReq {
pub struct BatchSetPointEquipmentReq { pub struct BatchSetPointEquipmentReq {
pub point_ids: Vec<Uuid>, pub point_ids: Vec<Uuid>,
pub equipment_id: Option<Uuid>, pub equipment_id: Option<Uuid>,
pub signal_role: Option<String>,
} }
/// Update point metadata (name/description/unit only). /// 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)); return Err(ApiErr::NotFound("No valid points found".to_string(), None));
} }
let result = let result = sqlx::query(
sqlx::query(r#"UPDATE point SET equipment_id = $1, updated_at = NOW() WHERE id = ANY($2)"#) r#"
UPDATE point
SET equipment_id = $1,
signal_role = $2,
updated_at = NOW()
WHERE id = ANY($3)
"#,
)
.bind(payload.equipment_id) .bind(payload.equipment_id)
.bind(payload.signal_role.as_deref())
.bind(&existing_points) .bind(&existing_points)
.execute(pool) .execute(pool)
.await?; .await?;

View File

@ -102,9 +102,25 @@ pub async fn get_points_with_ids(
pub async fn get_points_count( pub async fn get_points_count(
pool: &PgPool, pool: &PgPool,
source_id: Option<uuid::Uuid>, source_id: Option<uuid::Uuid>,
equipment_id: Option<uuid::Uuid>,
) -> Result<i64, sqlx::Error> { ) -> Result<i64, sqlx::Error> {
match source_id { match (source_id, equipment_id) {
Some(source_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>( sqlx::query_scalar::<_, i64>(
r#" r#"
SELECT COUNT(*) SELECT COUNT(*)
@ -117,7 +133,13 @@ pub async fn get_points_count(
.fetch_one(pool) .fetch_one(pool)
.await .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) .fetch_one(pool)
.await, .await,
} }
@ -126,11 +148,50 @@ pub async fn get_points_count(
pub async fn get_points_paginated( pub async fn get_points_paginated(
pool: &PgPool, pool: &PgPool,
source_id: Option<uuid::Uuid>, source_id: Option<uuid::Uuid>,
equipment_id: Option<uuid::Uuid>,
page_size: i32, page_size: i32,
offset: u32, offset: u32,
) -> Result<Vec<Point>, sqlx::Error> { ) -> Result<Vec<Point>, sqlx::Error> {
match source_id { match (source_id, equipment_id) {
Some(source_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 { if page_size == 0 {
Ok(vec![]) Ok(vec![])
} else if page_size == -1 { } else if page_size == -1 {
@ -164,7 +225,37 @@ pub async fn get_points_paginated(
.await .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 { if page_size == 0 {
Ok(vec![]) Ok(vec![])
} else if page_size == -1 { } else if page_size == -1 {

View File

@ -4,13 +4,13 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PLC Control</title> <title>PLC Control</title>
<link rel="stylesheet" href="/ui/styles.css?v=20260323b" /> <link rel="stylesheet" href="/ui/styles.css?v=20260323e" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="title">PLC Control</div> <div class="title">PLC Control</div>
<div class="topbar-actions"> <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> <button type="button" class="secondary" id="openApiDoc">API.md</button>
<div class="status" id="statusText">Ready</div> <div class="status" id="statusText">Ready</div>
</div> </div>
@ -19,10 +19,14 @@
<main class="grid"> <main class="grid">
<section class="panel top-left"> <section class="panel top-left">
<div class="panel-head"> <div class="panel-head">
<h2>数据源</h2> <h2>设备</h2>
<button id="openSourceForm">+ 新增</button> <button type="button" id="newEquipmentBtn">+ 新增</button>
</div> </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>
<section class="panel top-right"> <section class="panel top-right">
@ -34,15 +38,27 @@
<button class="secondary" id="nextPoints" title="下一页">&rsaquo;</button> <button class="secondary" id="nextPoints" title="下一页">&rsaquo;</button>
</div> </div>
</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"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th style="width:24%">名称</th> <th style="width:6%"></th>
<th style="width:18%"></th> <th style="width:22%">名称</th>
<th style="width:16%"></th>
<th style="width:10%">质量</th> <th style="width:10%">质量</th>
<th style="width:18%">设备/角色</th> <th style="width:18%">设备/角色</th>
<th style="width:23%">更新时间</th> <th style="width:21%">更新时间</th>
<th style="width:7%"></th> <th style="width:7%"></th>
</tr> </tr>
</thead> </thead>
@ -52,6 +68,14 @@
</section> </section>
<section class="panel bottom-left"> <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"> <div class="panel-head">
<h2>实时日志</h2> <h2>实时日志</h2>
</div> </div>
@ -70,6 +94,38 @@
</section> </section>
</main> </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 hidden" id="pointModal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-head"> <div class="modal-head">
@ -77,7 +133,9 @@
<button class="secondary" id="closeModal">X</button> <button class="secondary" id="closeModal">X</button>
</div> </div>
<div class="toolbar"> <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> <button class="secondary" id="refreshTree">刷新树</button>
</div> </div>
<div class="tree" id="nodeTree"></div> <div class="tree" id="nodeTree"></div>
@ -133,8 +191,8 @@
<select id="bindingEquipmentId"></select> <select id="bindingEquipmentId"></select>
</label> </label>
<label> <label>
角色 角色模板
<input id="bindingSignalRole" placeholder="remote_status / run_status / fault_status" /> <select id="bindingSignalRole"></select>
</label> </label>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="secondary" id="clearPointBinding">清空绑定</button> <button type="button" class="secondary" id="clearPointBinding">清空绑定</button>
@ -144,71 +202,28 @@
</div> </div>
</div> </div>
<div class="drawer-backdrop hidden" id="equipmentDrawer"> <div class="modal hidden" id="batchBindingModal">
<aside class="drawer equipment-drawer" role="dialog" aria-modal="true" aria-labelledby="equipmentTitle"> <div class="modal-content modal-sm">
<div class="drawer-head"> <div class="modal-head">
<h3 id="equipmentTitle">设备管理</h3> <h3>批量绑定点位</h3>
<button type="button" class="secondary" id="closeEquipmentDrawer">关闭</button> <button class="secondary" id="closeBatchBindingModal">X</button>
</div> </div>
<div class="drawer-body equipment-layout"> <form id="batchBindingForm" class="form">
<section class="equipment-sidebar"> <div class="muted" id="batchBindingSummary">已选中 0 个点位</div>
<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" />
<label> <label>
编码 设备
<input id="equipmentCode" required /> <select id="batchBindingEquipmentId"></select>
</label> </label>
<label> <label>
名称 角色模板
<input id="equipmentName" required /> <select id="batchBindingSignalRole"></select>
</label>
<label>
类型
<input id="equipmentKind" placeholder="coal_feeder / distributor" />
</label>
<label>
说明
<input id="equipmentDescription" />
</label> </label>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="secondary" id="equipmentReset">清空</button> <button type="button" class="secondary" id="clearBatchBinding">清空设备和角色</button>
<button type="submit" id="equipmentSubmit">保存</button> <button type="submit" id="saveBatchBinding">批量保存</button>
</div> </div>
</form> </form>
<div class="equipment-points">
<div class="panel-head">
<h2>设备点位</h2>
</div> </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>
<div class="drawer-backdrop hidden" id="apiDocDrawer"> <div class="drawer-backdrop hidden" id="apiDocDrawer">
@ -227,6 +242,6 @@
</aside> </aside>
</div> </div>
<script type="module" src="/ui/js/app.js?v=20260323b"></script> <script type="module" src="/ui/js/app.js?v=20260323e"></script>
</body> </body>
</html> </html>

View File

@ -3,29 +3,51 @@ import { openChart, renderChart } from "./chart.js";
import { dom } from "./dom.js"; import { dom } from "./dom.js";
import { closeApiDocDrawer, openApiDocDrawer } from "./docs.js"; import { closeApiDocDrawer, openApiDocDrawer } from "./docs.js";
import { import {
clearEquipmentFilter,
clearPointBinding, clearPointBinding,
closeEquipmentDrawer, closeEquipmentModal,
loadEquipments, loadEquipments,
openEquipmentDrawer, openCreateEquipmentModal,
resetEquipmentForm, resetEquipmentForm,
saveEquipment, saveEquipment,
} from "./equipment.js"; } from "./equipment.js";
import { startLogs, startPointSocket } from "./logs.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 { state } from "./state.js";
import { browseNodes, loadSources, saveSource } from "./sources.js"; import { loadSources, saveSource } from "./sources.js";
function bindEvents() { function bindEvents() {
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event))); dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
dom.equipmentForm.addEventListener("submit", (event) => withStatus(saveEquipment(event))); dom.equipmentForm.addEventListener("submit", (event) => withStatus(saveEquipment(event)));
dom.pointBindingForm.addEventListener("submit", (event) => withStatus(savePointBinding(event))); dom.pointBindingForm.addEventListener("submit", (event) => withStatus(savePointBinding(event)));
dom.batchBindingForm.addEventListener("submit", (event) => withStatus(saveBatchBinding(event)));
dom.sourceResetBtn.addEventListener("click", () => dom.sourceForm.reset()); dom.sourceResetBtn.addEventListener("click", () => dom.sourceForm.reset());
dom.equipmentResetBtn.addEventListener("click", resetEquipmentForm); dom.equipmentResetBtn.addEventListener("click", resetEquipmentForm);
dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments())); dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments()));
dom.newEquipmentBtn.addEventListener("click", 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.refreshTreeBtn.addEventListener("click", () => withStatus(loadTree()));
dom.createPointsBtn.addEventListener("click", () => withStatus(createPoints())); dom.createPointsBtn.addEventListener("click", () => withStatus(createPoints()));
dom.closeModalBtn.addEventListener("click", () => dom.pointModal.classList.add("hidden")); dom.closeModalBtn.addEventListener("click", () => dom.pointModal.classList.add("hidden"));
@ -42,8 +64,20 @@ function bindEvents() {
dom.pointBindingModal.classList.add("hidden"); dom.pointBindingModal.classList.add("hidden");
}); });
dom.openEquipmentDrawerBtn.addEventListener("click", openEquipmentDrawer); dom.openBatchBindingBtn.addEventListener("click", openBatchBinding);
dom.closeEquipmentDrawerBtn.addEventListener("click", closeEquipmentDrawer); 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.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer()));
dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer); dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer);
@ -81,6 +115,8 @@ function bindEvents() {
async function bootstrap() { async function bootstrap() {
bindEvents(); bindEvents();
renderSelectedNodes(); renderSelectedNodes();
updateSelectedPointSummary();
updatePointFilterSummary();
renderChart(); renderChart();
startLogs(); startLogs();
startPointSocket(); startPointSocket();

View File

@ -2,23 +2,54 @@ import { apiFetch } from "./api.js";
import { dom } from "./dom.js"; import { dom } from "./dom.js";
import { state } from "./state.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) { export async function openChart(pointId, pointName) {
state.chartPointId = pointId; state.chartPointId = pointId;
state.chartPointName = pointName || "点位"; state.chartPointName = pointName || "Point";
dom.chartTitle.textContent = `${state.chartPointName} 曲线`; dom.chartTitle.textContent = `${state.chartPointName} Chart`;
const items = await apiFetch(`/api/point/${pointId}/history?limit=120`); const items = await apiFetch(`/api/point/${pointId}/history?limit=120`);
state.chartData = (items || []) state.chartData = (items || [])
.map((item) => ({ .map(normalizeChartItem)
timestamp: item.timestamp || "",
valueNumber: typeof item.value_number === "number" ? item.value_number : null,
valueText: item.value_text || "",
}))
.filter((item) => item.valueNumber !== null); .filter((item) => item.valueNumber !== null);
renderChart(); 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() { export function renderChart() {
const ctx = dom.chartCanvas.getContext("2d"); const ctx = dom.chartCanvas.getContext("2d");
const width = dom.chartCanvas.width; const width = dom.chartCanvas.width;
@ -29,7 +60,7 @@ export function renderChart() {
ctx.fillStyle = "#94a3b8"; ctx.fillStyle = "#94a3b8";
ctx.font = "14px Segoe UI"; ctx.font = "14px Segoe UI";
ctx.fillText("Click a point row to view its chart", 24, 40); 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; return;
} }
@ -62,5 +93,5 @@ export function renderChart() {
ctx.stroke(); ctx.stroke();
const latest = state.chartData[state.chartData.length - 1]; 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}`;
} }

View File

@ -7,14 +7,21 @@ export const dom = {
pointList: byId("pointList"), pointList: byId("pointList"),
pointsPageInfo: byId("pointsPageInfo"), pointsPageInfo: byId("pointsPageInfo"),
selectedCount: byId("selectedCount"), selectedCount: byId("selectedCount"),
selectedPointCount: byId("selectedPointCount"),
pointFilterSummary: byId("pointFilterSummary"),
clearEquipmentFilterBtn: byId("clearEquipmentFilter"),
pointSourceSelect: byId("pointSourceSelect"),
pointSourceNodeCount: byId("pointSourceNodeCount"),
openPointModalBtn: byId("openPointModal"),
logView: byId("logView"), logView: byId("logView"),
chartCanvas: byId("chartCanvas"), chartCanvas: byId("chartCanvas"),
chartTitle: byId("chartTitle"), chartTitle: byId("chartTitle"),
chartSummary: byId("chartSummary"), chartSummary: byId("chartSummary"),
pointModal: byId("pointModal"), pointModal: byId("pointModal"),
sourceModal: byId("sourceModal"), sourceModal: byId("sourceModal"),
equipmentModal: byId("equipmentModal"),
pointBindingModal: byId("pointBindingModal"), pointBindingModal: byId("pointBindingModal"),
equipmentDrawer: byId("equipmentDrawer"), batchBindingModal: byId("batchBindingModal"),
apiDocDrawer: byId("apiDocDrawer"), apiDocDrawer: byId("apiDocDrawer"),
sourceForm: byId("sourceForm"), sourceForm: byId("sourceForm"),
sourceId: byId("sourceId"), sourceId: byId("sourceId"),
@ -31,16 +38,18 @@ export const dom = {
equipmentResetBtn: byId("equipmentReset"), equipmentResetBtn: byId("equipmentReset"),
equipmentKeyword: byId("equipmentKeyword"), equipmentKeyword: byId("equipmentKeyword"),
equipmentList: byId("equipmentList"), equipmentList: byId("equipmentList"),
equipmentPointList: byId("equipmentPointList"), closeEquipmentModalBtn: byId("closeEquipmentModal"),
pointBindingForm: byId("pointBindingForm"), pointBindingForm: byId("pointBindingForm"),
bindingPointId: byId("bindingPointId"), bindingPointId: byId("bindingPointId"),
bindingPointName: byId("bindingPointName"), bindingPointName: byId("bindingPointName"),
bindingEquipmentId: byId("bindingEquipmentId"), bindingEquipmentId: byId("bindingEquipmentId"),
bindingSignalRole: byId("bindingSignalRole"), bindingSignalRole: byId("bindingSignalRole"),
batchBindingForm: byId("batchBindingForm"),
batchBindingSummary: byId("batchBindingSummary"),
batchBindingEquipmentId: byId("batchBindingEquipmentId"),
batchBindingSignalRole: byId("batchBindingSignalRole"),
apiDocToc: byId("apiDocToc"), apiDocToc: byId("apiDocToc"),
apiDocContent: byId("apiDocContent"), apiDocContent: byId("apiDocContent"),
openEquipmentDrawerBtn: byId("openEquipmentDrawer"),
closeEquipmentDrawerBtn: byId("closeEquipmentDrawer"),
openApiDocBtn: byId("openApiDoc"), openApiDocBtn: byId("openApiDoc"),
closeApiDocBtn: byId("closeApiDoc"), closeApiDocBtn: byId("closeApiDoc"),
refreshChartBtn: byId("refreshChart"), refreshChartBtn: byId("refreshChart"),
@ -56,4 +65,9 @@ export const dom = {
closeSourceModalBtn: byId("closeSourceModal"), closeSourceModalBtn: byId("closeSourceModal"),
clearPointBindingBtn: byId("clearPointBinding"), clearPointBindingBtn: byId("clearPointBinding"),
closePointBindingModalBtn: byId("closePointBindingModal"), closePointBindingModalBtn: byId("closePointBindingModal"),
toggleAllPoints: byId("toggleAllPoints"),
openBatchBindingBtn: byId("openBatchBinding"),
clearSelectedPointsBtn: byId("clearSelectedPoints"),
closeBatchBindingModalBtn: byId("closeBatchBindingModal"),
clearBatchBindingBtn: byId("clearBatchBinding"),
}; };

View File

@ -1,14 +1,15 @@
import { apiFetch } from "./api.js"; import { apiFetch } from "./api.js";
import { dom } from "./dom.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"; import { state } from "./state.js";
function equipmentOf(item) { function equipmentOf(item) {
return item && item.equipment ? item.equipment : item; return item && item.equipment ? item.equipment : item;
} }
export function renderEquipmentOptions(selected = "") { export function renderBindingEquipmentOptions(selected = "", target = dom.bindingEquipmentId) {
const options = ['<option value="">未绑定</option>']; const options = ['<option value="">Unbound</option>'];
state.equipments.forEach((item) => { state.equipments.forEach((item) => {
const equipment = equipmentOf(item); const equipment = equipmentOf(item);
const isSelected = equipment.id === selected ? "selected" : ""; const isSelected = equipment.id === selected ? "selected" : "";
@ -16,67 +17,113 @@ export function renderEquipmentOptions(selected = "") {
`<option value="${equipment.id}" ${isSelected}>${equipment.code} / ${equipment.name}</option>`, `<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() { export function resetEquipmentForm() {
state.selectedEquipmentId = null;
dom.equipmentForm.reset(); dom.equipmentForm.reset();
dom.equipmentId.value = ""; 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() { export function renderEquipments() {
dom.equipmentList.innerHTML = ""; dom.equipmentList.innerHTML = "";
const activeEquipment = state.selectedEquipmentId
? state.equipmentMap.get(state.selectedEquipmentId) || null
: null;
dom.clearEquipmentFilterBtn.textContent = activeEquipment
? `设备筛选: ${activeEquipment.name}`
: "设备筛选: 全部";
if (!state.equipments.length) { if (!state.equipments.length) {
dom.equipmentList.innerHTML = '<div class="list-item"><div class="muted">暂无设备</div></div>'; dom.equipmentList.innerHTML = '<div class="list-item"><div class="muted">No equipment</div></div>';
dom.equipmentPointList.innerHTML =
'<tr><td colspan="3" class="empty-state">暂无设备点位</td></tr>';
return; return;
} }
state.equipments.forEach((item) => { state.equipments.forEach((item) => {
const equipment = equipmentOf(item); const equipment = equipmentOf(item);
const box = document.createElement("div"); 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 = ` box.innerHTML = `
<div class="row"> <div class="row">
<strong>${equipment.code}</strong> <strong>${equipment.code}</strong>
<span class="badge">${item.point_count ?? 0} pts</span> <span class="badge">${item.point_count ?? 0} pts</span>
</div> </div>
<div>${equipment.name}</div> <div>${equipment.name}</div>
<div class="muted">${equipment.kind || "未设置类型"}</div> <div class="muted">${equipment.kind || "No type"}</div>
<div class="row equipment-card-actions"></div>
`; `;
box.addEventListener("click", () => { box.addEventListener("click", () => {
state.selectedEquipmentId = equipment.id; selectEquipment(equipment.id).catch((error) => {
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) => {
dom.statusText.textContent = error.message; dom.statusText.textContent = error.message;
}); });
}); });
const actions = document.createElement("div"); const actionRow = box.querySelector(".equipment-card-actions");
actions.className = "row";
const editBtn = document.createElement("button");
editBtn.className = "secondary";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", (event) => {
event.stopPropagation();
openEditEquipmentModal(equipment);
});
const deleteBtn = document.createElement("button"); const deleteBtn = document.createElement("button");
deleteBtn.className = "danger"; deleteBtn.className = "danger";
deleteBtn.textContent = "删除"; deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", (event) => { deleteBtn.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
deleteEquipment(equipment.id).catch((error) => { deleteEquipment(equipment.id).catch((error) => {
dom.statusText.textContent = error.message; dom.statusText.textContent = error.message;
}); });
}); });
actions.appendChild(deleteBtn);
box.appendChild(actions); actionRow.append(editBtn, deleteBtn);
dom.equipmentList.appendChild(box); dom.equipmentList.appendChild(box);
}); });
} }
@ -94,54 +141,13 @@ export async function loadEquipments() {
return [equipment.id, equipment]; return [equipment.id, equipment];
}), }),
); );
renderEquipmentOptions(); renderBindingEquipmentOptions();
renderBatchBindingDefaults();
if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) {
state.selectedEquipmentId = null;
}
renderEquipments(); renderEquipments();
updatePointFilterSummary();
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);
});
} }
export async function saveEquipment(event) { export async function saveEquipment(event) {
@ -155,37 +161,37 @@ export async function saveEquipment(event) {
}; };
const id = dom.equipmentId.value; 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", method: id ? "PUT" : "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
closeEquipmentModal();
await loadEquipments(); await loadEquipments();
if (!id && result?.id) {
state.selectedEquipmentId = result.id;
}
renderEquipments();
updatePointFilterSummary();
await loadPoints(); await loadPoints();
} }
export async function deleteEquipment(equipmentId) { export async function deleteEquipment(equipmentId) {
if (!window.confirm("确认删除该设备?")) { if (!window.confirm("Delete this equipment?")) {
return; return;
} }
await apiFetch(`/api/equipment/${equipmentId}`, { method: "DELETE" }); await apiFetch(`/api/equipment/${equipmentId}`, { method: "DELETE" });
if (state.selectedEquipmentId === equipmentId) {
state.selectedEquipmentId = null;
}
resetEquipmentForm(); resetEquipmentForm();
closeEquipmentModal();
clearSelectedPoints();
await loadEquipments(); await loadEquipments();
await loadPoints(); 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) { export async function clearPointBinding(pointId = dom.bindingPointId.value) {
await apiFetch(`/api/point/${pointId}`, { await apiFetch(`/api/point/${pointId}`, {
method: "PUT", method: "PUT",

View File

@ -1,13 +1,54 @@
import { appendChartPoint } from "./chart.js";
import { dom } from "./dom.js"; import { dom } from "./dom.js";
import { formatValue } from "./points.js"; import { formatValue } from "./points.js";
import { state } from "./state.js"; import { state } from "./state.js";
function escapeHtml(text) {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
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) { export function appendLog(line) {
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
const div = document.createElement("div"); const div = document.createElement("div");
const parsed = parseLogLine(line);
if (!parsed) {
div.className = "log-line"; div.className = "log-line";
div.textContent = 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); dom.logView.appendChild(div);
if (atBottom) {
dom.logView.scrollTop = dom.logView.scrollHeight; dom.logView.scrollTop = dom.logView.scrollHeight;
}
} }
export function startLogs() { export function startLogs() {
@ -20,6 +61,9 @@ export function startLogs() {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
(data.lines || []).forEach(appendLog); (data.lines || []).forEach(appendLog);
}); });
state.logSource.addEventListener("error", () => {
appendLog("[log stream error]");
});
} }
export function startPointSocket() { export function startPointSocket() {
@ -36,14 +80,16 @@ export function startPointSocket() {
const data = payload.data; const data = payload.data;
const entry = state.pointEls.get(data.point_id); const entry = state.pointEls.get(data.point_id);
if (!entry) { if (entry) {
return;
}
entry.value.textContent = formatValue(data); entry.value.textContent = formatValue(data);
entry.quality.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`; entry.quality.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`;
entry.quality.textContent = (data.quality || "unknown").toUpperCase(); entry.quality.textContent = (data.quality || "unknown").toUpperCase();
entry.time.textContent = data.timestamp || "--"; entry.time.textContent = data.timestamp || "--";
}
if (state.chartPointId === data.point_id) {
appendChartPoint(data);
}
} catch { } catch {
// ignore malformed messages // ignore malformed messages
} }

View File

@ -1,9 +1,19 @@
import { apiFetch } from "./api.js"; import { apiFetch } from "./api.js";
import { openChart } from "./chart.js"; import { openChart } from "./chart.js";
import { dom } from "./dom.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"; import { state } from "./state.js";
function updatePointSourceNodeCount() {
const count = dom.nodeTree.querySelectorAll("details, summary").length;
dom.pointSourceNodeCount.textContent = `Nodes: ${count}`;
}
export function formatValue(monitor) { export function formatValue(monitor) {
if (!monitor) { if (!monitor) {
return "--"; return "--";
@ -18,7 +28,39 @@ export function formatValue(monitor) {
} }
export function renderSelectedNodes() { 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) { function renderNode(node) {
@ -55,15 +97,30 @@ function renderNode(node) {
return details; 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() { export async function loadTree() {
if (!state.selectedSourceId) { const sourceId = dom.pointSourceSelect.value || state.selectedSourceId;
dom.nodeTree.innerHTML = '<div class="muted">请选择数据源</div>'; if (!sourceId) {
dom.nodeTree.innerHTML = '<div class="muted">Select a source</div>';
dom.pointSourceNodeCount.textContent = "Nodes: 0";
return; 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 = ""; dom.nodeTree.innerHTML = "";
(data || []).forEach((node) => dom.nodeTree.appendChild(renderNode(node))); (data || []).forEach((node) => dom.nodeTree.appendChild(renderNode(node)));
updatePointSourceNodeCount();
} }
export async function createPoints() { export async function createPoints() {
@ -82,20 +139,38 @@ export async function createPoints() {
await loadPoints(); await loadPoints();
} }
export async function loadPoints() { function setPointSelected(pointId, checked) {
const sourceQuery = state.selectedSourceId ? `&source_id=${state.selectedSourceId}` : ""; if (checked) {
const data = await apiFetch( state.selectedPointIds.add(pointId);
`/api/point?page=${state.pointsPage}&page_size=${state.pointsPageSize}${sourceQuery}`, } 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 || []; const items = data.data || [];
state.pointsTotal = typeof data.total === "number" ? data.total : items.length; state.pointsTotal = typeof data.total === "number" ? data.total : items.length;
state.pointEls.clear(); state.pointEls.clear();
dom.pointList.innerHTML = ""; dom.pointList.innerHTML = "";
if (!items.length) { 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`; dom.pointsPageInfo.textContent = `${state.pointsPage} / 1`;
clearSelectedPoints();
updatePointFilterSummary();
return; return;
} }
@ -112,6 +187,7 @@ export async function loadPoints() {
}); });
tr.innerHTML = ` tr.innerHTML = `
<td></td>
<td> <td>
<div class="point-name">${point.name}</div> <div class="point-name">${point.name}</div>
<div class="point-id">${point.node_id}</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><span class="badge quality-${(monitor?.quality || "unknown").toLowerCase()}">${(monitor?.quality || "unknown").toUpperCase()}</span></td>
<td> <td>
<div class="point-meta"> <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 class="point-role">${point.signal_role || "--"}</div>
</div> </div>
</td> </td>
@ -128,11 +204,19 @@ export async function loadPoints() {
<td></td> <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"); const bindBtn = document.createElement("button");
bindBtn.className = "secondary"; bindBtn.className = "secondary";
bindBtn.textContent = "绑定"; bindBtn.textContent = "Bind";
bindBtn.addEventListener("click", (event) => { bindBtn.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
openPointBinding(point); openPointBinding(point);
@ -140,7 +224,7 @@ export async function loadPoints() {
const deleteBtn = document.createElement("button"); const deleteBtn = document.createElement("button");
deleteBtn.className = "danger"; deleteBtn.className = "danger";
deleteBtn.textContent = "删除"; deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", (event) => { deleteBtn.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
deletePoint(point.id).catch((error) => { deletePoint(point.id).catch((error) => {
@ -155,19 +239,24 @@ export async function loadPoints() {
row: tr, row: tr,
value: tr.querySelector(".point-value"), value: tr.querySelector(".point-value"),
quality: tr.querySelector(".badge"), 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)); const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
dom.pointsPageInfo.textContent = `${state.pointsPage} / ${totalPages}`; 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) { export function openPointBinding(point) {
dom.bindingPointId.value = point.id; dom.bindingPointId.value = point.id;
dom.bindingPointName.value = point.name || ""; dom.bindingPointName.value = point.name || "";
dom.bindingSignalRole.value = point.signal_role || ""; renderBindingEquipmentOptions(point.equipment_id || "");
renderEquipmentOptions(point.equipment_id || ""); dom.bindingSignalRole.innerHTML = renderRoleOptions(point.signal_role || "");
dom.pointBindingModal.classList.remove("hidden"); dom.pointBindingModal.classList.remove("hidden");
} }
@ -178,7 +267,7 @@ export async function savePointBinding(event) {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
equipment_id: dom.bindingEquipmentId.value || null, 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(); 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) { export async function deletePoint(pointId) {
if (!window.confirm("确认删除该点位?")) { if (!window.confirm("Delete this point?")) {
return; return;
} }
await apiFetch(`/api/point/${pointId}`, { method: "DELETE" }); await apiFetch(`/api/point/${pointId}`, { method: "DELETE" });
state.selectedPointIds.delete(pointId);
await loadPoints(); await loadPoints();
} }

24
web/js/roles.js Normal file
View File

@ -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("");
}

View File

@ -1,48 +1,47 @@
import { apiFetch } from "./api.js"; import { apiFetch } from "./api.js";
import { dom } from "./dom.js"; import { dom } from "./dom.js";
import { loadPoints, loadTree, renderSelectedNodes } from "./points.js"; import { loadPoints, updatePointFilterSummary } from "./points.js";
import { state } from "./state.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() { export function renderSources() {
dom.sourceList.innerHTML = ""; dom.sourceList.innerHTML = "";
state.sources.forEach((source) => { state.sources.forEach((source) => {
const item = document.createElement("div"); const card = document.createElement("div");
item.className = `list-item ${state.selectedSourceId === source.id ? "selected" : ""}`; card.className = `list-item source-card ${state.selectedSourceId === source.id ? "selected" : ""}`;
item.innerHTML = ` card.innerHTML = `
<div class="row"> <div class="row">
<strong>${source.name}</strong> <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>
<div class="muted">${source.endpoint}</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) => { selectSource(source.id).catch((error) => {
dom.statusText.textContent = error.message; dom.statusText.textContent = error.message;
}); });
}); });
const actions = document.createElement("div"); const actionRow = card.querySelector(".source-card-actions");
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 editBtn = document.createElement("button"); const editBtn = document.createElement("button");
editBtn.className = "secondary"; editBtn.className = "secondary";
editBtn.textContent = "编辑"; editBtn.textContent = "Edit";
editBtn.addEventListener("click", (event) => { editBtn.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
dom.sourceId.value = source.id; dom.sourceId.value = source.id;
@ -54,7 +53,7 @@ export function renderSources() {
const reconnectBtn = document.createElement("button"); const reconnectBtn = document.createElement("button");
reconnectBtn.className = "secondary"; reconnectBtn.className = "secondary";
reconnectBtn.textContent = "重连"; reconnectBtn.textContent = "Reconnect";
reconnectBtn.addEventListener("click", (event) => { reconnectBtn.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
reconnectSource(source.id, source.name).catch((error) => { reconnectSource(source.id, source.name).catch((error) => {
@ -64,7 +63,7 @@ export function renderSources() {
const deleteBtn = document.createElement("button"); const deleteBtn = document.createElement("button");
deleteBtn.className = "danger"; deleteBtn.className = "danger";
deleteBtn.textContent = "删除"; deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", (event) => { deleteBtn.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
deleteSource(source.id).catch((error) => { deleteSource(source.id).catch((error) => {
@ -72,25 +71,30 @@ export function renderSources() {
}); });
}); });
actions.append(selectBtn, editBtn, reconnectBtn, deleteBtn); actionRow.append(editBtn, reconnectBtn, deleteBtn);
item.appendChild(actions); card.appendChild(actionRow);
dom.sourceList.appendChild(item); dom.sourceList.appendChild(card);
}); });
renderPointSourceOptions();
} }
export async function loadSources() { export async function loadSources() {
state.sources = await apiFetch("/api/source"); state.sources = await apiFetch("/api/source");
if (state.selectedSourceId && !state.sources.some((item) => item.id === state.selectedSourceId)) {
state.selectedSourceId = null;
}
renderSources(); renderSources();
updatePointFilterSummary();
} }
export async function selectSource(sourceId) { export async function selectSource(sourceId) {
state.selectedSourceId = sourceId; state.selectedSourceId = state.selectedSourceId === sourceId ? null : sourceId;
state.selectedNodeIds.clear(); state.selectedNodeIds.clear();
state.pointsPage = 1; state.pointsPage = 1;
renderSources(); renderSources();
renderSelectedNodes(); updatePointFilterSummary();
await loadPoints(); await loadPoints();
await loadTree();
} }
export async function saveSource(event) { export async function saveSource(event) {
@ -113,27 +117,22 @@ export async function saveSource(event) {
await loadSources(); 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) { 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 apiFetch(`/api/source/${sourceId}/reconnect`, { method: "POST" });
await loadSources(); await loadSources();
dom.statusText.textContent = "Ready"; dom.statusText.textContent = "Ready";
} }
export async function browseNodes() { export async function deleteSource(sourceId) {
if (!state.selectedSourceId) { if (!window.confirm("Delete this source?")) {
throw new Error("请先选择数据源"); return;
} }
await apiFetch(`/api/source/${state.selectedSourceId}/browse`, { method: "POST" }); await apiFetch(`/api/source/${sourceId}`, { method: "DELETE" });
await loadTree(); if (state.selectedSourceId === sourceId) {
state.selectedSourceId = null;
}
await loadSources();
await loadPoints();
} }

View File

@ -5,6 +5,7 @@ export const state = {
selectedEquipmentId: null, selectedEquipmentId: null,
selectedSourceId: null, selectedSourceId: null,
selectedNodeIds: new Set(), selectedNodeIds: new Set(),
selectedPointIds: new Set(),
pointsPage: 1, pointsPage: 1,
pointsPageSize: 100, pointsPageSize: 100,
pointsTotal: 0, pointsTotal: 0,

View File

@ -81,7 +81,7 @@ body {
.grid { .grid {
display: 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; grid-template-rows: 1fr 380px;
gap: 1px; gap: 1px;
height: calc(100vh - var(--topbar-h)); height: calc(100vh - var(--topbar-h));
@ -89,18 +89,9 @@ body {
.panel.top-left { grid-column: 1; grid-row: 1; } .panel.top-left { grid-column: 1; grid-row: 1; }
.panel.top-right { grid-column: 2 / 4; grid-row: 1; } .panel.top-right { grid-column: 2 / 4; grid-row: 1; }
.panel.bottom-left { grid-column: 1; grid-row: 2; min-height: 0; }
.panel.bottom-left { .panel.bottom-middle { grid-column: 2; grid-row: 2; min-height: 0; }
grid-column: 1 / 3; .panel.bottom-right { grid-column: 3; grid-row: 2; min-height: 0; }
grid-row: 2;
min-height: 0;
}
.panel.bottom-right {
grid-column: 3;
grid-row: 2;
min-height: 0;
}
.panel { .panel {
background: var(--surface); background: var(--surface);
@ -213,6 +204,28 @@ button.danger:hover { background: var(--danger-hover); }
gap: 6px; 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 { .list-item button {
padding: 2px 8px; padding: 2px 8px;
font-size: 11px; font-size: 11px;
@ -353,6 +366,34 @@ button.danger:hover { background: var(--danger-hover); }
flex-shrink: 0; 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 ─────────────────────────────────────────── */
.form { .form {
@ -660,6 +701,10 @@ button.danger:hover { background: var(--danger-hover); }
flex-direction: column; flex-direction: column;
} }
.equipment-role-hint {
padding: 8px 14px 0;
}
.compact-table td, .compact-table td,
.compact-table th { .compact-table th {
padding-top: 6px; padding-top: 6px;
@ -677,6 +722,13 @@ button.danger:hover { background: var(--danger-hover); }
color: var(--text-3); color: var(--text-3);
} }
.inline-select {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
background: var(--surface);
}
.doc-toc { .doc-toc {
border-right: 1px solid var(--border-light); border-right: 1px solid var(--border-light);
background: var(--surface-2); background: var(--surface-2);
@ -872,14 +924,15 @@ button.danger:hover { background: var(--danger-hover); }
@media (max-width: 900px) { @media (max-width: 900px) {
.grid { .grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto auto; grid-template-rows: auto auto auto auto;
height: auto; height: auto;
} }
body { height: auto; overflow: auto; } body { height: auto; overflow: auto; }
.panel.top-left { min-height: 200px; } .panel.top-left { min-height: 200px; }
.panel.top-right { min-height: 300px; } .panel.top-right { min-height: 300px; }
.panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; } .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 { width: 100vw; }
.drawer-body { grid-template-columns: 1fr; } .drawer-body { grid-template-columns: 1fr; }
.equipment-layout { grid-template-columns: 1fr; } .equipment-layout { grid-template-columns: 1fr; }