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)]
|
#[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,12 +347,20 @@ 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#"
|
||||||
.bind(payload.equipment_id)
|
UPDATE point
|
||||||
.bind(&existing_points)
|
SET equipment_id = $1,
|
||||||
.execute(pool)
|
signal_role = $2,
|
||||||
.await?;
|
updated_at = NOW()
|
||||||
|
WHERE id = ANY($3)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(payload.equipment_id)
|
||||||
|
.bind(payload.signal_role.as_deref())
|
||||||
|
.bind(&existing_points)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"ok_msg": "Point equipment updated successfully",
|
"ok_msg": "Point equipment updated successfully",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
167
web/index.html
167
web/index.html
|
|
@ -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="下一页">›</button>
|
<button class="secondary" id="nextPoints" title="下一页">›</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">
|
<label>
|
||||||
<h2>设备列表</h2>
|
设备
|
||||||
<button type="button" id="newEquipmentBtn">+ 新增</button>
|
<select id="batchBindingEquipmentId"></select>
|
||||||
</div>
|
</label>
|
||||||
<div class="toolbar equipment-toolbar">
|
<label>
|
||||||
<input id="equipmentKeyword" placeholder="搜索编码或名称" />
|
角色模板
|
||||||
<button type="button" class="secondary" id="refreshEquipmentBtn">刷新</button>
|
<select id="batchBindingSignalRole"></select>
|
||||||
</div>
|
</label>
|
||||||
<div class="list" id="equipmentList"></div>
|
<div class="form-actions">
|
||||||
</section>
|
<button type="button" class="secondary" id="clearBatchBinding">清空设备和角色</button>
|
||||||
<section class="equipment-content">
|
<button type="submit" id="saveBatchBinding">批量保存</button>
|
||||||
<div class="panel-head">
|
</div>
|
||||||
<h2>设备详情</h2>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<form id="equipmentForm" class="form equipment-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 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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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("&", "&")
|
||||||
|
.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) {
|
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");
|
||||||
div.className = "log-line";
|
const parsed = parseLogLine(line);
|
||||||
div.textContent = 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);
|
dom.logView.appendChild(div);
|
||||||
dom.logView.scrollTop = dom.logView.scrollHeight;
|
if (atBottom) {
|
||||||
|
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.quality.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`;
|
||||||
|
entry.quality.textContent = (data.quality || "unknown").toUpperCase();
|
||||||
|
entry.time.textContent = data.timestamp || "--";
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.value.textContent = formatValue(data);
|
if (state.chartPointId === data.point_id) {
|
||||||
entry.quality.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`;
|
appendChartPoint(data);
|
||||||
entry.quality.textContent = (data.quality || "unknown").toUpperCase();
|
}
|
||||||
entry.time.textContent = data.timestamp || "--";
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed messages
|
// ignore malformed messages
|
||||||
}
|
}
|
||||||
|
|
|
||||||
181
web/js/points.js
181
web/js/points.js
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue